Repository: narwhals-dev/narwhals

Issue:
[Enh]: `Series.scatter()` should always return a new `Series`
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


posit-dev/py-shiny

### Please describe the purpose of the new feature or describe the problem to solve.

From [docs](https://github.com/narwhals-dev/narwhals/blob/b961d4eb982b945fcd8efb84f896c275086296c0/narwhals/series.py#L145-L150):
> 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.

I'd like for this behavior to be consistent: use copy on write to return a new Series

Pandas is moving towards copy on write by default in pandas v3. 

When using narwhals's .scatter method, it currently displays a warning about enabling copy on write by default for pandas v2.
```
/venv/lib/python3.12/site-packages/narwhals/_pandas_like/series.py:190: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  s.iloc[indices] = values
```
[link to related code](https://github.com/narwhals-dev/narwhals/blob/b961d4eb982b945fcd8efb84f896c275086296c0/narwhals/_pandas_like/series.py#L190)



### Suggest a solution if possible.

Change the return type of `.scatter()` to return a new `nw.Series` object.

For pandas, call `pd.options.mode.copy_on_write = True` to enable copy on write today to avoid problems later. 

For polars, update the code to return a new pl.Series object.

### If you have tried alternatives, please describe them below.

Adding `pd.options.mode.copy_on_write = True` line within pandas like `.scatter()` made the warning disappear and gave me expected behavior of a new series. 

I understand this is not a generic solution for pandas-like.

I'm currently getting around it by calling `nw_data = nw_data.clone()` before updating its Series values with `.scatter()`. 

### Additional information that may help us understand your needs.

_No response_

Discussion:
Cheukting: @MarcoGorelli all check passes! This is a joint PR with @marcoala at the sprint
Cheukting: Good catch @MarcoGorelli I have added the test and fix polars behaviour (we were wondering why polars didn't fail the test and now we know)

Patch:
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 6e96fb7c..6a629298 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -355,7 +355,6 @@ class PandasLikeDataFrame:
     ) -> Self:
         index = self._native_frame.index
         new_columns = evaluate_into_exprs(self, *exprs, **named_exprs)
-
         if not new_columns and len(self) == 0:
             return self
 
@@ -394,7 +393,11 @@ class PandasLikeDataFrame:
                 backend_version=self._backend_version,
             )
         else:
-            df = self._native_frame.copy(deep=False)
+            # This is the logic in pandas' DataFrame.assign
+            if self._backend_version < (2,):  # pragma: no cover
+                df = self._native_frame.copy(deep=True)
+            else:
+                df = self._native_frame.copy(deep=False)
             for s in new_columns:
                 df[s.name] = validate_dataframe_comparand(index, s)
         return self._from_native_frame(df)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 2532aea8..2fe53b22 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -186,7 +186,7 @@ class PandasLikeSeries:
                 implementation=self._implementation,
                 backend_version=self._backend_version,
             )
-        s = self._native_series
+        s = self._native_series.copy(deep=True)
         s.iloc[indices] = values
         s.name = self.name
         return self._from_native_series(s)
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 07804219..9ca2f704 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -212,6 +212,12 @@ class PolarsSeries:
 
         return self._from_native_series(result)
 
+    def scatter(self, indices: int | Sequence[int], values: Any) -> Self:
+        values = extract_native(values)
+        s = self._native_series.clone()
+        s.scatter(indices, values)
+        return self._from_native_series(s)
+
     def value_counts(
         self: Self,
         *,
diff --git a/narwhals/series.py b/narwhals/series.py
index a9eb51a4..0115ac34 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -139,15 +139,29 @@ class Series:
         Set value(s) at given position(s).
 
         Arguments:
-           indices: Position(s) to set items at.
-           values: Values to set.
+            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.
+        Note:
+            This method always returns a new Series, without modifying the original one.
+            Using this function in a for-loop is an anti-pattern, we recommend building
+            up your positions and values beforehand and doing an update in one go.
+
+            For example, instead of
+
+            ```python
+            for i in [1, 3, 2]:
+                value = some_function(i)
+                s = s.scatter(i, value)
+            ```
+
+            prefer
+
+            ```python
+            positions = [1, 3, 2]
+            values = [some_function(x) for x in positions]
+            s = s.scatter(positions, values)
+            ```
 
         Examples:
             >>> import pandas as pd


Test patch:
diff --git a/tests/series_only/scatter_test.py b/tests/series_only/scatter_test.py
index 2edab2b8..0677a8dd 100644
--- a/tests/series_only/scatter_test.py
+++ b/tests/series_only/scatter_test.py
@@ -24,3 +24,27 @@ def test_scatter(constructor_eager: Any, request: pytest.FixtureRequest) -> None
         "b": [142, 132, 124],
     }
     compare_dicts(result, expected)
+
+
+def test_scatter_unchanged(constructor_eager: Any) -> None:
+    df = nw.from_native(
+        constructor_eager({"a": [1, 2, 3], "b": [142, 124, 132]}), eager_only=True
+    )
+    df.with_columns(
+        df["a"].scatter([0, 1], [999, 888]), df["b"].scatter([0, 2, 1], [142, 124, 132])
+    )
+    expected = {
+        "a": [1, 2, 3],
+        "b": [142, 124, 132],
+    }
+    compare_dicts(df, expected)
+
+
+def test_single_series(constructor_eager: Any) -> None:
+    df = nw.from_native(
+        constructor_eager({"a": [1, 2, 3], "b": [142, 124, 132]}), eager_only=True
+    )
+    s = df["a"]
+    s.scatter([0, 1], [999, 888])
+    expected = {"a": [1, 2, 3]}
+    compare_dicts({"a": s}, expected)