Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
fix(vm): keep str's concat error when __radd__ declines
`PyStr::__add__` delegated to the right operand's `__radd__` whenever it had
one, and returned whatever came back. For every numeric type that is a
`NotImplemented`, which then reached the generic binary-op handler:

    >>> "a" + 1
    TypeError: unsupported operand type(s) for +: 'str' and 'int'

CPython falls back to `str`'s `sq_concat` once the reflected call declines, so
it names the concatenation instead:

    TypeError: can only concatenate str (not "int") to str

The specific message was already here, but only reachable for operands with no
`__radd__` at all, so `list`, `object`, `bytes` and `None` were correct while
`int`, `float`, `bool` and any class with a declining `__radd__` were not.

A `__radd__` that returns a value still wins, unchanged.

Verified against CPython 3.14.0: the new snippet asserts the message for
`int`, `float`, `bool`, `list`, `None`, `bytes` and a declining `__radd__`,
and passes under both interpreters. Reverting only the `str.rs` change fails
its first assertion.

Assisted-by: Claude Code:claude-opus-5
  • Loading branch information
rawsun007 committed Sep 5, 2026
commit fc0d149de0e6b59aa7a31a96c806da9d5afaa86a
13 changes: 10 additions & 3 deletions crates/vm/src/builtins/str.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,10 +653,17 @@ impl PyStr {
Self::new_str_unchecked(bytes.into(), kind)
}
.to_pyobject(vm))
} else if let Some(radd) = vm.get_method(other.clone(), identifier!(vm, __radd__)) {
// hack to get around not distinguishing number add from seq concat
radd?.call((zelf,), vm)
} else {
// hack to get around not distinguishing number add from seq concat
if let Some(radd) = vm.get_method(other.clone(), identifier!(vm, __radd__)) {
let result = radd?.call((zelf,), vm)?;
// CPython reaches `str`'s sq_concat once the reflected call declines, so a
// `__radd__` returning NotImplemented must still report the concat error
// rather than the generic binary-op one.
if !result.is(&vm.ctx.not_implemented) {
return Ok(result);
}
}
Err(vm.new_type_error(format!(
r#"can only concatenate str (not "{}") to str"#,
other.class().slot_name()
Expand Down
36 changes: 36 additions & 0 deletions extra_tests/snippets/builtin_str.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,3 +979,39 @@ def test_expandtabs_zero_tabsize():


test_expandtabs_zero_tabsize()


def test_concat_error_message():
# CPython falls back to str's sequence concatenation once a reflected
# __radd__ declines, so the message names the concatenation, not the
# generic binary operation. Types that define __radd__ (every number)
# used to take the generic path here.
def message(other):
try:
"a" + other
except TypeError as err:
return str(err)
raise AssertionError("TypeError was not raised")

assert message(1) == 'can only concatenate str (not "int") to str'
assert message(1.5) == 'can only concatenate str (not "float") to str'
assert message(True) == 'can only concatenate str (not "bool") to str'
assert message([]) == 'can only concatenate str (not "list") to str'
assert message(None) == 'can only concatenate str (not "NoneType") to str'
assert message(b"b") == 'can only concatenate str (not "bytes") to str'

class Declines:
def __radd__(self, other):
return NotImplemented

assert message(Declines()) == 'can only concatenate str (not "Declines") to str'

# A __radd__ that returns a value still wins.
class Accepts:
def __radd__(self, other):
return "from-radd"

assert "a" + Accepts() == "from-radd"


test_concat_error_message()
Loading