Skip to content

Commit 4f55bde

Browse files
authored
fix(vm): keep str's concat error when __radd__ declines (#8652)
`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
1 parent 25b5dfe commit 4f55bde

2 files changed

Lines changed: 46 additions & 3 deletions

File tree

‎crates/vm/src/builtins/str.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -653,10 +653,17 @@ impl PyStr {
653653
Self::new_str_unchecked(bytes.into(), kind)
654654
}
655655
.to_pyobject(vm))
656-
} else if let Some(radd) = vm.get_method(other.clone(), identifier!(vm, __radd__)) {
657-
// hack to get around not distinguishing number add from seq concat
658-
radd?.call((zelf,), vm)
659656
} else {
657+
// hack to get around not distinguishing number add from seq concat
658+
if let Some(radd) = vm.get_method(other.clone(), identifier!(vm, __radd__)) {
659+
let result = radd?.call((zelf,), vm)?;
660+
// CPython reaches `str`'s sq_concat once the reflected call declines, so a
661+
// `__radd__` returning NotImplemented must still report the concat error
662+
// rather than the generic binary-op one.
663+
if !result.is(&vm.ctx.not_implemented) {
664+
return Ok(result);
665+
}
666+
}
660667
Err(vm.new_type_error(format!(
661668
r#"can only concatenate str (not "{}") to str"#,
662669
other.class().slot_name()

‎extra_tests/snippets/builtin_str.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,3 +979,39 @@ def test_expandtabs_zero_tabsize():
979979

980980

981981
test_expandtabs_zero_tabsize()
982+
983+
984+
def test_concat_error_message():
985+
# CPython falls back to str's sequence concatenation once a reflected
986+
# __radd__ declines, so the message names the concatenation, not the
987+
# generic binary operation. Types that define __radd__ (every number)
988+
# used to take the generic path here.
989+
def message(other):
990+
try:
991+
"a" + other
992+
except TypeError as err:
993+
return str(err)
994+
raise AssertionError("TypeError was not raised")
995+
996+
assert message(1) == 'can only concatenate str (not "int") to str'
997+
assert message(1.5) == 'can only concatenate str (not "float") to str'
998+
assert message(True) == 'can only concatenate str (not "bool") to str'
999+
assert message([]) == 'can only concatenate str (not "list") to str'
1000+
assert message(None) == 'can only concatenate str (not "NoneType") to str'
1001+
assert message(b"b") == 'can only concatenate str (not "bytes") to str'
1002+
1003+
class Declines:
1004+
def __radd__(self, other):
1005+
return NotImplemented
1006+
1007+
assert message(Declines()) == 'can only concatenate str (not "Declines") to str'
1008+
1009+
# A __radd__ that returns a value still wins.
1010+
class Accepts:
1011+
def __radd__(self, other):
1012+
return "from-radd"
1013+
1014+
assert "a" + Accepts() == "from-radd"
1015+
1016+
1017+
test_concat_error_message()

0 commit comments

Comments
 (0)