Skip to content

fix special-case NotImplementedType in binop signatures to match runtime semantics #1129 - #2677

Open
asukaminato0721 wants to merge 4 commits into
meta:mainfrom
asukaminato0721:1129
Open

asukaminato0721 wants to merge 4 commits into
meta:mainfrom
asukaminato0721:1129

Conversation

@asukaminato0721

@asukaminato0721 asukaminato0721 commented Mar 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #1129

fixed the binop resolution path so NotImplementedType no longer leaks as a possible operator result.

successful dunder calls now strip only the exact NotImplementedType branch, keep searching reflected dunders when needed, and union any concrete results that can actually occur at runtime.

Test Plan

a regression test covering both pure fallback and mixed int | NotImplementedType behavior.

@meta-cla meta-cla Bot added the cla signed label Mar 5, 2026
@asukaminato0721
asukaminato0721 marked this pull request as ready for review March 5, 2026 23:27
Copilot AI review requested due to automatic review settings March 5, 2026 23:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes issue #1129, where binary operations involving dunder methods that return NotImplementedType would incorrectly leak the NotImplementedType into the inferred result type instead of following Python's runtime semantics (trying the reflected dunder when the forward one signals NotImplemented).

Changes:

  • The try_binop_calls logic in operators.rs is updated to strip NotImplementedType from successful dunder return types, accumulate non-NotImplementedType results, and continue searching for reflected dunders when needed.
  • NotImplementedType is added as a new stdlib entry in Stdlib, gated to Python ≥ 3.10 (consistent with how EllipsisType is handled).
  • A regression test is added covering both the pure-NotImplementedType fallback and the mixed int | NotImplementedType scenario.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
pyrefly/lib/alt/operators.rs Core fix: strips NotImplementedType from successful dunder results, accumulates partial results, and continues to reflected dunders when needed
crates/pyrefly_types/src/stdlib.rs Adds NotImplementedType as an Option<StdlibResult<ClassType>> stdlib entry, guarded by version >= 3.10
pyrefly/lib/test/operators.rs Regression test covering the bug from issue #1129 for both pure and mixed NotImplementedType return types

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread pyrefly/lib/alt/operators.rs Resolved
Comment thread pyrefly/lib/alt/operators.rs Resolved
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@stroxler stroxler 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.

I don't think we can merge this as is - the mypy primer noise is a hint that something is wrong here.

I think the issue is probably tied to NotImplementedType vs Never / NoReturn.

Some AI review suggestions:

Blocking finding

Preserve genuinely non-returning dunders — [operators.rs:265–274](

let ret_without_not_implemented = self.distribute_over_union(&ret, |ret| {
if matches!(ret, Type::ClassType(cls) if cls == not_implemented_type) {
self.heap.mk_never()
} else {
ret.clone()
}
});
if ret_without_not_implemented.is_never() {
continue;
}
).

The is_never() check conflates two cases:

  • The method returns only NotImplementedType, so dispatch should continue.
  • The method returns Never, so dispatch cannot continue.

With A.__add__(object) -> Never and B.__radd__(A) -> str, the patched checker infers str for A() + B(). At runtime, A.__add__ raises and the reflected method never executes. Without a reflected method, this instead produces a false unsupported-operation error. The same issue affects augmented assignment.

Fix: detect whether NotImplementedType was actually present; leave an original Never unchanged. This also matches the Never/NoReturn failures visible in the PR’s primer differential.

Other review concerns

  • The mixed-return test does not match runtime semantics. The added test asserts C() + D() is int, but executing that exact example returns str. For a general int | NotImplementedType return, reflected results remain possible. If strip-only behavior is intentional, describe it as an approximation rather than complete runtime modeling.
  • Keep the sentinel-absent fast path unchanged. Every successful dunder now allocates a vector, clones its result, and rebuilds unions—even without NotImplementedType. Selective stripping would avoid this cost.

@github-actions

Copy link
Copy Markdown

Diff from mypy_primer, showing the effect of this PR on open source code:

============================================================
SUMMARY
============================================================
Total: +7 new errors, -1 fixed errors
By preset: +2/-0 (default), +2/-0 (strict)

