Skip to content

Fix del obj.__dict__ to match CPython behavior (fixes #5355)#7568

Open
alok-108 wants to merge 13 commits intoRustPython:mainfrom
alok-108:fix-issue-5355-del-dict
Open

Fix del obj.__dict__ to match CPython behavior (fixes #5355)#7568
alok-108 wants to merge 13 commits intoRustPython:mainfrom
alok-108:fix-issue-5355-del-dict

Conversation

@alok-108
Copy link
Copy Markdown

@alok-108 alok-108 commented Apr 6, 2026

This is a proposed fix for issue #5355 where deleting __dict__ on an object failed to match CPython's behavior.

Goal

Match CPython behavior where del obj.__dict__ successfully removes the dictionary, and subsequent access to __dict__ re-creates it lazily as a fresh empty dictionary.

Technical Changes

  1. Optional Instance Dictionary: Updated InstanceDict in crates/vm/src/object/core.rs to hold an Option<PyDictRef>. This allows the dictionary field to be set to None upon deletion.
    1. Deletion Support: Modified object_set_dict in crates/vm/src/builtins/object.rs and relevant parts of crates/vm/src/builtins/type.rs to handle PySetterValue::Delete.
    1. Lazy Creation: Implemented logic in object_get_dict to re-initialize the dictionary if it was previously deleted, ensuring compatibility with CPython.

Note on Verification

The logic follows established RustPython patterns (like transpose() and try_into_value()). However, I was unable to fully verify the build locally due to a missing Windows linker (link.exe) in my current environment. I've manually traced the logic and believe it to be correct.

@youknowone - Could you please review this proposed fix?

Summary by CodeRabbit

  • Refactor

    • Instance attribute dictionaries are now optional and created on demand; object dict storage and setter semantics were unified to explicitly represent assign vs delete.
  • Bug Fixes

    • Attribute assignment/deletion and lookup now consistently respect absence of an instance dictionary and raise AttributeError when appropriate.
  • Tests

    • Added a test confirming lazy dict recreation and that removed attributes raise AttributeError.

@alok-108 alok-108 changed the title Fix del obj.__dict__ to match CPython behavior (issue #5355) Fix del obj.__dict__ to match CPython behavior (fixes #5355) Apr 6, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Make per-object dict presence explicit (optional), add lazy dict materialization via InstanceDict::get_or_insert, and propagate PySetterValue through dict setters/getters so assign/delete flows are handled uniformly and avoid eager per-instance dict allocation.

Changes

Cohort / File(s) Summary
Object Dictionary Core Model
crates/vm/src/object/core.rs
Per-object dict made explicit: InstanceDict stores PyRwLock<Option<PyDictRef>>. APIs now use Option<PyDictRef>; added get_or_insert(&self, vm) to lazily create a dict. ObjExt::new gains has_dict flag; GC clearing and PyObject::set_dict updated to accept/return Option.
Builtin Object Dict Functions
crates/vm/src/builtins/object.rs
object_get_dict now falls back to instance_dict().get_or_insert(vm) when obj.dict() is absent, only raising if no instance dict exists. object_set_dict signature changed to accept PySetterValue<PyDictRef> and maps Assign/Delete to Some/None before calling obj.set_dict.
Type System Dict Descriptor
crates/vm/src/builtins/type.rs
subtype_set_dict now accepts a PySetterValue; if a __dict__ descriptor exists it forwards the PySetterValue to the descriptor setter, otherwise it converts assignment/delete variants into Option<PyObjectRef> and calls the generic object_set_dict fallback.
Attribute Protocol Path
crates/vm/src/protocol/object.rs
PyObject::generic_setattr now operates on instance_dict(): assignments write via instance_dict.get_or_insert(vm).set_item(...); deletions consult instance_dict.get() and return new_no_attribute_error if no instance dict exists.
Tests
extra_tests/snippets/test_del_dict.py
Adds a test that deletes an instance __dict__, verifies lazy re-creation returns an empty dict, and asserts previously set attributes are no longer present.

Sequence Diagram(s)

sequenceDiagram
    actor Caller
    participant subtype_set_dict as "subtype_set_dict"
    participant Descriptor as "base_type.__dict__ descriptor"
    participant object_set_dict as "object::object_set_dict"
    participant Obj as "PyObject"
    participant InstDict as "InstanceDict"
    participant VM as "VirtualMachine"

    Caller->>subtype_set_dict: set __dict__ (PySetterValue)
    alt descriptor exists
        subtype_set_dict->>Descriptor: call setter(PySetterValue)
        Descriptor-->>Caller: return / error
    else descriptor missing (fallback)
        subtype_set_dict->>object_set_dict: forward PySetterValue (convert if needed)
        object_set_dict->>Obj: set_dict(Some(dict)/None)
        alt Obj has direct dict storage
            Obj->>Obj: replace or clear dict
            Obj-->>object_set_dict: Ok / Err
        else Obj only has InstanceDict
            object_set_dict->>InstDict: instance_dict().get_or_insert(vm) if Assign
            InstDict->>VM: allocate new dict (vm.ctx.new_dict())
            VM-->>InstDict: new dict
            InstDict-->>Obj: store dict
            Obj-->>object_set_dict: Ok / Err
        end
        object_set_dict-->>Caller: return / error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibbled bytes till dictionaries grew,
Lazy leaves sprouted when asked anew,
Assign or delete — I hop and persist,
InstanceDict waits, then makes a list,
Empty pockets, then attributes through.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically summarizes the main change: fixing del obj.__dict__ to match CPython behavior, including the issue reference.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/vm/src/builtins/object.rs`:
- Around line 560-564: The lazy __dict__ recreation is doing a check-then-set
across separate lock acquisitions (obj.instance_dict() followed by d.set(...)),
allowing two threads to allocate distinct dicts and one to overwrite the other;
fix by performing the allocation/check under the InstanceDict write lock:
acquire the write lock on the InstanceDict returned by obj.instance_dict(),
inside the lock check whether the slot is still empty and only then create (or
assign) a single vm.ctx.new_dict() and return that dict (cloning the stored
value to return) so allocation and set happen atomically under the same write
lock rather than across separate lock boundaries.

In `@crates/vm/src/object/core.rs`:
- Around line 946-949: The GC clear path currently calls old_dict.into_inner()
unconditionally in gc_clear_raw, but into_inner now returns Option<PyDictRef> so
deleted-dict cases must be skipped; update gc_clear_raw to call
old_dict.into_inner() and match on the Option (e.g., if let Some(dict) =
old_dict.into_inner() { push dict to gc } ), ensuring you only push when Some
and do nothing for None; reference InstanceDict::into_inner and the gc_clear_raw
logic that currently uses old_dict.into_inner().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: c2b32e62-e40e-48b5-8a82-a01309c97b2f

📥 Commits

Reviewing files that changed from the base of the PR and between d5a90e5 and f1d9358.

📒 Files selected for processing (8)
  • build_error.txt
  • build_error_gnu.txt
  • build_error_gnu_explicit.txt
  • build_error_u8.txt
  • crates/vm/src/builtins/object.rs
  • crates/vm/src/builtins/type.rs
  • crates/vm/src/object/core.rs
  • test.py

Copy link
Copy Markdown
Member

@youknowone youknowone left a comment

Choose a reason for hiding this comment

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

Please find a test under Lib/test/ and make sure it is working; otherwise put a test code under extra_tests/snippets to ensure this is fixing the reported issue. Also do not submit test files.

also, do NOT submit patch without proper human review. we expect PR authors are responsible for their patches.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] test: cpython/Lib/test/test_class.py (TODO: 13)
[x] test: cpython/Lib/test/test_genericclass.py
[x] test: cpython/Lib/test/test_subclassinit.py

dependencies:

dependent tests: (no tests depend on class)

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@alok-108
Copy link
Copy Markdown
Author

alok-108 commented Apr 7, 2026

Hi @youknowone, thanks for the feedback. I've updated the PR with the following:

  • Added a snippet test in extra_tests/snippets/test_del_dict.py to verify the fix for del obj.dict.
    • Enabled two passing CPython tests: test_store_attr_deleted_dict and test_rematerialize_object_dict.
    • Fixed a potential GC crash in gc_clear_raw by using replace(None) to keep the InstanceDict container alive.
    • Updated generic_setattr to lazily re-create the dictionary after deletion if a new attribute is set.
      About the 'proper human review' - sorry if I skipped a step. I'm still learning the workflow here. Should I have discussed this on the issue before opening the PR? Any guidance on the preferred process would be great. Thanks!

@alok-108 alok-108 requested a review from youknowone April 7, 2026 08:05
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
extra_tests/snippets/test_del_dict.py (1)

5-20: Please make the regression prove replacement semantics.

This still passes if del obj.__dict__ just clears the original dict in place. Keeping an old_dict reference before deletion, then asserting obj.__dict__ is not old_dict would lock down the “drop and recreate” behavior, and a quick post-delete assignment would cover the new rematerialization-on-assign path too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@extra_tests/snippets/test_del_dict.py` around lines 5 - 20, Capture the dict
before deletion (old_dict = obj.__dict__), then delete it with del obj.__dict__
and assert that obj.__dict__ is a new dict object (obj.__dict__ is not old_dict)
and empty; next assign obj.x = 99 and assert that obj.__dict__ is not old_dict
and contains the new value (and that old_dict was not mutated) to prove the
drop-and-recreate semantics for obj, obj.__dict__, and attribute assignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/vm/src/object/core.rs`:
- Around line 310-316: The call to ObjExt::new in init_type_hierarchy uses the
old 2-argument signature but the constructor now requires a third has_dict:
bool; update the call site to pass the correct boolean (e.g. use the existing
type spec or flag that indicates whether the type has an instance dict such as a
TypeSpec::has_dict / class_has_dict / has_dict variable in scope), i.e. replace
the two-arg call with ObjExt::new(..., has_dict) and if no such value is
available in that scope, compute it from the type metadata or default to false.

---

Nitpick comments:
In `@extra_tests/snippets/test_del_dict.py`:
- Around line 5-20: Capture the dict before deletion (old_dict = obj.__dict__),
then delete it with del obj.__dict__ and assert that obj.__dict__ is a new dict
object (obj.__dict__ is not old_dict) and empty; next assign obj.x = 99 and
assert that obj.__dict__ is not old_dict and contains the new value (and that
old_dict was not mutated) to prove the drop-and-recreate semantics for obj,
obj.__dict__, and attribute assignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: cbe3bfc6-e325-4f88-95a0-9ac56b547624

📥 Commits

Reviewing files that changed from the base of the PR and between 28c5fe2 and d3cc9d4.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_class.py is excluded by !Lib/**
📒 Files selected for processing (3)
  • crates/vm/src/object/core.rs
  • crates/vm/src/protocol/object.rs
  • extra_tests/snippets/test_del_dict.py

Copy link
Copy Markdown
Contributor

@ShaharNaveh ShaharNaveh left a comment

Choose a reason for hiding this comment

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

Thanks for the PR:)
please remove txt files from this PR, they seem like a debug artifact that should not be commited

@alok-108
Copy link
Copy Markdown
Author

alok-108 commented Apr 7, 2026

@ShaharNaveh thanks for the review. I've removed the .txt files from the root and cleaned up the TODO comments in test_class.py. Only the expectedFailure removals remain now.

Copy link
Copy Markdown
Contributor

@ShaharNaveh ShaharNaveh left a comment

Choose a reason for hiding this comment

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

Great! lgtm if CI passes

@ShaharNaveh
Copy link
Copy Markdown
Contributor

@alok-108 please fix the CI errors and push:)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants