Repository: narwhals-dev/narwhals

Issue:
[Bug]: PyArrow: multi-element `__getitem__` is not supported
### Describe the bug

The code below raises
```python
Traceback (most recent call last):
  File "/home/marcogorelli/polars-api-compat-dev/t.py", line 20, in <module>
    print(func(pa.table(data)))
          ^^^^^^^^^^^^^^^^^^^^
  File "/home/marcogorelli/polars-api-compat-dev/narwhals/translate.py", line 717, in wrapper
    result = func(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^
  File "/home/marcogorelli/polars-api-compat-dev/t.py", line 16, in func
    return df['a'][[0, 1]]
           ~~~~~~~^^^^^^^^
  File "/home/marcogorelli/polars-api-compat-dev/narwhals/series.py", line 57, in __getitem__
    return self._from_compliant_series(self._compliant_series[idx])
                                       ~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/home/marcogorelli/polars-api-compat-dev/narwhals/_arrow/series.py", line 318, in __getitem_
_
    return self._from_native_series(self._native_series[idx])
                                    ~~~~~~~~~~~~~~~~~~~^^^^^
  File "pyarrow/table.pxi", line 306, in pyarrow.lib.ChunkedArray.__getitem__
TypeError: 'list' object cannot be interpreted as an integer
```

### Steps or code to reproduce the bug

```python
# ruff: noqa

import polars as pl
import narwhals as nw
import pandas as pd
import duckdb
import pandas as pd
import pyarrow as pa
import dask.dataframe as dd

data = {'a': [1, 1, 2], 'b': [5, 4, 3]}


@nw.narwhalify
def func(df):
    return df['a'][[0, 1]]

print(func(pd.DataFrame(data)))
print(func(pl.DataFrame(data)))
print(func(pa.table(data)))
```

### Expected results

Equivalent of
```
0    1
1    1
Name: a, dtype: int64
shape: (2,)
Series: 'a' [i64]
[
        1
        1
]
```
for pandas/Polars

### Actual results

error

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.11.9 (main, Jul 25 2024, 22:42:09) [Clang 18.1.8 ]
executable: /home/marcogorelli/polars-api-compat-dev/.venv/bin/python
   machine: Linux-5.15.153.1-microsoft-standard-WSL2-x86_64-with-glibc2.35

Python dependencies:
     narwhals: 1.6.3
       pandas: 2.2.2
       polars: 1.6.0
         cudf: 
        modin: 
      pyarrow: 17.0.0
        numpy: 2.1.1
```


### Relevant log output

_No response_

Discussion:

Patch:
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index b637f550..c016b566 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -12,6 +12,7 @@
         - any
         - arg_true
         - cast
+        - clip
         - count
         - cum_sum
         - diff
@@ -22,7 +23,6 @@
         - gather_every
         - head
         - is_between
-        - clip
         - is_duplicated
         - is_empty
         - is_first_distinct
@@ -38,12 +38,13 @@
         - min
         - mode
         - name
-        - null_count
         - n_unique
+        - null_count
         - pipe
         - quantile
         - round
         - sample
+        - scatter
         - shape
         - shift
         - sort
diff --git a/docs/installation.md b/docs/installation.md
index 58302d41..5a49dba8 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.6.4'
+'1.7.0'
 ```
 then installation worked correctly!
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index f410a1b2..d76ad226 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -53,7 +53,7 @@ from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.6.4"
+__version__ = "1.7.0"
 
 __all__ = [
     "dependencies",
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 50927426..73390fdd 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -321,8 +321,27 @@ class ArrowSeries:
     def __getitem__(self, idx: int | slice | Sequence[int]) -> Any | Self:
         if isinstance(idx, int):
             return self._native_series[idx]
+        if isinstance(idx, Sequence):
+            return self._from_native_series(self._native_series.take(idx))
         return self._from_native_series(self._native_series[idx])
 
+    def scatter(self, indices: int | Sequence[int], values: Any) -> Self:
+        import numpy as np  # ignore-banned-import
+        import pyarrow as pa  # ignore-banned-import
+        import pyarrow.compute as pc  # ignore-banned-import
+
+        ca = self._native_series
+        mask = np.zeros(len(ca), dtype=bool)
+        mask[indices] = True
+        if isinstance(values, self.__class__):
+            values = validate_column_comparand(values)
+        if isinstance(values, pa.ChunkedArray):
+            values = values.combine_chunks()
+        if not isinstance(values, pa.Array):
+            values = pa.array(values)
+        result = pc.replace_with_mask(ca, mask, values.take(indices))
+        return self._from_native_series(result)
+
     def to_list(self) -> Any:
         return self._native_series.to_pylist()
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index a13f252e..8288be26 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -10,6 +10,7 @@ from typing import overload
 from narwhals._pandas_like.utils import int_dtype_mapper
 from narwhals._pandas_like.utils import narwhals_to_native_dtype
 from narwhals._pandas_like.utils import native_series_from_iterable
+from narwhals._pandas_like.utils import set_axis
 from narwhals._pandas_like.utils import to_datetime
 from narwhals._pandas_like.utils import translate_dtype
 from narwhals._pandas_like.utils import validate_column_comparand
@@ -167,6 +168,22 @@ class PandasLikeSeries:
     def dtype(self) -> DType:
         return translate_dtype(self._native_series)
 
+    def scatter(self, indices: int | Sequence[int], values: Any) -> Self:
+        if isinstance(values, self.__class__):
+            # .copy() is necessary in some pre-2.2 versions of pandas to avoid
+            # `values` also getting modified (!)
+            values = validate_column_comparand(self._native_series.index, values).copy()
+            values = set_axis(
+                values,
+                self._native_series.index[indices],
+                implementation=self._implementation,
+                backend_version=self._backend_version,
+            )
+        s = self._native_series
+        s.iloc[indices] = values
+        s.name = self.name
+        return self._from_native_series(s)
+
     def cast(
         self,
         dtype: Any,
diff --git a/narwhals/series.py b/narwhals/series.py
index 147838e4..9fcb07a2 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -85,6 +85,58 @@ class Series:
         ca = pa.chunked_array([self.to_arrow()])
         return ca.__arrow_c_stream__(requested_schema=requested_schema)
 
+    def scatter(self, indices: int | Sequence[int], values: Any) -> Self:
+        """
+        Set value(s) at given position(s).
+
+        Arguments:
+           indices: Position(s) to set items at.
+           values: Values to set.
+
+        Warning:
+            For some libraries (pandas, Polars), this method operates in-place,
+            whereas for others (PyArrow) it doesn't!
+            We recommend being careful with it, and not relying on the
+            in-placeness. For example, a valid use case is when updating
+            a column in an eager dataframe, see the example below.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(df["a"].scatter([0, 1], [999, 888]))
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+                 a  b
+            0  999  4
+            1  888  5
+            2    3  6
+            >>> func(df_pl)
+            shape: (3, 2)
+            ┌─────┬─────┐
+            │ a   ┆ b   │
+            │ --- ┆ --- │
+            │ i64 ┆ i64 │
+            ╞═════╪═════╡
+            │ 999 ┆ 4   │
+            │ 888 ┆ 5   │
+            │ 3   ┆ 6   │
+            └─────┴─────┘
+        """
+        return self._from_compliant_series(
+            self._compliant_series.scatter(indices, self._extract_native(values))
+        )
+
     @property
     def shape(self) -> tuple[int]:
         """
diff --git a/pyproject.toml b/pyproject.toml
index 12482a34..5ec7fef5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.6.4"
+version = "1.7.0"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index f6e5303c..ec599def 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -162,6 +162,7 @@ if (
             "value_counts",
             "zip_with",
             "item",
+            "scatter",
         }
     )
 ):


Test patch:
diff --git a/tests/series_only/scatter_test.py b/tests/series_only/scatter_test.py
new file mode 100644
index 00000000..2edab2b8
--- /dev/null
+++ b/tests/series_only/scatter_test.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+
+def test_scatter(constructor_eager: Any, request: pytest.FixtureRequest) -> None:
+    if "modin" in str(constructor_eager):
+        # https://github.com/modin-project/modin/issues/7392
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(
+        constructor_eager({"a": [1, 2, 3], "b": [142, 124, 132]}), eager_only=True
+    )
+    result = df.with_columns(
+        df["a"].scatter([0, 1], [999, 888]),
+        df["b"].scatter([0, 2, 1], df["b"]),
+    )
+    expected = {
+        "a": [999, 888, 3],
+        "b": [142, 132, 124],
+    }
+    compare_dicts(result, expected)
diff --git a/tests/series_only/slice_test.py b/tests/series_only/slice_test.py
new file mode 100644
index 00000000..f9d2b4e2
--- /dev/null
+++ b/tests/series_only/slice_test.py
@@ -0,0 +1,15 @@
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_slice(constructor_eager: Any) -> None:
+    data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = {"a": df["a"][[0, 1]]}
+    expected = {"a": [1, 2]}
+    compare_dicts(result, expected)
+    result = {"a": df["a"][1:]}
+    expected = {"a": [2, 3]}
+    compare_dicts(result, expected)