Repository navigation
fix: raise InvalidOperationError for mismatched list.contains items on pyarrow-backed backends - #3915
jonasdedden wants to merge 4 commits into
Conversation
FBruzzesi
left a comment
There was a problem hiding this comment.
Thanks @jonasdedden , just a few minor comments 🙌🏼
|
Hey @FBruzzesi, just a question, this PR isn't blocked by anything, right? :) |
list.contains numeric coercion and all-null inner
|
Hey @jonasdedden sorry for the slow feedback on this one. I was about to merge, then I got ghosts from pyspark. Running it locally got me 4 errors: I am after a long day, so I won't investigate much further right now. Hopefully during the weekend I can dive deeper, yet feel free to provide a fix for these cases in either this or a separate PR 🙏🏼 |
9369346 to
ef43b60
Compare
|
@FBruzzesi thanks for noticing!! Converted this PR to draft for now, as it first requires a new 16LOC bugfix PR #3990 to be merged. |
ef43b60 to
df56d9b
Compare
|
Actually, let's keep this PR on hold again for #4001 to be merged first? What do you think @FBruzzesi ? |
|
Ok, I guess we are ready to come back to this one 🤣 |
…ns` items PyArrow, pandas and Dask leaked pyarrow's `ArrowNotImplementedError` when the item cannot be compared with the inner dtype (e.g. a bool or str item on an int list). Raise `InvalidOperationError` instead, as Polars and `ArrowSeries.__contains__` do.
…-null inner Pin how each backend handles mixed numeric kinds (unspecified, see narwhals-dev#3900), check that mismatched bool/str/datetime-precision items raise where Polars does, and that an all-null inner list contains nothing. SQL backends coerce mismatched items instead, and pyarrow-backed ones coerce the datetime precision, hence the xfails.
df56d9b to
9e34ef1
Compare
list.contains numeric coercion and all-null innerInvalidOperationError for mismatched list.contains items on pyarrow-backed backends
|
@FBruzzesi I seem to not have covered exact exception handling in edge cases in #4001, so this PR unfortunately became a |
FBruzzesi
left a comment
There was a problem hiding this comment.
Thanks a lot @jonasdedden, and thanks for your patience with the back and forth on #3990 and #4001 🙌🏼
The fix is right. While testing it I found a few close cousins that still let pyarrow errors through:
- a NaN item on a non-float list
- a timezone-mismatched datetime
- a column with zero rows
The inline comments explain each one. They all come down to the same fix: check the item's type once, before any data is processed. I've attached the full diff with that change, plus tests for the new cases and a couple of small cleanups in the test file.
There is probably one more case I didn't touch which is the OverflowError for items of 2**63 or more, since it comes from #4001 and needs a different fix. I'm happy to open a follow-up issue for it.
Feel free to push back on any of it! Once it's in, I think this is good to go 🚀
| try: | ||
| matches = pc.equal(values, lit(item)) | ||
| except pa.ArrowNotImplementedError as exc: | ||
| msg = f"Unable to compare item of type {type(item)} with list of type {block.type}." | ||
| raise InvalidOperationError(msg) from exc |
There was a problem hiding this comment.
Thanks for tracking this down! While testing it I found a few more ways pyarrow errors still get through, all close cousins of the one you fixed:
NaNitem on a non-float list:item=float("nan")takes thepc.is_nanbranch, which is outside the try. On aList(String)orList(Boolean)it raises a rawArrowNotImplementedError, while Polars raisesInvalidOperationError.- Timezone mismatch: a tz-aware datetime item on a naive
List(Datetime)(or the reverse) raisesArrowInvalid, which the except doesn't catch.ArrowSeries.__contains__catches (ArrowInvalid,ArrowNotImplementedError,ArrowTypeError), so we can match that. - Zero rows: with no rows,
_list_blocksyields nothing, sopc.equalnever runs and a mismatched item quietly returns an empty result. Polars raises here too, because it checks the dtype, not the data.
All three points can be address with a single check in list_contains, before any blocks, against an empty array of the inner type. pyarrow chooses its comparison function from the types alone, so an empty array is enough to trigger the error, and it costs about 3.5µs per call. In the attached diff:
- The null / NaN / equal branching moves into a small
_item_matcheshelper, so the check and the per-block code can't drift apart. list_containscalls_item_matches(pa.array([], value_type), item)once and maps all three pyarrow errors toInvalidOperationError._list_block_containsno longer needs a try. Once the type check has passed, any pyarrow error during the real computation depends on the data, so we shouldn't relabel it as "can't compare".
diff --git a/src/narwhals/_arrow/utils.py b/src/narwhals/_arrow/utils.py
index 2014943e6..4b1606785 100644
--- a/src/narwhals/_arrow/utils.py
+++ b/src/narwhals/_arrow/utils.py
@@ -575,10 +575,27 @@ def list_contains(array: ChunkedArrayAny, item: NonNestedLiteral) -> ChunkedArra
A running count of matches over the flattened values changes within a list iff the
list holds a match. That keeps this linear, where a group-by per list would sort.
"""
+ list_type = cast("pa.ListType[Any] | pa.LargeListType[Any]", array.type)
+ try:
+ # Probe an empty array, so that a mismatched `item` raises even without rows.
+ _item_matches(pa.array([], list_type.value_type), item)
+ except (pa.ArrowInvalid, pa.ArrowNotImplementedError, pa.ArrowTypeError) as exc:
+ msg = (
+ f"Unable to compare item of type {type(item)} with list of type {list_type}."
+ )
+ raise InvalidOperationError(msg) from exc
blocks = [_list_block_contains(block, item) for block in _list_blocks(array)]
return pa.chunked_array(blocks, pa.bool_())
+def _item_matches(values: ArrayAny, item: NonNestedLiteral) -> pa.BooleanArray:
+ if item is None:
+ return pc.is_null(values)
+ if isinstance(item, float) and math.isnan(item):
+ return pc.is_nan(values) # NaN matches NaN, as in Polars.
+ return pc.equal(values, lit(item))
+
+
def _list_blocks(array: ChunkedArrayAny) -> Iterator[ListArrayAny]:
"""Split `array` into runs of whole lists, of up to about `_LIST_BLOCK_VALUES` values.
@@ -615,16 +632,7 @@ def _list_block_contains(block: ListArrayAny, item: NonNestedLiteral) -> pa.Bool
offsets = pc.subtract(offsets, lit(first, offsets.type)) # type: ignore[arg-type]
ends, starts = offsets.slice(1), offsets.slice(0, len(block))
- if item is None:
- matches = pc.is_null(values)
- elif isinstance(item, float) and math.isnan(item):
- matches = pc.is_nan(values) # NaN matches NaN, as in Polars.
- else:
- try:
- matches = pc.equal(values, lit(item))
- except pa.ArrowNotImplementedError as exc:
- msg = f"Unable to compare item of type {type(item)} with list of type {block.type}."
- raise InvalidOperationError(msg) from exc
+ matches = _item_matches(values, item)
# Counting modulo 2^k stays exact within lists shorter than 2^k, so the narrowest
# type that fits the longest list is enough.Let me know what you think of this
There was a problem hiding this comment.
Applied, with one change: the check now runs against pa.nulls(1, value_type) instead of an empty array. pyarrow skips its timezone check on empty input, so with the empty array the tz mismatch still leaked ArrowInvalid.
One open question: Polars doesn't raise on a tz mismatch, it coerces (True on every version from 0.20.4 to 1.44). We now raise InvalidOperationError, like ArrowSeries.__contains__. Is that fine, or should we follow Polars? We could also add a test for it or leave it for the follow-up.
There was a problem hiding this comment.
timezones are a minefield territory. Happy to keep as follow up to be honest, unless the change is quite straightforward
There was a problem hiding this comment.
Also great catch on the empty array, thanks!
… data Probe the item against a single null of the inner type, so that a NaN item on a non-float list, a timezone mismatch and empty input all raise `InvalidOperationError` instead of leaking pyarrow errors or returning silently. An empty probe isn't enough, as pyarrow skips its timezone check on empty arrays. Tests: add a NaN item and a zero-row case, select xfails by item value instead of param id, and xfail the NaN case on polars<1.28, which doesn't raise there. Co-authored-by: Francesco Bruzzesi <42817048+FBruzzesi@users.noreply.github.com>
… coverage skips it The full-coverage job runs polars>=1.28, so the branch never ran there.
All good! I anyways was always extremely disappointed or annoyed by the very lackluster or inconsistent handling of list-types in various dataframe libraries (with examples such as pyarrow,pandas and Dask just outright missing important stuff), so if |
|
nice one, thanks both! 🙏 |


Description
Now that #4001 added
list.containsfor PyArrow, pandas and Dask, an item whose type can't be compared with the inner dtype (e.g. a bool or str item on an int list) leaked pyarrow'sArrowNotImplementedError. It now raisesInvalidOperationError, matching Polars andArrowSeries.__contains__.Also adds tests (follow-up from narwhals-daft differential testing) for:
2.0matches ints,1.5does not,300overflowsInt8, and1matches aFloat64list. Mixing numeric kinds is unspecified (Decide on a cross-backend coercion foris_in&__contains__#3900), so these pin today's behaviour rather than guarantee it. Polars 2.0 requires an explicit cast, hence the xfail there.InvalidOperationError, as in Polars. SQL backends coerce the item instead, and pyarrow-backed backends cast the datetime precision, hence the xfails.[None, None]contains nothing. Skipped on Ibis and PySpark, which cannot infer the type of an all-null column.What type of PR is this? (check all applicable)
Related issues
list.containsfor PyArrow, pandas and Dask #4001is_in&__contains__#3900AI assistance
Checklist
Code follows style guide (ruff)
Tests added
Documented the changes (N/A, error type only)
If this is your first PR to narwhals, attach a screenshot of
pytestpassing locally (not CI):