Repository navigation
fix: match Polars in str.slice for negative offset, raise on negative length - #3969
Conversation
|
test-sdist failed with 'Unexpected packages/ (workspace) directory inside the sdist'. the diff here touches only src and test files, the sdist config is unchanged from main, and the same check passed on my other pr (#3970) built from the same base an hour earlier, so it looks like a packaging/runner flake rather than something introduced here. would a re-run be ok? |
|
Hey @ptimizeroracle first and foremost, thanks for splitting the PR into two. May I ask why there are changes unrelated to the |
af15771 to
ac995aa
Compare
|
you were right, and thank you for the precise diagnosis. the cause: when splitting the pr i checked out whole files from the older combined branch, which silently reverted main's newer starts_with/ends_with, replace and zfill changes living in the same files. fixed in ac995aa: the backend files are main's versions again with only the slice methods changed, verified by diffing against main (only slice hunks remain). the zfill and starts_with failures disappear locally, full suite 15777 passed across nine constructors. the sdist job's packages/ leak should also follow, as it was building from the reverted tree |
- pandas, pyarrow and dask computed stop = offset + length, which lands before the end-relative start for a negative offset: slice(-3, 3) on "fdas" returned an empty string instead of "das". all three now slice from offset and head-slice the result, matching polars - the shared SQL backend computed a non-positive substring start for strings shorter than |offset|, and DuckDB silently drops a character there: slice(-3) on "ab" returned "a". the start is clamped to 1 via greatest - a negative length was inconsistent: polars raised InvalidOperationError while the other backends silently returned empty or wrong strings. the stable Expr and Series accessors now validate it up-front with validate_str_non_negative, like str.zfill's width split out of narwhals-dev#3955 per review; the float->int cast fixes follow in a separate pr Signed-off-by: ptimizeroracle <contact@binblok.com>
Signed-off-by: ptimizeroracle <contact@binblok.com>
ac995aa to
bfbb87b
Compare
FBruzzesi
left a comment
There was a problem hiding this comment.
Hey @ptimizeroracle thanks for splitting this out and for the clean-up after the last round, the diff is now nicely scoped to str.slice.
Rejecting a negative length up-front is a good call. Polars already raises there, and it reuses the zfill validation pattern. The DuckDB non-positive substr start is a real bug too.
I checked each backend against native Polars and found one more thing to handle before merging. When a negative offset reaches before the start of the string, Polars doesn't just clamp the start to 0. It also shortens length by the overhang:
import polars as pl
pl.Series(["ab"]).str.slice(-3, 2).to_list() # starts 1 char before "a" -> keeps 2 - 1 = 1 char
['a']
pl.Series(["fdas"]).str.slice(-10, 3).to_list() # overhang 6 >= length 3
['']The two-step approach (slice from offset, then take the first length characters) and the SQL greatest(1, ...) clamp keep the full length, so these cases now differ from Polars:
| call | input | Polars | this PR (pandas, pyarrow, dask) | this PR (duckdb, sqlframe, ibis) | main (pandas, pyarrow, dask) |
|---|---|---|---|---|---|
slice(-3, 2) |
"ab" |
"a" |
"ab" |
"ab" |
"a" |
slice(-3, 1) |
"ab" |
"" |
"a" |
"a" |
"" |
slice(-5, 2) |
"fdas" |
"f" |
"fd" |
"fd" |
"f" |
slice(-10, 3) |
"fdas" |
"" |
"fda" |
"fda" |
"" |
The last column matters most. On main, pandas, pyarrow and dask already got these right, because a negative Python stop (offset + length < 0) clamps the same way Polars does. The original bug only shows up when offset < 0 <= offset + length, where stop wraps around past zero. So for those three backends the smallest fix is to keep the single native call and set stop = None in that one case:
def str_slice_stop(offset: int, length: int | None) -> int | None:
"""Translate Polars' `str.slice(offset, length)` into a Python-slice `stop`."""
if length is None or offset < 0 <= offset + length:
return None
return offset + lengthPutting it in narwhals/_utils.py lets pandas-like, pyarrow and dask share it instead of each carrying its own copy of the logic. The inline suggestions below assume that helper exists.
On a grid of 10 offsets x 6 lengths over ["abcde", "ab", "", None, "héllo"], this plus the SQL change suggested below gives 0 mismatches against native Polars. The branch as it stands has 11 mismatches per backend. The current tests all use length >= abs(offset), which is why CI didn't catch it; I've suggested extra cases on the test file.
replace the two-step slice with a shared str_slice_stop helper: when a negative offset overhangs the string start, a negative python stop clamps exactly like polars shortens the length, and the offset < 0 <= offset+length window returns None instead of a nonsense forward index. sql backends shorten the substr count by the same overhang. tests gain the overhang cases from the review grid.
|
applied all suggestions, thanks for the thorough review and for catching the overhang cases — the grid made it easy to verify.
i also ran the parity grid locally, slightly wider than yours (offsets -10..10, lengths None,0,1,2,3,5,10, over ["abcde","ab","","héllo"]): 147 cases per backend, 0 mismatches against native polars on pandas, pandas[pyarrow], pyarrow, dask, duckdb and sqlframe |
str.slice for negative offset, raise on negative length
|
pulled your commit locally, all nine constructors green on the slice tests, ci fully green too. good catch on pre-0.20.17 polars having the same overhang bug — the reverse-slice workaround is a neat trick, and it means narwhals now returns consistent results even where the reference backend itself was wrong |
FBruzzesi
left a comment
There was a problem hiding this comment.
Thanks for bearing with me @ptimizeroracle

Aligns
Expr.str.slice/Series.str.slicewith Polars whenoffsetis negative, and rejects a negativelengthon every backend.When a negative
offsetreaches before the start of the string, Polars clamps the start to 0 and shortenslengthby the overhang:slice(-3, 2)on"ab"is"a",slice(-10, 3)on"fdas"is"".offset + lengthwas used as a Python-slicestop, which wraps around whenoffset < 0 <= offset + length(slice(-3, 3)on"fdas"gave""). The sharedstr_slice_stophelper returnsNonein that case and keeps a single native call.substrstart is clamped to 1 and the count is shortened by the overhang. DuckDB previously dropped a character (slice(-3)on"ab"gave"a").str.slicedidn't shortenlengthon overhang and miscounted negative offsets on multi-byte strings. Patched by slicing the reversed string.length: Polars raisedInvalidOperationErrorwhile other backends returned wrong strings. It now raisesValueErrorup-front on all backends (same validation asstr.zfill'swidth), includingstable.v1/stable.v2.Checked against native Polars on a grid of offsets and lengths, including nulls, empty strings and strings shorter than
|offset|.