Skip to content

Short-circuit identity in rich_compare_bool for Eq/Ne (PyObject_RichCompareBool parity)#7734

Merged
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-rich-compare-bool-identity
Apr 29, 2026
Merged

Short-circuit identity in rich_compare_bool for Eq/Ne (PyObject_RichCompareBool parity)#7734
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-rich-compare-bool-identity

Conversation

@changjoon-park

@changjoon-park changjoon-park commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Background

CPython distinguishes two comparison entry points (Objects/object.c):

  • PyObject_RichCompare — returns the raw __eq__ / __ne__ result; no identity short-circuit
  • PyObject_RichCompareBool — returns bool; identity implies equality (and inequality is false on identity), short-circuiting before dispatch

Collection membership / equality (x in [x], [nan] == [nan], set/dict comparisons) go through the bool variant and rely on the short-circuit. RustPython's rich_compare_bool skipped the identity check, so a buggy or raising __eq__ propagated even when the operand was the same object.

Repro

class BadEq:
    def __hash__(self): return 7
    def __eq__(self, other): raise RuntimeError("eq called")

x = BadEq()
d = {x: x}
v1 = next(iter(d.values()))

v1 in d.values()
# CPython 3.14: True
# RustPython:   RuntimeError("eq called")    (before this PR)

NaN exhibits the canonical asymmetry that proves the contract:

n = float('nan')
n == n          # False  (RichCompare path, no shortcut)
[n] == [n]      # True   (RichCompareBool path, identity shortcut)
n in [n]        # True

Fix

Add an identity short-circuit at the top of rich_compare_bool for Eq (returns true) and Ne (returns false). Ordering ops fall through to _cmp because Python does not guarantee reflexivity for < / <= / > / >= (e.g. user-defined __lt__ may legitimately return False for x < x). _cmp itself is untouched, so == / != operators continue to invoke __eq__ / __ne__ exactly as before.

Tests unmasked

  • test_dictviews.TestDictViews.test_compare_error

Verification

53 scenarios across 10 categories — byte-identical with CPython 3.14.4 (diff empty):

  • 6 ordering ops with raising dunders (<, <=, >, >=) — all raise (no shortcut)
  • 7 collection membership types (list, tuple, set, frozenset, dict, dict.keys, deque)
  • 2 dict view types (values, items)
  • 7 container equality (== and != for list / tuple / set / frozenset / dict)
  • 6 NaN edge cases (n == n False, [n] == [n] True, n in [n] True)
  • 5 FalseEq class cases (where __eq__ returns False)
  • 3 list operations (count, index, remove)
  • 3 set operations (add, union, len)
  • 3 dict operations (get, setdefault)
  • 4 hash collision + identity separation cases

No regressions across 14 modules (~2,402 tests): test_dictviews, test_dict, test_set, test_list, test_tuple, test_collections, test_typing, test_descr, test_iter, test_compare, test_class, test_userdict, test_userlist, test_ordered_dict.

Out-of-scope finding (recorded for follow-up)

range.__contains__ and array.__contains__ return False on type mismatch (e.g. RaisingEq() in range(10) returns False without calling __eq__). CPython's specialized range_contains and array_contains fall back to the default sequence contains for non-fast-path types, which would invoke __eq__. This divergence is independent of the rich_compare_bool change in this PR — those specialized contains slots bypass bool_eq entirely.

Summary by CodeRabbit

  • Bug Fixes
    • Equality/inequality now short-circuit when both operands are the same object, improving correctness and performance.
    • Mapping and weak proxy comparisons now evaluate rich-comparison results and coerce them to booleans, ensuring consistent semantics and proper error propagation.
    • Weak references retain identity fallback when a referent cannot be resolved.

…ompareBool parity)

CPython distinguishes two comparison entry points (Objects/object.c):
- PyObject_RichCompare returns the raw __eq__ / __ne__ result; no
  identity short-circuit
- PyObject_RichCompareBool returns bool; identity implies equality
  (and inequality is false on identity), short-circuiting before
  dispatch

Collection membership / equality (x in [x], [nan] == [nan], set/dict
comparisons) go through the bool variant and rely on the short-circuit.
RustPython's rich_compare_bool skipped the identity check, so a buggy
or raising __eq__ propagated even when the operand was the same object.

Add an identity short-circuit at the top of rich_compare_bool for Eq
(returns true) and Ne (returns false). Ordering ops fall through to
_cmp because Python does not guarantee reflexivity for </<=/>/>=.
_cmp itself is untouched, so == / != operators continue to invoke
__eq__ / __ne__ exactly as before.

Unmasks test_dictviews.TestDictViews.test_compare_error.

Verified byte-identical with CPython 3.14.4 across 53 scenarios in 10
categories (collection membership / equality / ordering ops / NaN /
hash collision / dict views / list-set-dict ops). 14-module regression
sweep ~2,402 tests passes with no regressions.
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: fa7fce32-bc41-44f1-94a4-688779f10045

📥 Commits

Reviewing files that changed from the base of the PR and between dbb338e and 55c3130.

📒 Files selected for processing (3)
  • crates/vm/src/builtins/mappingproxy.rs
  • crates/vm/src/builtins/weakproxy.rs
  • crates/vm/src/builtins/weakref.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/vm/src/builtins/weakproxy.rs
  • crates/vm/src/builtins/weakref.rs

📝 Walkthrough

Walkthrough

rich_compare_bool now short-circuits identity for Eq/Ne; mapping and weak proxies delegate to rich_compare and coerce the result to a boolean; weakref equality uses referent rich_compare with boolean coercion and falls back to identity when upgrade fails.

Changes

