From 3271b74aaf26ea9aac6306aa5e812ccdcfcabf96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:29:49 +0000 Subject: [PATCH 1/6] Initial plan From a3fb6fed740a862e216e674b52932663f8dde1f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:50:43 +0000 Subject: [PATCH 2/6] fix: guard __class__ reassignment layout Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --- crates/vm/src/builtins/object.rs | 19 ++++++++++++++++--- crates/vm/src/vm/interpreter.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 77a1eff6c7d..458577cc1ec 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -497,16 +497,29 @@ impl PyBaseObject { ) -> PyResult<()> { match value.downcast::() { Ok(cls) => { - let both_module = instance.class().fast_issubclass(vm.ctx.types.module_type) + let current_cls = instance.class(); + let both_module = current_cls.fast_issubclass(vm.ctx.types.module_type) && cls.fast_issubclass(vm.ctx.types.module_type); - let both_mutable = !instance - .class() + let both_mutable = !current_cls .slots .flags .has_feature(PyTypeFlags::IMMUTABLETYPE) && !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); + if current_cls.slots.basicsize != cls.slots.basicsize + || current_cls.slots.member_count != cls.slots.member_count + || has_dict(current_cls) != has_dict(&cls) + || (cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) + && instance.typeid() != PyBaseObject::payload_type_id()) + { + return Err(vm.new_type_error(format!( + "__class__ assignment: '{}' object layout differs from '{}'", + cls.name(), + current_cls.name() + ))); + } instance.set_class(cls, vm); Ok(()) } else { diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 503feb3dc7f..1eecf0a8b34 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -169,4 +169,31 @@ mod tests { assert_eq!(value.as_str(), "Hello Hello Hello Hello ") }) } + + #[test] + fn class_assignment_layout_mismatch_error() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let source = r#" +class TypeA: + def __init__(self): + self.a = 1 + +class TypeB: + __slots__ = "b" + def __init__(self): + self.b = 2 + +obj = TypeA() +try: + obj.__class__ = TypeB +except TypeError as e: + assert str(e) == "__class__ assignment: 'TypeB' object layout differs from 'TypeA'" +else: + raise AssertionError("TypeError not raised") +"#; + vm.run_code_string(scope, source, "".to_owned()) + .expect("script should complete without uncaught exceptions"); + }) + } } From fbdb4002ac41f11dc44600cb285f70c79747e9ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 25 Dec 2025 11:41:24 +0000 Subject: [PATCH 3/6] Auto-format: cargo fmt --all --- crates/vm/src/builtins/object.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 458577cc1ec..354ef11d7e9 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -507,7 +507,8 @@ 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_dict = + |typ: &Py| typ.slots.flags.has_feature(PyTypeFlags::HAS_DICT); if current_cls.slots.basicsize != cls.slots.basicsize || current_cls.slots.member_count != cls.slots.member_count || has_dict(current_cls) != has_dict(&cls) From 71e9eb7431741c9862ad16b302e4a1e84d5ea0bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:39:37 +0000 Subject: [PATCH 4/6] test: move __class__ layout check to builtin_type snippet Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --- crates/vm/src/vm/interpreter.rs | 27 --------------------------- extra_tests/snippets/builtin_type.py | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 1eecf0a8b34..503feb3dc7f 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -169,31 +169,4 @@ mod tests { assert_eq!(value.as_str(), "Hello Hello Hello Hello ") }) } - - #[test] - fn class_assignment_layout_mismatch_error() { - Interpreter::without_stdlib(Default::default()).enter(|vm| { - let scope = vm.new_scope_with_builtins(); - let source = r#" -class TypeA: - def __init__(self): - self.a = 1 - -class TypeB: - __slots__ = "b" - def __init__(self): - self.b = 2 - -obj = TypeA() -try: - obj.__class__ = TypeB -except TypeError as e: - assert str(e) == "__class__ assignment: 'TypeB' object layout differs from 'TypeA'" -else: - raise AssertionError("TypeError not raised") -"#; - vm.run_code_string(scope, source, "".to_owned()) - .expect("script should complete without uncaught exceptions"); - }) - } } diff --git a/extra_tests/snippets/builtin_type.py b/extra_tests/snippets/builtin_type.py index 7a8e4840e13..21fe62c30c7 100644 --- a/extra_tests/snippets/builtin_type.py +++ b/extra_tests/snippets/builtin_type.py @@ -240,6 +240,26 @@ class C(B, BB): assert C.mro() == [C, B, A, BB, AA, object] +class TypeA: + def __init__(self): + self.a = 1 + + +class TypeB: + __slots__ = "b" + + def __init__(self): + self.b = 2 + + +obj = TypeA() +with assert_raises(TypeError) as cm: + obj.__class__ = TypeB +assert "__class__ assignment: 'TypeB' object layout differs from 'TypeA'" in str( + cm.exception +) + + assert type(Exception.args).__name__ == "getset_descriptor" assert type(None).__bool__(None) is False From 89e080142582881d6a423606a29c8fdb5642ac09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 16:43:53 +0000 Subject: [PATCH 5/6] fix: allow module __class__ reassignment with matching layout Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --- crates/vm/src/builtins/object.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 354ef11d7e9..546428d44b1 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -512,8 +512,6 @@ impl PyBaseObject { if current_cls.slots.basicsize != cls.slots.basicsize || current_cls.slots.member_count != cls.slots.member_count || has_dict(current_cls) != has_dict(&cls) - || (cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) - && instance.typeid() != PyBaseObject::payload_type_id()) { return Err(vm.new_type_error(format!( "__class__ assignment: '{}' object layout differs from '{}'", From e0090ff1db38729a575e5eb4dc1955701f434810 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sat, 27 Dec 2025 00:45:36 +0900 Subject: [PATCH 6/6] Fix condition --- crates/vm/src/builtins/object.rs | 19 +++++++++++++++++- extra_tests/snippets/builtin_type.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 546428d44b1..a61fb1e2971 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -509,8 +509,25 @@ impl PyBaseObject { if both_mutable || both_module { let has_dict = |typ: &Py| typ.slots.flags.has_feature(PyTypeFlags::HAS_DICT); + // 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_str() == y.as_str()) + } + (None, None) => true, + _ => false, + }; if current_cls.slots.basicsize != cls.slots.basicsize - || current_cls.slots.member_count != cls.slots.member_count + || !slots_equal || has_dict(current_cls) != has_dict(&cls) { return Err(vm.new_type_error(format!( diff --git a/extra_tests/snippets/builtin_type.py b/extra_tests/snippets/builtin_type.py index 21fe62c30c7..67269e694c0 100644 --- a/extra_tests/snippets/builtin_type.py +++ b/extra_tests/snippets/builtin_type.py @@ -260,6 +260,36 @@ def __init__(self): ) +# Test: same slot count but different slot names should fail +class SlotX: + __slots__ = ("x",) + + +class SlotY: + __slots__ = ("y",) + + +slot_obj = SlotX() +with assert_raises(TypeError) as cm: + slot_obj.__class__ = SlotY +assert "__class__ assignment: 'SlotY' object layout differs from 'SlotX'" in str( + cm.exception +) + + +# Test: same slots should succeed +class SlotA: + __slots__ = ("a",) + + +class SlotA2: + __slots__ = ("a",) + + +slot_a = SlotA() +slot_a.__class__ = SlotA2 # Should work + + assert type(Exception.args).__name__ == "getset_descriptor" assert type(None).__bool__(None) is False