fix(str): reject a negative width or length in zfill, pad_start and pad_end - #3770
fix(str): reject a negative width or length in zfill, pad_start and pad_end#3770mkzung wants to merge 7 commits into
Conversation
|
thanks @mkzung !
i think i'd prefer that, could we do that instead please? thanks 🙏 |
ce7c603 to
0363a10
Compare
|
Done. The Arrow backend now raises InvalidOperationError on a negative width, matching Polars, instead of returning unchanged, and width 0 stays a no-op. I also moved the regression tests to pyarrow only, since Polars' own zfill(0) raises on the older CI versions. |
| # Match that here rather than letting pyarrow's utf8_lpad(width - 1) | ||
| # crash later with a negative pad width. | ||
| msg = f"zfill does not support a negative width, got {width}." | ||
| raise InvalidOperationError(msg) |
There was a problem hiding this comment.
@MarcoGorelli should we push this check up to narwhals/expr_str.py. If we do that then
- The error can be raised as early as possible for all backends
- All implementations will have a uniform error message
If not, then I'm happy with the implementation here.
|
Worth adding that the inconsistency is wider than zfill. With a negative width today, pandas returns the string unchanged, Polars raises So moving the check up to mkzung/narwhals@main...mkzung:narwhals:fix/zfill-validate-in-public-layer Happy to fold it into this PR, or to leave it until you and @MarcoGorelli have settled where the check belongs. |
|
Moved it up to expr_str.py, so every backend now raises the same message before I kept the guards in series_str.py as well. Series.str.zfill calls the compliant The _arrow change is still in there for the case Marco raised earlier: a negative |
| def test_str_zfill_zero_width_pyarrow() -> None: | ||
| # A zero width is a no-op (every string is already at least 0 long). This used | ||
| # to crash on the pyarrow backend with `ArrowInvalid: Negative buffer resize`, | ||
| # because pc.case_when eagerly evaluated the utf8_lpad(width - 1) = -1 branch. | ||
| pytest.importorskip("pyarrow") | ||
| result = nw.from_dict(data, backend="pyarrow")["a"].str.zfill(0) | ||
| assert_equal_data({"a": result}, data) |
There was a problem hiding this comment.
shall we parametrise over all constructors, to make sure none of the others crash?
There was a problem hiding this comment.
The zero-width test runs over every constructor now (plus a series version) - nothing crashes, only the pandas+pyarrow xfail.
| if width < 0: | ||
| msg = f"`width` must be non-negative but got {width}" | ||
| raise InvalidOperationError(msg) |
There was a problem hiding this comment.
this is repeated a few times, could/should it be a util function? like validate_width
There was a problem hiding this comment.
Pulled the check into a validate_width helper in _utils.py, used by zfill and both pad methods.
Moves the check camriddell suggested up from the Arrow backend into Expr.str.zfill and Series.str.zfill, so every backend fails the same way and fails early. Before this, zfill(-1) returned the string unchanged on pandas, raised an internal 'conversion from i128 to u64 failed' on Polars, and crashed inside utf8_lpad on pyarrow. The Arrow width == 0 short-circuit stays: that one is a real backend quirk.
Same bug as zfill, same place. On pyarrow, pad_start(-1) and pad_end(-1) leak 'ArrowInvalid: Negative buffer resize' straight through narwhals; pandas returns the string unchanged; Polars raises an internal i128-to-u64 conversion error. Validating in the public layer gives all three the same error, as it now does for zfill.
`is_close` already raises "`abs_tol` must be non-negative but got ...", so use the same phrasing here rather than a second format for the same kind of check. Tests now pin the message the way the is_close ones do, and pad_start/pad_end get the expression-path case that zfill already had.
The zfill and pad comments narrated the same three backend behaviours twice over. Kept it once and used the space for the part that is not obvious from reading the test: Polars already raised InvalidOperationError here, so pinning only the type would pass against unfixed code.
One validate_width helper instead of the repeated width < 0 block across zfill/pad_start/pad_end, and the zero-width no-op now runs over every constructor rather than only pyarrow. Also drops a stale zfill docstring line that still said a negative width is a no-op.
a1282fe to
c0103c1
Compare
|
@mkzung looks like the above failures are from a behavior alignment correction we implement for older Polars versions. narwhals/src/narwhals/_polars/expr.py Line 464 in 34edd7e I think if we replace the linked line |
zfill(0) is a no-op, but on Polars <= 1.30.0 it went through the branch that re-pads a leading "+", and that branch calls zfill(width - 1). At width zero that is zfill(-1), which fails the cast to u64 even though the predicate guarding the branch is false: Polars builds the whole expression before choosing. max(width - 1, 0) keeps it valid, and the public layer already rejects a negative width, so zero is the only value that reaches it. Local polars is 1.42, where the branch never runs, so the suite is green either way. Checked against a real 1.30.0 instead: the three failing tests are the three CI reports, expression eager and lazy plus the eager series, and they pass with the fix. The rest of the suite is unchanged, 186 in tests/expr_and_series/str and 4701 overall.
|
Done, pushed. The reason it fires with the predicate false is that Polars builds the One thing I ran into while checking, separate from this PR and already on main: that branch disagrees with newer Polars when the remainder is multibyte or holds a second plus. |
I cannot replicate this. Can you re-confirm and if this issue persists then create an issue following the issue template (with an MRE). |
|
You are right not to be able to replicate it, and one half of what I wrote was wrong. It only happens on Polars What does hold is multibyte content after a leading Filed as #3838 with the MRE and the version matrix. The cause is that the branch mixes units, The two red checks here are |
Description
A negative
widthorlengthreached the backends and each one did somethingdifferent with it. pandas returned the string unchanged, Polars raised
InvalidOperationError: conversion from 'i128' to 'u64' failed ... [-1], andPyArrow raised
ArrowInvalid: Negative buffer resize: -21.pad_startandpad_enddo the same, since the argument reaches the same backend calls.This started as a PyArrow-only fix for
zfill. @camriddell raised the question ofwhether the check belonged further up in
expr_str.pyinstead, so it raises earlyand every backend reports the same thing. That is what it now does. The PyArrow
backend keeps only its
width == 0short-circuit, which is an evaluation-orderproblem rather than an argument one.
The check is in both
expr_str.pyandseries_str.pybecauseSeries.str.zfillcalls the compliant series directly rather than going through an expression, so
expr_str.pyalone would not cover it. Removing either set turns tests red.pc.case_whenis why PyArrow crashed rather than returned: it evaluates everybranch eagerly, so
pc.utf8_lpad(remaining_chars, width - 1)ran with a negativepad length before any row was selected.
The tests pin the message and not just the exception type, which matters here more
than it usually would. Polars already raised
InvalidOperationErrorfor this, justwith its internal cast message, so without the message pinned the Polars case
passes against unfixed code.
InvalidOperationErroris also what the libraryraises elsewhere for an argument rejected before anything is computed.
Tests run on every backend through the existing fixtures. The expression cases use
constructorrather thanconstructor_eager, so the lazy backends are covered aswell, where the check raises at
select()rather than atcollect().Reproduction, before this change:
What type of PR is this? (check all applicable)
Related issues
AI assistance
I used an AI coding assistant to help locate the root cause and draft the regression
test. I have read, run, and verified every line, and I take full responsibility for
the change.
Checklist
str.zfill)