Cohort / File(s) Summary
Identity-based comparison
crates/vm/src/protocol/object.rs
PyObject::rich_compare_bool adds an identity short-circuit: if self.is(other), return true for Eq and false for Ne; other ops continue via _cmp.
Proxy rich-compare conversion
crates/vm/src/builtins/mappingproxy.rs, crates/vm/src/builtins/weakproxy.rs
cmp now calls the underlying object's rich_compare, then converts the returned Python object to bool with PyArithmeticValue::from_object(...).try_to_bool(vm), propagating conversion errors.
WeakRef equality semantics
crates/vm/src/builtins/weakref.rs
PyWeak::cmp (eq-only) upgrades referents and uses rich_compare + PyArithmeticValue::from_object(...).try_to_bool(vm) for equality; if upgrade fails, falls back to zelf.is(other) identity check.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I hop through objects, quick and spry,

When selves align I wink my eye.
Proxies ask and answers bloom,
Weakrefs peek and skip the gloom.
I nibble code and hum a tune.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding identity short-circuit logic to rich_compare_bool for Eq/Ne operations to match CPython's PyObject_RichCompareBool behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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
Review rate limit: 6/8 reviews remaining, refill in 12 minutes and 7 seconds.

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

@ShaharNaveh ShaharNaveh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ty:)
lgtm!

@changjoon-park

Copy link
Copy Markdown
Contributor Author

ty:) lgtm!

thanks for the quick check !

@github-actions

github-actions Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

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

[x] test: cpython/Lib/test/test_dict.py (TODO: 6)
[x] test: cpython/Lib/test/test_dictcomps.py (TODO: 1)
[x] test: cpython/Lib/test/test_dictviews.py (TODO: 1)
[x] test: cpython/Lib/test/test_userdict.py
[x] test: cpython/Lib/test/mapping_tests.py

dependencies:

dependent tests: (no tests depend on dict)

Legend:

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/vm/src/builtins/weakref.rs (1)

139-142: Consider extracting a helper for the rich_compare to PyComparisonValue conversion.

This pattern appears identically in mappingproxy.rs, weakproxy.rs, and here:

crate::function::PyArithmeticValue::from_object(vm, res)
    .map(|obj| obj.try_to_bool(vm))
    .transpose()

A helper in crate::function or crate::protocol::object could reduce duplication:

pub fn rich_compare_result_to_comparison_value(
    vm: &VirtualMachine,
    res: PyObjectRef,
) -> PyResult<PyComparisonValue> {
    PyArithmeticValue::from_object(vm, res)
        .map(|obj| obj.try_to_bool(vm))
        .transpose()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/vm/src/builtins/weakref.rs` around lines 139 - 142, The same
conversion from an object returned by rich_compare to a PyComparisonValue is
duplicated (seen in weakref.rs, weakproxy.rs, mappingproxy.rs) using
PyArithmeticValue::from_object(vm, res).map(|obj|
obj.try_to_bool(vm)).transpose(); extract a small helper (e.g.,
rich_compare_result_to_comparison_value(vm, res)) into crate::function or
crate::protocol::object that takes (&VirtualMachine, PyObjectRef) and returns
PyResult<PyComparisonValue>, then replace the duplicated call sites (including
the rich_compare call in weakref.rs) to call that helper; keep the helper name
and signature consistent across call sites and handle the same mapping/transpose
logic inside it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/vm/src/builtins/weakref.rs`:
- Around line 139-142: The same conversion from an object returned by
rich_compare to a PyComparisonValue is duplicated (seen in weakref.rs,
weakproxy.rs, mappingproxy.rs) using PyArithmeticValue::from_object(vm,
res).map(|obj| obj.try_to_bool(vm)).transpose(); extract a small helper (e.g.,
rich_compare_result_to_comparison_value(vm, res)) into crate::function or
crate::protocol::object that takes (&VirtualMachine, PyObjectRef) and returns
PyResult<PyComparisonValue>, then replace the duplicated call sites (including
the rich_compare call in weakref.rs) to call that helper; keep the helper name
and signature consistent across call sites and handle the same mapping/transpose
logic inside it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 7e83b45e-e50f-48d5-8a2d-e6b02352abeb

📥 Commits

Reviewing files that changed from the base of the PR and between fe4c8b3 and 09ec8f6.

📒 Files selected for processing (3)
  • crates/vm/src/builtins/mappingproxy.rs
  • crates/vm/src/builtins/weakproxy.rs
  • crates/vm/src/builtins/weakref.rs

The identity short-circuit added to rich_compare_bool exposed a latent
bug in three proxy types (weakref, weakproxy, mappingproxy): they were
delegating their __eq__ to the bool variant on referents, while CPython
uses PyObject_RichCompare so the referent's __eq__ runs even when the
referents share identity.

Fixes test_weak_keyed_cascading_deletes which depends on key __eq__
firing during dict deletion to trigger a side-effect that mutates the
key list.
@changjoon-park changjoon-park force-pushed the fix-rich-compare-bool-identity branch from dbb338e to 55c3130 Compare April 29, 2026 15:21
@changjoon-park

Copy link
Copy Markdown
Contributor Author

CI was failing on test_weak_keyed_cascading_deletes. Turns out the new short-circuit exposed a latent issue in weakref / weakproxy / mappingproxy — they were going through the bool variant for referent compare, but CPython actually uses PyObject_RichCompare there so the referent's __eq__ still runs even on identity match.

Pushed a follow-up that routes those three through PyObject_RichCompare. Tested against CPython 3.14 across a bunch of scenarios (Eq/Ne, ordering ops, dead refs, NotImplemented from referent) and they line up now. Failing test passes locally and no regressions across the nearby modules.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

@youknowone youknowone merged commit 6c91c5b into RustPython:main Apr 29, 2026
21 checks passed
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