From 1352d19d37e70da4afb8bb3d47673e97cc51e3fc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Mon, 27 Jul 2026 12:53:52 +0900 Subject: [PATCH 1/2] str/bytes: hand back the receiver when a cut spans it whole `ll_stringslice_startstop` (rstr.py:867-869) returns the source string unchanged for `start == 0 and stop >= len`, so every string method that cuts a piece out of its receiver shares that receiver's storage when the cut spans it whole, and `is_w` (unicodeobject.py:110-111, bytesobject.py:34-35) reports the two identical. `descr_replace` (unicodeobject.py:1175-1176) returns `self` on zero replacements by the same reasoning, `format_string` (newformat.py:602-608) wraps the receiver's own storage once the spec parses to the defaults, and `descr_str` (unicodeobject.py:333-337) returns `self` for an exact `str`. pyre allocated a fresh object at each of those sites, so 31 identity cases read False where PyPy reads True: `s[:]`, `format(s, "")`, `f"{s}"`, `f"{s!s}"`, `str.__format__(s, "")`, `s.strip()`, `s.split(',')[0]`, `s.splitlines()[0]`, `s.replace('z', 'z')` and the bytes counterparts. Adds `w_str_cut` / `cut_bytes_like`, which return the receiver when the piece's byte count matches it and the receiver is an exact `str` / `bytes`. A subclass cuts to a fresh base object and a `bytearray` always gets a fresh mutable one, matching `is_w`'s `user_overridden_class` rejection and `_new`. Both are for cuts only: a transform can preserve the byte count while changing the bytes, and those have no upstream identity shortcut. Routed through them: the str and bytes full slice, strip/lstrip/rstrip, split/rsplit over both the separator and whitespace forms, splitlines, partition's no-match arm, and removeprefix/removesuffix. `wtf8_replace` and `replace_bytes` now return the `(res, replacements)` pair `replace_count` (rstring.py:220-309) does, so replace keys its shortcut on the count rather than on the resulting bytes -- `s.replace('a', 'a')` still builds a new string. `format_value_dispatch_w` is the object-returning `PyObject_Format` entry; `format()`, FORMAT_SIMPLE/FORMAT_WITH_SPEC and `str.__format__` take it, and `convert_value` honours `descr_str` for the `!s` conversion. `format()` also stops returning an immortal string for a dynamic result. `s.encode().decode() is s` stays False: PyPy shares one RPython string between the str and the bytes, while `W_UnicodeObject.value` is a per-object `*mut Wtf8Buf`. Assisted-by: Claude --- pyre/pyre-interpreter/src/baseobjspace.rs | 30 ++++++ pyre/pyre-interpreter/src/builtins.rs | 3 +- pyre/pyre-interpreter/src/runtime_ops.rs | 9 +- pyre/pyre-interpreter/src/type_methods.rs | 122 +++++++++++++++------- pyre/pyre-interpreter/src/typedef.rs | 72 ++++++++++--- pyre/pyre-object/src/unicodeobject.rs | 31 ++++++ 6 files changed, 207 insertions(+), 60 deletions(-) diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 5d9e151e3da..a6467a7fbf1 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -1643,6 +1643,21 @@ unsafe fn getitem_str(obj: PyObjectRef, index: PyObjectRef) -> PyResult { )?; let (start, _stop, step, slicelength) = crate::sliceobject::slice_adjust_indices(rs, rp, st, len as i64); + // `_unicode_sliced` (unicodeobject.py:1043-1050) cuts the utf8 + // storage, and `ll_stringslice_startstop` (rstr.py:867-869) hands the + // source string back unchanged for `start == 0 and stop >= len` — so a + // whole-string slice shares its operand's storage and `is_w` + // (unicodeobject.py:110-111) reports the two identical. Only for an + // exact `str`: a subclass slices to a fresh base `str`, and `is_w` + // rejects a `user_overridden_class` operand anyway + // (unicodeobject.py:106). + if step == 1 + && start == 0 + && slicelength == len as i64 + && pyre_object::pyobject::is_exact_type(obj, &pyre_object::pyobject::STR_TYPE) + { + return Ok(obj); + } let mut result = Wtf8Buf::new(); let mut i = start; for n in 0..slicelength { @@ -1705,6 +1720,21 @@ unsafe fn getitem_bytes_like(obj: PyObjectRef, index: PyObjectRef) -> PyResult { if is_slice(index) { let len = pyre_object::bytesobject::bytes_like_len(obj) as i64; let (start, stop, step) = normalize_slice(index, len)?; + // `_new(self._value[start:stop])` (stringmethods.py descr_getslice) + // runs through `ll_stringslice_startstop` (rstr.py:867-869), which + // hands the source string back unchanged for `start == 0 and + // stop >= len`; `is_w` (bytesobject.py:34-35) then reports the whole + // slice identical to its operand. Immutable exact `bytes` only — a + // `bytearray` gets a fresh object from `_new` because it is mutable, + // and a subclass keeps pointer identity through `is_w`. + if step == 1 + && start == 0 + && stop >= len + && is_bytes + && pyre_object::pyobject::is_exact_type(obj, &pyre_object::bytesobject::BYTES_TYPE) + { + return Ok(obj); + } let mut result = Vec::new(); let mut i = start; if step > 0 { diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index 7b268627517..8df4ab8c9c3 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -12450,8 +12450,7 @@ fn builtin_format(args: &[PyObjectRef]) -> Result { } else { String::new() }; - let s = crate::type_methods::format_value_dispatch(value, &spec)?; - Ok(pyre_object::w_str_from_wtf8(s)) + crate::type_methods::format_value_dispatch_w(value, &spec) } /// `__import__(name, globals=None, locals=None, fromlist=(), level=0)` diff --git a/pyre/pyre-interpreter/src/runtime_ops.rs b/pyre/pyre-interpreter/src/runtime_ops.rs index 4e76c43ccb9..f38f9692c02 100644 --- a/pyre/pyre-interpreter/src/runtime_ops.rs +++ b/pyre/pyre-interpreter/src/runtime_ops.rs @@ -629,6 +629,12 @@ pub fn convert_value_code(conv: ConvertValueOparg) -> i64 { /// `__str__` / `__repr__` may run Python → fallible. pub fn convert_value(value: PyObjectRef, conv: i64) -> Result { if conv == 0 || conv == 3 { + // `descr_str` (unicodeobject.py:333-337) returns `self` for an exact + // `str` and converts anything else — a subclass included — to a fresh + // base `str`. + if unsafe { pyre_object::is_exact_type(value, &pyre_object::STR_TYPE) } { + return Ok(value); + } let w = unsafe { crate::py_str_wtf8(value)? }; return Ok(pyre_object::w_str_from_wtf8_managed(w)); } @@ -657,8 +663,7 @@ pub fn format_value(value: PyObjectRef, spec: PyObjectRef) -> Result PyObjectRef { +/// Box a run of `recv`'s own code points into a str object, so a run covering +/// all of them yields `recv` itself — see [`pyre_object::w_str_cut`]. The runs +/// a splitter emits are disjoint pieces of the receiver, so an equal WTF-8 byte +/// count means this one spans the whole string. +fn cps_to_str_cut(recv: PyObjectRef, cps: &[CodePoint]) -> PyObjectRef { let mut buf = Wtf8Buf::with_capacity(cps.len()); for &cp in cps { buf.push(cp); } - w_str_from_wtf8_managed(buf) + unsafe { pyre_object::w_str_cut(recv, &buf) } } /// A lone surrogate is not whitespace. @@ -796,7 +799,7 @@ fn cp_is_whitespace(cp: CodePoint) -> bool { /// `str.split()` with no separator: split on runs of whitespace, /// dropping leading/trailing runs. When `maxsplit >= 0`, after that /// many splits the rest (leading whitespace stripped) is one tail token. -fn wtf8_split_whitespace(s: &Wtf8, maxsplit: i64) -> Vec { +fn wtf8_split_whitespace(recv: PyObjectRef, s: &Wtf8, maxsplit: i64) -> Vec { let cps: Vec = s.code_points().collect(); let mut out: Vec = Vec::new(); let mut i = 0usize; @@ -814,20 +817,20 @@ fn wtf8_split_whitespace(s: &Wtf8, maxsplit: i64) -> Vec { while i < cps.len() && !cp_is_whitespace(cps[i]) { i += 1; } - out.push(cps_to_str(&cps[start..i])); + out.push(cps_to_str_cut(recv, &cps[start..i])); } while i < cps.len() && cp_is_whitespace(cps[i]) { i += 1; } if i < cps.len() { - out.push(cps_to_str(&cps[i..])); + out.push(cps_to_str_cut(recv, &cps[i..])); } out } /// `str.rsplit()` with no separator: like `wtf8_split_whitespace` but /// scanning from the right, so the tail token is the leading remainder. -fn wtf8_rsplit_whitespace(s: &Wtf8, maxsplit: i64) -> Vec { +fn wtf8_rsplit_whitespace(recv: PyObjectRef, s: &Wtf8, maxsplit: i64) -> Vec { let cps: Vec = s.code_points().collect(); let mut tokens: Vec = Vec::new(); let mut i = cps.len(); @@ -845,7 +848,7 @@ fn wtf8_rsplit_whitespace(s: &Wtf8, maxsplit: i64) -> Vec { while i > 0 && !cp_is_whitespace(cps[i - 1]) { i -= 1; } - tokens.push(cps_to_str(&cps[i..end])); + tokens.push(cps_to_str_cut(recv, &cps[i..end])); } tokens.reverse(); let mut prefix_end = i; @@ -853,7 +856,7 @@ fn wtf8_rsplit_whitespace(s: &Wtf8, maxsplit: i64) -> Vec { prefix_end -= 1; } if prefix_end > 0 { - let mut out = vec![cps_to_str(&cps[..prefix_end])]; + let mut out = vec![cps_to_str_cut(recv, &cps[..prefix_end])]; out.extend(tokens); out } else { @@ -877,17 +880,20 @@ pub fn str_method_split(args: &[PyObjectRef]) -> Result wtf8_split_whitespace(s, maxsplit), + None => wtf8_split_whitespace(args[0], s, maxsplit), }; Ok(w_list_new(parts)) } @@ -953,10 +959,10 @@ pub fn str_method_rsplit(args: &[PyObjectRef]) -> Result wtf8_rsplit_whitespace(s, maxsplit), + None => wtf8_rsplit_whitespace(args[0], s, maxsplit), }; Ok(w_list_new(parts)) } @@ -1010,7 +1016,13 @@ pub fn str_method_format_map(args: &[PyObjectRef]) -> Result, left: bool, right: bool) -> Wtf8Buf { +/// +/// Returns the cut itself, not a copy: both `_strip` arms cut +/// `value[lpos:rpos]` out of the utf8 storage (`_utf8_sliced`, +/// unicodeobject.py:1456; `_strip_unboxed`, unicodeobject.py:1480), so a strip +/// that removes nothing shares its operand's storage — see +/// [`pyre_object::w_str_cut`]. +fn strip_chars<'a>(s: &'a Wtf8, chars: Option<&Wtf8>, left: bool, right: bool) -> &'a Wtf8 { let chars_set: Option> = chars.map(|c| c.code_points().collect()); let mut current: &Wtf8 = s; if left { @@ -1025,7 +1037,7 @@ fn strip_chars(s: &Wtf8, chars: Option<&Wtf8>, left: bool, right: bool) -> Wtf8B None => current.trim_end_matches(cp_is_whitespace), }; } - current.to_wtf8_buf() + current } /// `pypy/objspace/std/unicodeobject.py:1464-1473 W_UnicodeObject @@ -1053,12 +1065,7 @@ pub fn str_method_strip(args: &[PyObjectRef]) -> Result extract_strip_chars(a, "strip")?, None => None, }; - Ok(w_str_from_wtf8_managed(strip_chars( - s, - chars.as_deref(), - true, - true, - ))) + Ok(unsafe { pyre_object::w_str_cut(args[0], strip_chars(s, chars.as_deref(), true, true)) }) } pub fn str_method_lstrip(args: &[PyObjectRef]) -> Result { @@ -1068,12 +1075,7 @@ pub fn str_method_lstrip(args: &[PyObjectRef]) -> Result extract_strip_chars(a, "lstrip")?, None => None, }; - Ok(w_str_from_wtf8_managed(strip_chars( - s, - chars.as_deref(), - true, - false, - ))) + Ok(unsafe { pyre_object::w_str_cut(args[0], strip_chars(s, chars.as_deref(), true, false)) }) } pub fn str_method_rstrip(args: &[PyObjectRef]) -> Result { @@ -1083,12 +1085,7 @@ pub fn str_method_rstrip(args: &[PyObjectRef]) -> Result extract_strip_chars(a, "rstrip")?, None => None, }; - Ok(w_str_from_wtf8_managed(strip_chars( - s, - chars.as_deref(), - false, - true, - ))) + Ok(unsafe { pyre_object::w_str_cut(args[0], strip_chars(s, chars.as_deref(), false, true)) }) } /// `unicodeobject.py descr_startswith` — accepts either a single str @@ -1280,7 +1277,16 @@ pub fn str_method_replace(args: &[PyObjectRef]) -> Result crate::builtins::space_index_w(w_count)?, None => -1, }; - Ok(w_str_from_wtf8_managed(wtf8_replace(s, old, new, maxcount))) + let (out, replacements) = wtf8_replace(s, old, new, maxcount); + // `descr_replace` (unicodeobject.py:1175-1176) returns `self` when nothing + // was replaced — keyed on the count, so `s.replace('a', 'a')` still builds + // a new string. Exact `str` only: a subclass yields a fresh base `str`, + // and `is_w` rejects a `user_overridden_class` operand + // (unicodeobject.py:106). + if replacements == 0 && unsafe { pyre_object::is_exact_type(pos[0], &pyre_object::STR_TYPE) } { + return Ok(pos[0]); + } + Ok(w_str_from_wtf8_managed(out)) } /// WTF-8 window for the optional `start` / `end` search args: resolve them @@ -2347,6 +2353,29 @@ pub fn format_value_dispatch(val: PyObjectRef, spec: &str) -> Result Result { + if spec.is_empty() && unsafe { pyre_object::is_exact_type(val, &pyre_object::STR_TYPE) } { + return Ok(val); + } + Ok(pyre_object::w_str_from_wtf8_managed(format_value_dispatch( + val, spec, + )?)) +} + /// The type name of `obj` for a TypeError message — the `w_class` name /// for instances, else the storage type name. pub(crate) fn arg_type_name(obj: PyObjectRef) -> String { @@ -2390,6 +2419,12 @@ pub fn builtin_value_format(args: &[PyObjectRef]) -> Result PyObjectRef { /// WTF-8 bytes (rstring.py:220-309 `replace_count` isutf8 path). A /// negative `maxcount` means no limit; an empty `sub` inserts `by` at /// every code-point boundary, including the ends. -fn wtf8_replace(input: &Wtf8, sub: &Wtf8, by: &Wtf8, maxcount: i64) -> Wtf8Buf { +/// +/// Returns the `(res, replacements)` pair `replace_count` does — `descr_replace` +/// (unicodeobject.py:1175-1176) keys its identity shortcut on the count, not on +/// the resulting bytes. +fn wtf8_replace(input: &Wtf8, sub: &Wtf8, by: &Wtf8, maxcount: i64) -> (Wtf8Buf, usize) { if maxcount == 0 { - return input.to_wtf8_buf(); + return (input.to_wtf8_buf(), 0); } let inp = input.as_bytes(); let sub_b = sub.as_bytes(); let mut out = Wtf8Buf::new(); let mut start = 0usize; let mut maxcount = maxcount; + let mut replacements = 0usize; if sub_b.is_empty() { let mut indices = input.code_point_indices().map(|(i, _)| i); // Skip the leading boundary at 0; it is handled by the first @@ -5042,6 +5082,7 @@ fn wtf8_replace(input: &Wtf8, sub: &Wtf8, by: &Wtf8, maxcount: i64) -> Wtf8Buf { loop { out.push_wtf8(by); maxcount -= 1; + replacements += 1; if start == inp.len() || maxcount == 0 { break; } @@ -5057,13 +5098,14 @@ fn wtf8_replace(input: &Wtf8, sub: &Wtf8, by: &Wtf8, maxcount: i64) -> Wtf8Buf { out.push_wtf8(by); start = next + sub_b.len(); maxcount -= 1; + replacements += 1; } None => break, } } } out.push_wtf8(unsafe { Wtf8::from_bytes_unchecked(&inp[start..]) }); - out + (out, replacements) } /// PyPy: unicodeobject.py descr_partition @@ -5172,7 +5214,7 @@ pub fn str_method_splitlines(args: &[PyObjectRef]) -> Result Result PyObjectRef { new_bytes_like(recv, b"") } +/// [`new_bytes_like`] for a piece cut out of `recv`'s own storage. +/// +/// The cut is `self._value[start:stop]`, and `ll_stringslice_startstop` +/// (rstr.py:867-869) hands the source string back unchanged when it spans the +/// whole (`start == 0 and stop >= len`); `is_w` (bytesobject.py:34-35) then +/// reports the piece identical to its operand. A piece cut from `recv` spans +/// it whole exactly when their lengths agree. +/// +/// Immutable exact `bytes` only: `_new` on a `bytearray` must produce a fresh +/// mutable object, a subclass cuts to a fresh base `bytes`, and `is_w` rejects +/// a `user_overridden_class` operand (bytesobject.py:30-31). +/// +/// Restricted to cuts — a transform that preserves the length has no upstream +/// identity shortcut, so routing one through here would create a divergence. +fn cut_bytes_like(recv: PyObjectRef, piece: &[u8]) -> PyObjectRef { + if piece.len() == unsafe { pyre_object::bytesobject::bytes_like_data(recv) }.len() + && unsafe { + pyre_object::pyobject::is_exact_type(recv, &pyre_object::bytesobject::BYTES_TYPE) + } + { + return recv; + } + new_bytes_like(recv, piece) +} + fn bytes_method_upper(args: &[PyObjectRef]) -> Result { crate::type_methods::require_receiver(args, "upper")?; let data = unsafe { pyre_object::bytesobject::bytes_like_data(args[0]) }; @@ -16885,7 +16910,7 @@ fn bytes_strip( hi -= 1; } } - Ok(new_bytes_like(args[0], &data[lo..hi])) + Ok(cut_bytes_like(args[0], &data[lo..hi])) } fn bytes_method_strip(args: &[PyObjectRef]) -> Result { @@ -16976,7 +17001,10 @@ fn type_name_of(obj: PyObjectRef) -> String { /// Non-overlapping left-to-right byte replacement, capped at `limit`. /// An empty `old` inserts `new` before every byte and at the end, per /// CPython `bytes.replace(b"", ...)`. -fn replace_bytes(data: &[u8], old: &[u8], new: &[u8], limit: usize) -> Vec { +/// +/// Returns the `(res, replacements)` pair `replace_count` (rstring.py:220-309) +/// does: `descr_replace` keys its identity shortcut on the count. +fn replace_bytes(data: &[u8], old: &[u8], new: &[u8], limit: usize) -> (Vec, usize) { let mut out = Vec::with_capacity(data.len()); let mut count = 0; if old.is_empty() { @@ -16989,8 +17017,9 @@ fn replace_bytes(data: &[u8], old: &[u8], new: &[u8], limit: usize) -> Vec { } if count < limit { out.extend_from_slice(new); + count += 1; } - return out; + return (out, count); } let mut i = 0; while i < data.len() { @@ -17003,7 +17032,7 @@ fn replace_bytes(data: &[u8], old: &[u8], new: &[u8], limit: usize) -> Vec { i += 1; } } - out + (out, count) } const BYTES_WHITESPACE: [u8; 6] = [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]; @@ -17162,7 +17191,7 @@ fn bytes_split(args: &[PyObjectRef], forward: bool) -> Result = parts.iter().map(|p| new_bytes_like(pos[0], p)).collect(); + let items: Vec = parts.iter().map(|p| cut_bytes_like(pos[0], p)).collect(); Ok(pyre_object::w_list_new(items)) } @@ -17202,10 +17231,20 @@ fn bytes_method_replace(args: &[PyObjectRef]) -> Result usize::MAX, }; - Ok(new_bytes_like( - pos[0], - &replace_bytes(data, old, new, limit), - )) + let (out, replacements) = replace_bytes(data, old, new, limit); + // `descr_replace` returns `self` when nothing was replaced + // (unicodeobject.py:1175-1176 for the str twin) — keyed on the count, so + // `b.replace(b'a', b'a')` still builds a new object. Exact `bytes` only: + // a `bytearray` must stay a fresh mutable object, and `is_w` rejects a + // `user_overridden_class` operand (bytesobject.py:30-31). + if replacements == 0 + && unsafe { + pyre_object::pyobject::is_exact_type(pos[0], &pyre_object::bytesobject::BYTES_TYPE) + } + { + return Ok(pos[0]); + } + Ok(new_bytes_like(pos[0], &out)) } /// `stringmethods.py:descr_join` — concatenate the bytes-like elements @@ -17297,9 +17336,10 @@ fn bytes_partition(args: &[PyObjectRef], forward: bool) -> Result { - // A bytearray receiver must not alias into the result tuple - // (mutating it would mutate the tuple); hand back a fresh copy. - let whole = new_bytes_like(args[0], data); + // `cut_bytes_like` keeps a bytearray receiver out of the result + // tuple (mutating it would mutate the tuple) and hands an + // immutable exact `bytes` back unchanged. + let whole = cut_bytes_like(args[0], data); let empty = || empty_bytes_like(args[0]); if forward { Ok(pyre_object::w_tuple_new(vec![whole, empty(), empty()])) @@ -17599,7 +17639,7 @@ fn bytes_method_removeprefix(args: &[PyObjectRef]) -> Result Result Result Result PyObjectRef { } } +/// `_utf8_sliced` (unicodeobject.py:1373-1379) — wrap a piece cut out of +/// `recv`'s own WTF-8 storage. +/// +/// The cut goes through `self._utf8[start:stop]`, and +/// `ll_stringslice_startstop` (rstr.py:867-869) hands the source string back +/// unchanged when the cut spans it whole (`start == 0 and stop >= len`). The +/// piece then shares its operand's storage, so `is_w` (unicodeobject.py:110-111) +/// reports the two identical. A piece cut from `recv` spans it whole exactly +/// when their WTF-8 byte counts agree. +/// +/// Only an exact `str` comes back unchanged: a subclass cuts to a fresh base +/// `str`, and `is_w` rejects a `user_overridden_class` operand anyway +/// (unicodeobject.py:106). +/// +/// Restricted to cuts. A transform (`lower`, `casefold`, a `replace` that did +/// work) can preserve the byte count while changing the bytes, and none of +/// those has an upstream identity shortcut, so routing one through here would +/// manufacture a divergence rather than close one. +/// +/// # Safety +/// `recv` must point to a valid `W_UnicodeObject`, and `piece` must be a +/// contiguous cut of its WTF-8 storage. +pub unsafe fn w_str_cut(recv: PyObjectRef, piece: &Wtf8) -> PyObjectRef { + if piece.len() == unsafe { w_str_get_wtf8(recv) }.len() + && unsafe { is_exact_type(recv, &STR_TYPE) } + { + return recv; + } + w_str_from_wtf8_managed(piece.to_wtf8_buf()) +} + /// Immortal `w_str_from_wtf8`: always allocates through `malloc_typed`, /// bypassing the `gc_interp` gate so the result is never collected. /// From 041402cfdd86778e703000743a24bc0e0f718ea0 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Mon, 27 Jul 2026 16:42:18 +0900 Subject: [PATCH 2/2] str/bytes: split removeprefix's slice arm from removesuffix's empty-argument arm The whole-span shortcut belongs to `ll_stringslice_startstop` alone (rstr.py:867-869). A one-bound `s[start:]` resolves to `ll_stringslice_startonly` (rstr.py:857-858), which calls `_ll_stringslice` directly and always builds a fresh string, `start == 0` included. `descr_removeprefix` (stringmethods.py:875-880, unicodeobject.py:1497-1505) slices with that one-bound form, so an empty prefix allocates and `b.removeprefix(b"") is b` is False; only the no-match arm rewraps the receiver's own storage. Routing the result through `cut_bytes_like` collapsed the two arms into one and returned the receiver for an empty prefix. `descr_removesuffix` (stringmethods.py:882-889, unicodeobject.py:1507-1517) guards its slice arm with `if suffix and ...`, so an empty suffix reaches the rewrap arm and `s.removesuffix("") is s` is True. The str side matched on `strip_suffix` alone, which succeeds for an empty suffix, and took the slice arm. Both now carry the upstream two-arm shape. The bytes side of removesuffix already had the guard. `w_str_cut` and `cut_bytes_like` document the one-bound caveat: check which RPython slice helper the upstream arm resolves to before routing a new call site through them. Assisted-by: Claude --- pyre/pyre-interpreter/src/type_methods.rs | 14 +++++++-- pyre/pyre-interpreter/src/typedef.rs | 36 +++++++++++++++-------- pyre/pyre-object/src/unicodeobject.rs | 10 +++++++ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/pyre/pyre-interpreter/src/type_methods.rs b/pyre/pyre-interpreter/src/type_methods.rs index 11af56eb7bc..3d8fb93ab1d 100644 --- a/pyre/pyre-interpreter/src/type_methods.rs +++ b/pyre/pyre-interpreter/src/type_methods.rs @@ -5267,10 +5267,18 @@ pub fn str_method_removesuffix(args: &[PyObjectRef]) -> Result Ok(w_str_from_wtf8_managed(rest.to_wtf8_buf())), - None => Ok(str_result_unchanged(pos[0])), + // `descr_removesuffix` (unicodeobject.py:1511-1517) guards the slice arm + // with `if suffix and ...`, so an empty suffix falls through to the arm + // that rewraps the receiver's own storage — `s.removesuffix("") is s` for + // an exact `str`. `removeprefix` has no such guard: its slice arm goes + // through `ll_stringslice_startonly` (rstr.py:857-858), which has no + // whole-span shortcut, so an empty prefix still builds a fresh object. + if !suffix.is_empty() + && let Some(rest) = s.strip_suffix(suffix) + { + return Ok(w_str_from_wtf8_managed(rest.to_wtf8_buf())); } + Ok(str_result_unchanged(pos[0])) } /// PyPy: unicodeobject.py descr_expandtabs diff --git a/pyre/pyre-interpreter/src/typedef.rs b/pyre/pyre-interpreter/src/typedef.rs index f83ec29370a..e9ebc8a5e05 100644 --- a/pyre/pyre-interpreter/src/typedef.rs +++ b/pyre/pyre-interpreter/src/typedef.rs @@ -16845,6 +16845,14 @@ fn empty_bytes_like(recv: PyObjectRef) -> PyObjectRef { /// /// Restricted to cuts — a transform that preserves the length has no upstream /// identity shortcut, so routing one through here would create a divergence. +/// +/// Restricted further to cuts upstream spells with **both** bounds. Only +/// `ll_stringslice_startstop` carries the shortcut; a one-bound `s[start:]` +/// resolves to `ll_stringslice_startonly` (rstr.py:857-858), which always +/// builds a fresh string. `descr_removeprefix`'s `selfval[len(prefix):]` +/// (stringmethods.py:879) is one of those, so it allocates even for an empty +/// prefix and does not come here. Check which helper the upstream arm +/// resolves to before routing a new call site through this function. fn cut_bytes_like(recv: PyObjectRef, piece: &[u8]) -> PyObjectRef { if piece.len() == unsafe { pyre_object::bytesobject::bytes_like_data(recv) }.len() && unsafe { @@ -17634,12 +17642,15 @@ fn bytes_method_removeprefix(args: &[PyObjectRef]) -> Result Ok(new_bytes_like(args[0], rest)), + None => Ok(cut_bytes_like(args[0], data)), + } } /// `bytes.removesuffix` — drop a trailing bytes-like suffix if present. @@ -17655,12 +17666,13 @@ fn bytes_method_removesuffix(args: &[PyObjectRef]) -> Result PyObjectRef { /// those has an upstream identity shortcut, so routing one through here would /// manufacture a divergence rather than close one. /// +/// Restricted further to cuts upstream spells with **both** bounds. Only +/// `ll_stringslice_startstop` carries the shortcut; a one-bound `s[start:]` +/// goes through `ll_stringslice_startonly` (rstr.py:857-858), which calls +/// `_ll_stringslice` directly and always builds a fresh string. So a method +/// whose match arm slices with a single bound — `descr_removeprefix`'s +/// `selfval[len(prefix):]` (stringmethods.py:879) — must allocate even when an +/// empty argument makes the cut span the receiver whole, and must not come +/// here. Check which helper the upstream arm resolves to before routing a new +/// call site through this function. +/// /// # Safety /// `recv` must point to a valid `W_UnicodeObject`, and `piece` must be a /// contiguous cut of its WTF-8 storage.