Repository: narwhals-dev/narwhals
Pull request #2154 (2025-03-06 09:50:00)

perf: simplify pandas scatter
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/README.md b/README.md
index a2a0198a..913b4463 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,7 @@ for an up-to-date plan of future work.
 Join the party!
 
 - [altair](https://github.com/vega/altair/)
+- [darts](https://github.com/unit8co/darts)
 - [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 - [marimo](https://github.com/marimo-team/marimo)
 - [panel-graphic-walker](https://github.com/panel-extensions/panel-graphic-walker)
diff --git a/docs/ecosystem.md b/docs/ecosystem.md
index 7c9cbb6c..acc7ff0c 100644
--- a/docs/ecosystem.md
+++ b/docs/ecosystem.md
@@ -6,6 +6,7 @@ The following is a non-exhaustive list of libraries and tools that choose to use
 for their dataframe interoperability needs:
 
 * [altair](https://github.com/vega/altair/)
+* [darts](https://github.com/unit8co/darts)
 * [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 * [marimo](https://github.com/marimo-team/marimo)
 * [panel-graphic-walker](https://github.com/panel-extensions/panel-graphic-walker)
diff --git a/docs/installation.md b/docs/installation.md
index 11ad75b6..24ad5504 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -30,7 +30,7 @@ To verify the installation, start the Python REPL and execute:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.29.0'
+'1.29.1'
 ```
 
 If you see the version number, then the installation was successful!
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 70bcfb26..feb5e240 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -80,7 +80,7 @@ from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_reset_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.29.0"
+__version__ = "1.29.1"
 
 __all__ = [
     "Array",
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 40680de8..141c98fb 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -403,18 +403,28 @@ class ArrowSeries(CompliantSeries):
     def scatter(self: Self, indices: int | Sequence[int], values: Any) -> Self:
         import numpy as np  # ignore-banned-import
 
-        mask: _1DArray = np.zeros(self.len(), dtype=bool)
-        mask[indices] = True
-        if isinstance(values, self.__class__):
-            ser, values = extract_native(self, values)
+        if isinstance(indices, int):
+            indices_native = pa.array([indices])
+            values_native = pa.array([values])
         else:
-            ser = self._native_series
-        if isinstance(values, pa.ChunkedArray):
-            values = values.combine_chunks()
-        if not isinstance(values, pa.Array):
-            values = pa.array(values)
+            # TODO(unassigned): we may also want to let `indices` be a Series.
+            # https://github.com/narwhals-dev/narwhals/issues/2155
+            indices_native = pa.array(indices)
+            if isinstance(values, self.__class__):
+                values_native = values._native_series.combine_chunks()
+            else:
+                values_native = pa.array(values)
+
+        sorting_indices = pc.sort_indices(indices_native)  # type: ignore[call-overload]
+        indices_native = pc.take(indices_native, sorting_indices)
+        values_native = pc.take(values_native, sorting_indices)
+
+        mask: _1DArray = np.zeros(self.len(), dtype=bool)
+        mask[indices_native] = True
         result = pc.replace_with_mask(
-            ser, cast("list[bool]", mask), values.take(cast("Indices", indices))
+            self._native_series,
+            cast("list[bool]", mask),
+            values_native.take(cast("Indices", indices_native)),
         )
         return self._from_native_series(result)
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index ea05c77a..0f9eba7e 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -227,11 +227,8 @@ class PandasLikeSeries(CompliantSeries):
 
     def scatter(self: 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 = align_and_extract_native(self, values)
             values = set_index(
-                values.copy(),
+                values._native_series,
                 self._native_series.index[indices],
                 implementation=self._implementation,
                 backend_version=self._backend_version,
diff --git a/narwhals/typing.py b/narwhals/typing.py
index b908e671..9b363945 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -27,7 +27,9 @@ else:
 
 if TYPE_CHECKING:
     from types import ModuleType
+    from typing import Iterable
     from typing import Mapping
+    from typing import Sized
 
     import numpy as np
     from typing_extensions import Self
@@ -53,8 +55,8 @@ if TYPE_CHECKING:
 
         def join(self, *args: Any, **kwargs: Any) -> Any: ...
 
-    class NativeSeries(Protocol):
-        def __len__(self) -> int: ...
+    class NativeSeries(Sized, Iterable[Any], Protocol):
+        def filter(self, *args: Any, **kwargs: Any) -> Any: ...
 
     class DataFrameLike(Protocol):
         def __dataframe__(self, *args: Any, **kwargs: Any) -> Any: ...
diff --git a/pyproject.toml b/pyproject.toml
index 0ef18f69..21a252f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.29.0"
+version = "1.29.1"
 dependencies = []
 requires-python = ">=3.8"
 authors = [
@@ -195,15 +195,16 @@ filterwarnings = [
   # This warning was temporarily raised by pandas but then reverted.
   'ignore:.*Passing a BlockManager to DataFrame:DeprecationWarning',
   # This warning was temporarily raised by Polars but then reverted.
-  'ignore:.*The default coalesce behavior of left join will change:DeprecationWarning',
+  'ignore:.*The default coalesce behavior of left join will change:DeprecationWarning:polars',
   'ignore: unclosed <socket.socket',
   'ignore:.*The distutils package is deprecated and slated for removal in Python 3.12:DeprecationWarning:pyspark',
   'ignore:.*distutils Version classes are deprecated. Use packaging.version instead.*:DeprecationWarning:pyspark',
   'ignore:.*is_datetime64tz_dtype is deprecated and will be removed in a future version.*:DeprecationWarning:pyspark',
   # Warning raised by PyArrow nightly just by importing pandas
   'ignore:.*Python binding for RankQuantileOptions not exposed:RuntimeWarning:pyarrow',
-  'ignore:.*pandas only supports SQLAlchemy:UserWarning',
+  'ignore:.*pandas only supports SQLAlchemy:UserWarning:sqlframe',
   'ignore:.*numpy.core is deprecated and has been renamed to numpy._core.*:DeprecationWarning:sqlframe',
+  "ignore:.*__array__ implementation doesn't accept a copy keyword, so passing copy=False failed:DeprecationWarning:modin"
 ]
 xfail_strict = true
 markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 835fc658..fe6e04a2 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -306,7 +306,7 @@ jobs:
       - name: install-deps
         run: |
           cd plotly.py
-          uv pip install -r test_requirements/requirements_312_optional.txt --system
+          uv pip install -r test_requirements/requirements_core.txt -r test_requirements/requirements_optional.txt --system
           uv pip install -e . --system
       - name: install-narwhals-dev
         run: |
@@ -493,3 +493,43 @@ jobs:
           touch tests/utils/__init__.py
           pytest tests
         timeout-minutes: 15
+  
+  darts:
+    strategy:
+      matrix:
+        python-version: ["3.11"]
+        os: ["ubuntu-latest"]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: "true"
+          cache-suffix: ${{ matrix.python-version }}
+          cache-dependency-glob: "pyproject.toml"
+      - name: clone-darts
+        run: |
+          git clone https://github.com/unit8co/darts.git --depth=1
+          cd darts
+          git log
+      - name: install-deps
+        run: |
+          cd darts
+          uv pip install -r requirements/core.txt -r requirements/dev.txt --system
+      - name: install-narwhals-dev
+        run: |
+          uv pip uninstall narwhals --system
+          # TODO(FBruzzesi): Install polars from darts requirements when they introcude it
+          uv pip install -e ".[polars]" --system
+      - name: show-deps
+        run: uv pip freeze
+      - name: Run tests
+        run: |
+          cd darts
+          pytest darts/tests/test_timeseries.py
+          pytest darts/tests/test_timeseries_multivariate.py
+          pytest darts/tests/test_timeseries_static_covariates.py
diff --git a/tests/conftest.py b/tests/conftest.py
index bf5d97b6..244d31b9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
     from narwhals.typing import IntoDataFrame
     from narwhals.typing import IntoFrame
 
-MIN_PANDAS_NULLABLE_VERSION = (1, 5)
+MIN_PANDAS_NULLABLE_VERSION = (2,)
 
 # When testing cudf.pandas in Kaggle, we get an error if we try to run
 # python -m cudf.pandas -m pytest --constructors=pandas. This gives us
diff --git a/tests/series_only/scatter_test.py b/tests/series_only/scatter_test.py
index 11065ec9..d7f0aa77 100644
--- a/tests/series_only/scatter_test.py
+++ b/tests/series_only/scatter_test.py
@@ -49,3 +49,28 @@ def test_single_series(constructor_eager: ConstructorEager) -> None:
     s.scatter([0, 1], [999, 888])
     expected = {"a": [1, 2, 3]}
     assert_equal_data({"a": s}, expected)
+
+
+def test_scatter_integer(constructor_eager: ConstructorEager) -> None:
+    df = nw.from_native(
+        constructor_eager({"a": [1, 2, 3], "b": [142, 124, 132]}), eager_only=True
+    )
+    s = df["a"]
+    result = s.scatter(0, 999)
+    expected = {"a": [999, 2, 3]}
+    assert_equal_data({"a": result}, expected)
+
+
+def test_scatter_unordered_indices(
+    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
+) -> None:
+    if "modin" in str(constructor_eager):
+        # bugged
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [16, 12, 10, 9, 6, 5, 2],
+    }
+    indices = [6, 1, 0, 5, 3, 2, 4]
+    df = nw.from_native(constructor_eager(data))
+    result = df["a"].scatter(indices, df["a"])
+    assert_equal_data({"a": result}, {"a": [10, 12, 5, 6, 2, 9, 16]})
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 7b48c8a7..ab607f4a 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -292,16 +292,20 @@ def test_from_mock_interchange_protocol_non_strict() -> None:
     assert result is mockdf
 
 
-def test_from_native_altair_array_like() -> None:
+def test_from_native_strict_native_series() -> None:
     obj: list[int] = [1, 2, 3, 4]
     array_like = cast("Iterable[Any]", obj)
     not_array_like: Literal[1] = 1
+    np_array = pl.Series(obj).to_numpy()
 
     with pytest.raises(TypeError, match="got.+list"):
-        false_positive_native_series = nw.from_native(obj, series_only=True)  # noqa: F841
+        nw.from_native(obj, series_only=True)  # type: ignore[call-overload]
 
     with pytest.raises(TypeError, match="got.+list"):
-        true_negative_iterable = nw.from_native(array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
+        nw.from_native(array_like, series_only=True)  # type: ignore[call-overload]
 
     with pytest.raises(TypeError, match="got.+int"):
-        true_negative_not_native_series = nw.from_native(not_array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
+        nw.from_native(not_array_like, series_only=True)  # type: ignore[call-overload]
+
+    with pytest.raises(TypeError, match="got.+numpy.ndarray"):
+        nw.from_native(np_array, series_only=True)  # type: ignore[call-overload]