From 5648a3346f010b224c79e7b04adaf91dbd4673bb Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:37:42 +0300 Subject: [PATCH 1/6] Upgrade `der` to 0.8 (#7695) * Update `der` to 0.8, move to workspace dependencies * Add `pem` feature --- Cargo.lock | 31 ++++++++++++++++++++++++------- Cargo.toml | 1 + crates/stdlib/Cargo.toml | 2 +- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d13adab616..82dd5aedfb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,6 +663,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -988,13 +994,24 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468 0.7.0", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -2518,7 +2535,7 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes", "cbc", - "der", + "der 0.7.10", "pbkdf2", "scrypt", "sha2", @@ -2531,7 +2548,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "pkcs5", "rand_core 0.6.4", "spki", @@ -3409,7 +3426,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "csv-core", - "der", + "der 0.8.0", "digest", "dns-lookup", "dyn-clone", @@ -3902,7 +3919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -4880,8 +4897,8 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", - "der", + "const-oid 0.9.6", + "der 0.7.10", "sha1", "signature", "spki", diff --git a/Cargo.toml b/Cargo.toml index 9dcf4126f38..68ac5686d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,6 +170,7 @@ ruff_source_file = { package = "rustpython-ruff_source_file", version = "0.15.8" # 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" } +der = { version = "0.8", features = ["alloc", "oid", "pem", "zeroize"] } phf = { version = "0.13.1", default-features = false, features = ["macros"]} ahash = "0.8.12" ascii = "1.1" diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index f87bc99dcdd..606dae21d6d 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -128,7 +128,7 @@ rustls-pemfile = { version = "2.2", optional = true } rustls-platform-verifier = { version = "0.7", optional = true } x509-cert = { version = "0.2.5", features = ["pem", "builder"], optional = true } x509-parser = { version = "0.18", optional = true } -der = { version = "0.7", features = ["alloc", "oid"], optional = true } +der = { workspace = true, optional = true } pem-rfc7468 = { version = "1.0", features = ["alloc"], optional = true } webpki-roots = { version = "1.0", optional = true } aws-lc-rs = { version = "1.16.3", optional = true } From 1fa676fd0706507a2c050a9eee02e4da49e399ab Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:37:55 +0300 Subject: [PATCH 2/6] Upgrade cspell to v10.0.0 (#7696) * Update cspell to `v10.0.0` * Force node version to be 24 * Ensure node24 * Disable cache --- .github/workflows/ci.yaml | 8 +++++++- .pre-commit-config.yaml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b70b4142936..ac1060859ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ env: CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_RELEASE_DEBUG: 0 CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' # TODO: Remove on 2026/06/02 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # TODO: Remove on 2026/06/02 jobs: rust_tests: @@ -411,6 +411,12 @@ jobs: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek + # TODO: Remove on 2026/06/02 when node24 is the default + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + package-manager-cache: false + node-version: "24" + - name: prek id: prek uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6dd0afc7221..fbf5f1b4618 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: priority: 1 # so rustfmt runs first - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v9.7.0 + rev: v10.0.0 hooks: - id: cspell types: [rust] From f10f4418549055be80824fecefed3ba92d3b7e0f Mon Sep 17 00:00:00 2001 From: Changjoon Date: Mon, 27 Apr 2026 21:38:44 +0900 Subject: [PATCH 3/6] Defer staticmethod/classmethod callable storage to __init__ (#7697) CPython's staticmethod and classmethod set __func__ and copy wrapper attributes (__doc__, __name__, etc.) only inside __init__ (Objects/funcobject.c::sm_init / cm_init). RustPython did this work in slot_new and again in __init__, so subclasses that override __init__ without calling super().__init__() saw __func__ pointing at the original callable instead of None. Move the callable assignment and the wrapper-attribute copy into Initializer::init; slot_new now just validates the signature and stores None for the callable, matching the CPython contract. --- Lib/test/test_descr.py | 2 -- crates/vm/src/builtins/classmethod.rs | 50 ++++++++++++-------------- crates/vm/src/builtins/staticmethod.rs | 27 +++++++------- 3 files changed, 35 insertions(+), 44 deletions(-) diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index 615212d6024..1fb477823bf 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -5193,7 +5193,6 @@ def foo(self): with self.assertRaisesRegex(NotImplementedError, "BAR"): B().foo - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_staticmethod_new(self): class MyStaticMethod(staticmethod): def __init__(self, func): @@ -5204,7 +5203,6 @@ def func(): pass self.assertIsNone(sm.__func__) self.assertIsNone(sm.__wrapped__) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_classmethod_new(self): class MyClassMethod(classmethod): def __init__(self, func): diff --git a/crates/vm/src/builtins/classmethod.rs b/crates/vm/src/builtins/classmethod.rs index 8955d31ce40..f2821c3f16f 100644 --- a/crates/vm/src/builtins/classmethod.rs +++ b/crates/vm/src/builtins/classmethod.rs @@ -66,34 +66,15 @@ impl Constructor for PyClassMethod { type Args = PyObjectRef; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let callable: Self::Args = args.bind(vm)?; - // Create a dictionary to hold copied attributes - let dict = vm.ctx.new_dict(); - - // Copy attributes from the callable to the dict - // This is similar to functools.wraps in CPython - if let Ok(doc) = callable.get_attr("__doc__", vm) { - dict.set_item(identifier!(vm.ctx, __doc__), doc, vm)?; - } - if let Ok(name) = callable.get_attr("__name__", vm) { - dict.set_item(identifier!(vm.ctx, __name__), name, vm)?; - } - if let Ok(qualname) = callable.get_attr("__qualname__", vm) { - dict.set_item(identifier!(vm.ctx, __qualname__), qualname, vm)?; - } - if let Ok(module) = callable.get_attr("__module__", vm) { - dict.set_item(identifier!(vm.ctx, __module__), module, vm)?; - } - if let Ok(annotations) = callable.get_attr("__annotations__", vm) { - dict.set_item(identifier!(vm.ctx, __annotations__), annotations, vm)?; - } - - // Create PyClassMethod instance with the pre-populated dict + // Validate the signature here, but defer storing the callable and + // copying its attributes to `__init__` so that subclasses overriding + // `__init__` without calling `super().__init__()` see `__func__` as + // `None`, matching CPython. + let _: Self::Args = args.bind(vm)?; let classmethod = Self { - callable: PyMutex::new(callable), + callable: PyMutex::new(vm.ctx.none()), }; - - let result = PyRef::new_ref(classmethod, cls, Some(dict)); + let result = PyRef::new_ref(classmethod, cls, Some(vm.ctx.new_dict())); Ok(PyObjectRef::from(result)) } @@ -105,8 +86,21 @@ impl Constructor for PyClassMethod { impl Initializer for PyClassMethod { type Args = PyObjectRef; - fn init(zelf: PyRef, callable: Self::Args, _vm: &VirtualMachine) -> PyResult<()> { - *zelf.callable.lock() = callable; + fn init(zelf: PyRef, callable: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + *zelf.callable.lock() = callable.clone(); + // Copy wrapper attributes from the callable, mirroring functools.wraps. + let dict = zelf.as_object().dict().expect("classmethod has __dict__"); + for attr in [ + identifier!(vm.ctx, __doc__), + identifier!(vm.ctx, __name__), + identifier!(vm.ctx, __qualname__), + identifier!(vm.ctx, __module__), + identifier!(vm.ctx, __annotations__), + ] { + if let Ok(value) = callable.get_attr(attr, vm) { + dict.set_item(attr, value, vm)?; + } + } Ok(()) } } diff --git a/crates/vm/src/builtins/staticmethod.rs b/crates/vm/src/builtins/staticmethod.rs index 2554fa816aa..551e1cb4b88 100644 --- a/crates/vm/src/builtins/staticmethod.rs +++ b/crates/vm/src/builtins/staticmethod.rs @@ -1,6 +1,6 @@ use super::{PyGenericAlias, PyStr, PyType, PyTypeRef}; use crate::{ - Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, + AsObject, Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, common::lock::PyMutex, function::{FuncArgs, PySetterValue}, @@ -44,20 +44,16 @@ impl Constructor for PyStaticMethod { type Args = PyObjectRef; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let callable: Self::Args = args.bind(vm)?; - let doc = callable.get_attr("__doc__", vm); - + // Validate the signature here, but defer storing the callable and + // copying its attributes to `__init__` so that subclasses overriding + // `__init__` without calling `super().__init__()` see `__func__` as + // `None`, matching CPython. + let _: Self::Args = args.bind(vm)?; let result = Self { - callable: PyMutex::new(callable), + callable: PyMutex::new(vm.ctx.none()), } .into_ref_with_type(vm, cls)?; - let obj = PyObjectRef::from(result); - - if let Ok(doc) = doc { - obj.set_attr("__doc__", doc, vm)?; - } - - Ok(obj) + Ok(PyObjectRef::from(result)) } fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { @@ -80,8 +76,11 @@ impl PyStaticMethod { impl Initializer for PyStaticMethod { type Args = PyObjectRef; - fn init(zelf: PyRef, callable: Self::Args, _vm: &VirtualMachine) -> PyResult<()> { - *zelf.callable.lock() = callable; + fn init(zelf: PyRef, callable: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + *zelf.callable.lock() = callable.clone(); + if let Ok(doc) = callable.get_attr("__doc__", vm) { + zelf.as_object().set_attr("__doc__", doc, vm)?; + } Ok(()) } } From dc81c740cfcc5a405d4939d4f2fcb27776d219b0 Mon Sep 17 00:00:00 2001 From: Changjoon Date: Mon, 27 Apr 2026 21:39:21 +0900 Subject: [PATCH 4/6] Match CPython wording for __slots__ conflict and __doc__ delete errors (#7698) The behavior already matched CPython (the slot conflict is detected, the __doc__ delete is rejected); only the message text drifted. - "__slots__ conflicts with a class variable" -> drop the stray "a" to match CPython's "conflicts with class variable". - "cannot delete '__doc__' attribute of type 'X'" -> insert "immutable" before "type" to match CPython's wording (CPython surfaces the same phrase even for user-defined classes since the descriptor refuses the delete unconditionally). --- Lib/test/test_descr.py | 2 -- crates/vm/src/builtins/type.rs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index 1fb477823bf..d19789537f3 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -4952,7 +4952,6 @@ class A(int): with self.assertRaises(TypeError): a + a - @unittest.expectedFailure # TODO: RUSTPYTHON def test_slot_shadows_class_variable(self): with self.assertRaises(ValueError) as cm: class X: @@ -4961,7 +4960,6 @@ class X: m = str(cm.exception) self.assertEqual("'foo' in __slots__ conflicts with class variable", m) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_set_doc(self): class X: "elephant" diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 1cf8119e9c9..cfa08dcf5a6 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -2004,7 +2004,7 @@ impl Constructor for PyType { // Check if slot name conflicts with class attributes if attributes.contains_key(vm.ctx.intern_str(slot.as_wtf8())) { return Err(vm.new_value_error(format!( - "'{}' in __slots__ conflicts with a class variable", + "'{}' in __slots__ conflicts with class variable", slot.as_wtf8() ))); } @@ -2404,7 +2404,7 @@ impl Py { // Similar to CPython's type_set_doc let value = value.ok_or_else(|| { vm.new_type_error(format!( - "cannot delete '__doc__' attribute of type '{}'", + "cannot delete '__doc__' attribute of immutable type '{}'", self.name() )) })?; From 9794ab7fdf32dc3a17fa0450708948351fe22125 Mon Sep 17 00:00:00 2001 From: Changjoon Date: Mon, 27 Apr 2026 21:41:40 +0900 Subject: [PATCH 5/6] Enforce int_max_str_digits on int-to-str conversions (#7688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enforce int_max_str_digits on int-to-str conversions The str-to-int direction already enforced sys.get_int_max_str_digits() via bytes_to_int; the int-to-str direction did not. CPython 3.14 enforces both per PEP 644. Adds check_int_to_str_digits helper in builtins::int (bit-count fast path + digit upper-bound from log10(2)), wired into the four Python-level entry points: repr, the str fast path in protocol::object, int.__format__ (decimal/n/empty spec only — binary bases x/o/b are exempt per CPython), and the DecimalD/I/U branches of vm::cformat for both str % and bytes %. Unmasks 8 expectedFailure tests across test_int (max_str_digits, DoS prevention, int_from_other_bases — each mirrored in IntSubclass), test_ast (test_repr_large_input_crash) and test_reprlib (test_numbers). Boundary cases (4299/4300/4301 digits at limit=4300) match CPython 3.14.4. * Skip int-to-str DoS test on platforms without time.get_clock_info The test_denial_of_service_prevented_int_to_str regression test uses support.Stopwatch, which calls time.get_clock_info('monotonic'). In RustPython that function is gated to unix/windows targets only, so on wasm32-wasip1 it surfaces as AttributeError and breaks the wasm-wasi CI. Guard the test with skipUnless(hasattr(time, 'get_clock_info'), ...) so it runs everywhere it can and is skipped on wasm. Also narrow is_decimal_int_format to Number(Case::Lower): 'N' is rejected by format_int as UnknownFormatCode, so excluding it preserves that error path instead of intercepting it with the digit-limit check. * Add TODO: RUSTPYTHON marker to skipUnless reason scripts/update_lib uses TODO: RUSTPYTHON markers inside unittest decorator reason strings to identify and migrate custom RustPython patches across CPython library updates. * Use expectedFailureIf for wasm get_clock_info gap skipUnless silently hides the test forever; expectedFailureIf surfaces unexpected success once RustPython implements time.get_clock_info on wasm, prompting marker removal. --- Lib/test/test_ast/test_ast.py | 1 - Lib/test/test_int.py | 8 ++++--- Lib/test/test_reprlib.py | 1 - crates/common/src/format.rs | 11 +++++++++ crates/vm/src/builtins/int.rs | 30 ++++++++++++++++++++++++- crates/vm/src/cformat.rs | 19 ++++++++++------ crates/vm/src/protocol/object.rs | 3 ++- extra_tests/snippets/builtin_int.py | 35 +++++++++++++++++++++++++++++ 8 files changed, 94 insertions(+), 14 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 7142064dd77..0578b755d65 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1057,7 +1057,6 @@ def test_repr(self) -> None: with self.subTest(test_input=test): self.assertEqual(repr(ast.parse(test)), snapshot) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised def test_repr_large_input_crash(self): # gh-125010: Fix use-after-free in ast repr() source = "0x0" + "e" * 10_000 diff --git a/Lib/test/test_int.py b/Lib/test/test_int.py index f7b26e37e3a..e61b1c65799 100644 --- a/Lib/test/test_int.py +++ b/Lib/test/test_int.py @@ -1,4 +1,5 @@ import sys +import time import unittest # TODO: RUSTPYTHON @@ -573,7 +574,6 @@ def check(self, i, base=None): else: self.int_class(i, base) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_max_str_digits(self): maxdigits = sys.get_int_max_str_digits() @@ -588,7 +588,10 @@ def test_max_str_digits(self): with self.assertRaises(ValueError): str(i) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailureIf( + not hasattr(time, "get_clock_info"), + "TODO: RUSTPYTHON; time.get_clock_info is not available on wasm", + ) def test_denial_of_service_prevented_int_to_str(self): """Regression test: ensure we fail before performing O(N**2) work.""" maxdigits = sys.get_int_max_str_digits() @@ -713,7 +716,6 @@ def _other_base_helper(self, base): with self.assertRaises(ValueError) as err: int_class(f'{s}1', base) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_int_from_other_bases(self): base = 3 with self.subTest(base=base): diff --git a/Lib/test/test_reprlib.py b/Lib/test/test_reprlib.py index 3396b54cc9f..db3d87bd17a 100644 --- a/Lib/test/test_reprlib.py +++ b/Lib/test/test_reprlib.py @@ -150,7 +150,6 @@ def test_frozenset(self): eq(r(frozenset({1, 2, 3, 4, 5, 6})), "frozenset({1, 2, 3, 4, 5, 6})") eq(r(frozenset({1, 2, 3, 4, 5, 6, 7})), "frozenset({1, 2, 3, 4, 5, 6, ...})") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_numbers(self): for x in [123, 1.0 / 3]: self.assertEqual(r(x), repr(x)) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 4d31acf7a3f..9b769a038e7 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -478,6 +478,17 @@ impl FormatSpec { matches!(self.format_type, Some(FormatType::Number(Case::Lower))) } + /// Returns true if this format spec produces a decimal int representation + /// subject to `sys.get_int_max_str_digits()` (no spec, 'd', or 'n'). + /// Binary bases ('b', 'o', 'x', 'X') are exempt per CPython. 'N' is rejected + /// later in `format_int` as `UnknownFormatCode`, so it is not included here. + pub fn is_decimal_int_format(&self) -> bool { + matches!( + self.format_type, + None | Some(FormatType::Decimal) | Some(FormatType::Number(Case::Lower)) + ) + } + /// Insert locale-aware thousands separators into an integer string. /// Follows CPython's GroupGenerator logic for variable-width grouping. fn insert_locale_grouping(int_part: &str, locale: &LocaleInfo) -> String { diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 0ae1c6d8654..bf92d49c247 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -500,6 +500,9 @@ impl PyInt { } let format_spec = FormatSpec::parse(spec.as_str()).map_err(|err| err.into_pyexception(vm))?; + if format_spec.is_decimal_int_format() { + check_int_to_str_digits(&zelf.value, vm)?; + } let result = if format_spec.has_locale_format() { let locale = crate::format::get_locale_info(); format_spec.format_int_locale(&zelf.value, &locale) @@ -655,9 +658,34 @@ impl Comparable for PyInt { } } +/// Pre-format check enforcing `sys.get_int_max_str_digits()` on int → str conversions. +/// Mirrors CPython's PEP 644 DoS mitigation. Cheap fast-path for small values via +/// bit-count upper bound on decimal digits. +pub(crate) fn check_int_to_str_digits(value: &BigInt, vm: &VirtualMachine) -> PyResult<()> { + let limit = vm.state.int_max_str_digits.load(); + if limit == 0 { + return Ok(()); + } + let bits = value.bits(); + // Below ~452 decimal digits: definitely under any reasonable limit. + if bits < 1500 { + return Ok(()); + } + // Upper bound on decimal digit count: ⌈bits × log10(2)⌉ + 1, with log10(2) ≈ 0.30103. + let digits_upper = (bits as usize * 30103 / 100000) + 1; + if digits_upper > limit { + return Err(vm.new_value_error(format!( + "Exceeds the limit ({limit} digits) for integer string conversion; \ + use sys.set_int_max_str_digits() to increase the limit" + ))); + } + Ok(()) +} + impl Representable for PyInt { #[inline] - fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { + fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { + check_int_to_str_digits(&zelf.value, vm)?; Ok(zelf.to_str_radix_10()) } } diff --git a/crates/vm/src/cformat.rs b/crates/vm/src/cformat.rs index 5421b21d416..87b943f890a 100644 --- a/crates/vm/src/cformat.rs +++ b/crates/vm/src/cformat.rs @@ -9,7 +9,8 @@ use crate::{ AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, builtins::{ - PyBaseExceptionRef, PyByteArray, PyBytes, PyFloat, PyInt, PyStr, try_f64_to_bigint, tuple, + PyBaseExceptionRef, PyByteArray, PyBytes, PyFloat, PyInt, PyStr, + int::check_int_to_str_digits, try_f64_to_bigint, tuple, }, function::ArgIntoFloat, protocol::PyBuffer, @@ -54,17 +55,19 @@ fn spec_format_bytes( CNumberType::DecimalD | CNumberType::DecimalI | CNumberType::DecimalU => { match_class!(match &obj { ref i @ PyInt => { + check_int_to_str_digits(i.as_bigint(), vm)?; Ok(spec.format_number(i.as_bigint()).into_bytes()) } ref f @ PyFloat => { - Ok(spec - .format_number(&try_f64_to_bigint(f.to_f64(), vm)?) - .into_bytes()) + let bigint = try_f64_to_bigint(f.to_f64(), vm)?; + check_int_to_str_digits(&bigint, vm)?; + Ok(spec.format_number(&bigint).into_bytes()) } obj => { if let Some(method) = vm.get_method(obj.clone(), identifier!(vm, __int__)) { let result = method?.call((), vm)?; if let Some(i) = result.downcast_ref::() { + check_int_to_str_digits(i.as_bigint(), vm)?; return Ok(spec.format_number(i.as_bigint()).into_bytes()); } } @@ -149,17 +152,19 @@ fn spec_format_string( CNumberType::DecimalD | CNumberType::DecimalI | CNumberType::DecimalU => { match_class!(match &obj { ref i @ PyInt => { + check_int_to_str_digits(i.as_bigint(), vm)?; Ok(spec.format_number(i.as_bigint()).into()) } ref f @ PyFloat => { - Ok(spec - .format_number(&try_f64_to_bigint(f.to_f64(), vm)?) - .into()) + let bigint = try_f64_to_bigint(f.to_f64(), vm)?; + check_int_to_str_digits(&bigint, vm)?; + Ok(spec.format_number(&bigint).into()) } obj => { if let Some(method) = vm.get_method(obj.clone(), identifier!(vm, __int__)) { let result = method?.call((), vm)?; if let Some(i) = result.downcast_ref::() { + check_int_to_str_digits(i.as_bigint(), vm)?; return Ok(spec.format_number(i.as_bigint()).into()); } } diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index e59a1f15a6f..1ddbc1162c0 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -5,7 +5,7 @@ use crate::{ AsObject, Py, PyObject, PyObjectRef, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{ PyBytes, PyDict, PyDictRef, PyGenericAlias, PyInt, PyList, PyStr, PyTuple, PyTupleRef, - PyType, PyTypeRef, PyUtf8Str, pystr::AsPyStr, + PyType, PyTypeRef, PyUtf8Str, int::check_int_to_str_digits, pystr::AsPyStr, }, common::{hash::PyHash, str::to_ascii}, convert::{ToPyObject, ToPyResult}, @@ -392,6 +392,7 @@ impl PyObject { // Fast path for exact int: skip __str__ method resolution let obj = match obj.downcast_exact::(vm) { Ok(int) => { + check_int_to_str_digits(int.as_bigint(), vm)?; return Ok(vm.ctx.new_str(int.to_str_radix_10())); } Err(obj) => obj, diff --git a/extra_tests/snippets/builtin_int.py b/extra_tests/snippets/builtin_int.py index aab24cbb4cc..2828b5ad26d 100644 --- a/extra_tests/snippets/builtin_int.py +++ b/extra_tests/snippets/builtin_int.py @@ -366,3 +366,38 @@ class SubInt(int): subint = int.__new__(SubInt, 11) assert subint.real is not subint assert type(subint.real) is int + + +# sys.set_int_max_str_digits enforced on int → str conversions (PEP 644). +# Decimal paths (str, repr, f-string, %d, format(d/n/empty)) raise ValueError; +# binary bases ('x', 'o', 'b') are exempt. +import sys + +_orig_limit = sys.get_int_max_str_digits() +try: + sys.set_int_max_str_digits(4000) + huge = 10**4001 # 4002 decimal digits, well over the limit + + for fn in [ + lambda: str(huge), + lambda: repr(huge), + lambda: f"{huge}", + lambda: "%d" % huge, + lambda: b"%d" % huge, + lambda: format(huge, ""), + lambda: format(huge, "d"), + lambda: format(huge, ",d"), + ]: + with assert_raises(ValueError): + fn() + + # Binary bases must NOT raise. + assert format(huge, "x") + assert format(huge, "o") + assert format(huge, "b") + + # Limit disabled: no check. + sys.set_int_max_str_digits(0) + assert str(huge) +finally: + sys.set_int_max_str_digits(_orig_limit) From 1d42ee565f8ee6ba3862dd8557059bc94c19f6b8 Mon Sep 17 00:00:00 2001 From: Changjoon Date: Mon, 27 Apr 2026 21:50:23 +0900 Subject: [PATCH 6/6] Preserve __dict__ and __slots__ state in deque.__reduce__ (#7699) deque.__reduce__ passed None as the unpickle state, so a deque subclass's __dict__ and __slots__ values were dropped across a pickle round-trip. CPython's deque___reduce___impl (Modules/_collectionsmodule.c::deque___reduce___impl) calls _PyObject_GetState, which returns the dict (or a (dict, slots) tuple) so subclass attributes survive. Replace the placeholder with the result of __getstate__() on the instance. object.__getstate__ already implements the dict / dict+slots protocol, matching _PyObject_GetState. --- Lib/test/test_deque.py | 1 - crates/vm/src/stdlib/_collections.rs | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_deque.py b/Lib/test/test_deque.py index f798ab4b6d1..d4b42c0bd55 100644 --- a/Lib/test/test_deque.py +++ b/Lib/test/test_deque.py @@ -817,7 +817,6 @@ def test_basics(self): d.clear() self.assertEqual(len(d), 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'Deque' object has no attribute 'x' def test_copy_pickle(self): for cls in Deque, DequeWithSlots: for d in cls('abc'), cls('abcde', maxlen=4): diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index 2807e171777..8eacc69f039 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -388,7 +388,11 @@ mod _collections { Some(v) => vm.new_pyobj((vm.ctx.empty_tuple.clone(), v)), None => vm.ctx.empty_tuple.clone().into(), }; - Ok(vm.new_pyobj((cls, value, vm.ctx.none(), PyDequeIterator::new(zelf)))) + // Use __getstate__ to capture both __dict__ and __slots__ values so + // subclass attributes survive a pickle round-trip (matches CPython's + // deque___reduce___impl, which calls _PyObject_GetState). + let state = vm.call_method(zelf.as_object(), "__getstate__", ())?; + Ok(vm.new_pyobj((cls, value, state, PyDequeIterator::new(zelf)))) } #[pyclassmethod]