From 4f55bde7c8e590edfd34b0ed8ebba9529ee6dece Mon Sep 17 00:00:00 2001 From: Roshan Ramani <154859727+rawsun007@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:22:07 +0530 Subject: [PATCH] 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 --- crates/vm/src/builtins/str.rs | 13 ++++++++--- extra_tests/snippets/builtin_str.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 712d61675..7e3328216 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -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() diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index ccd2a0352..1c04c8305 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -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()