Projects with changes (2):
  openlibrary: +4 -1
  xarray: +3 -0
============================================================

FULL DIFF DETAILS
------------------------------------------------------------

openlibrary (https://github.com/internetarchive/openlibrary)
- ERROR openlibrary/book_providers.py:596:92-155: Implicit conversion of `Acquisition | NotImplementedType | Unknown | None` to `bool` is not allowed [implicit-bool]
+ ERROR openlibrary/book_providers.py:596:92-155: Implicit conversion of `Acquisition | Unknown | None` to `bool` is not allowed [implicit-bool]
+ ERROR openlibrary/solr/updater/edition.py:353:16-63: Returning implicit Any from function declared to return "bool" [no-any-return-implicit]
+ ERROR openlibrary/solr/updater/edition.py:526:16-75: Returning implicit Any from function declared to return "bool" [no-any-return-implicit]
+ ERROR openlibrary/solr/updater/edition.py:530:16-78: Returning implicit Any from function declared to return "bool" [no-any-return-implicit]

xarray (https://github.com/pydata/xarray)
+ ERROR xarray/core/resample_cftime.py:459:23-34: `+` is not supported between `datetime64` and `Tick` [unsupported-operation]
+ ERROR xarray/tests/test_cftime_offsets.py:558:14-29: `+` is not supported between `Day` and `Day` [unsupported-operation]
+ ERROR xarray/tests/test_cftime_offsets.py:565:14-29: The type of `result` is partially unknown; it contains an implicit `Any` [partially-unknown-type]

@stroxler stroxler 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.

Looks like a good improvement! This is big change though - fresh PR review and analysis of mypy_primer results flagged a few more issues we should address before we merge

  1. Inferred placeholder returns falsely reject inherited operators.
    [operators.rs:268–283](

    let ret = match ret {
    Type::ClassType(cls) if cls == *not_implemented_type => continue,
    Type::Union(mut union) => {
    if let Some(index) = union.members.iter().position(
    |member| matches!(member, Type::ClassType(cls) if cls == not_implemented_type),
    ) {
    union.members.remove(index);
    let ret_without_not_implemented = self.unions(union.members);
    errors.extend(callee_errors);
    successful_ret = self.union(successful_ret, ret_without_not_implemented);
    continue;
    }
    Type::Union(union)
    }
    ret => ret,
    };
    )

    This explains the remaining xarray Day + Day primer error. The inherited __add__ calls self.__apply__; Pyrefly infers its return using the base implementation, which returns NotImplemented. Day overrides __apply__, so the operation actually succeeds—but the patch treats the inferred placeholder as definitive rejection.

    I reproduced this with a minimal base/subclass example: no errors before the patch, unsupported-operation afterward, and CPython returns Day. The fix should account for inferred placeholder returns before treating them like explicitly annotated sentinel-only returns.

  2. Equality needs identity fallback when both methods decline.
    [operators.rs:294–310](

    if !successful_ret.is_never() {
    successful_ret
    } else if let Some((callee_errors, call_errors, ret)) = first_call {
    errors.extend(callee_errors);
    errors.extend(call_errors);
    ret
    } else {
    let dunders = calls
    .iter()
    .map(|(dunder, _, _)| format!("`{dunder}`"))
    .collect::<Vec<_>>()
    .join(" or ");
    self.error_with_context(
    errors,
    range,
    ErrorKind::UnsupportedOperation,
    format!("Cannot find {dunders}"),
    )

    When both equality methods return NotImplemented, Python falls back to identity comparison. The patch instead reports unsupported-operation. Mixed returns are also affected: methods annotated Literal[True] | NotImplementedType produce inferred Literal[True], although comparing distinct instances can return False.

    Preserve a bool identity-fallback branch for ==/!=, and cover both pure and mixed sentinel returns.

  3. The pure-sentinel arm drops deprecation diagnostics. [Line 269](

    Type::ClassType(cls) if cls == *not_implemented_type => continue,
    ) continues before propagating callee_errors. I verified a deprecated forward method warns before the patch but not afterward. Preserve those diagnostics before continuing.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

special-case NotImplementedType in binop signatures to match runtime semantics

5 participants