Repository: narwhals-dev/narwhals

Issue:
[Enh]: Add `__iter__` support to `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 . Not required... just a bonus

### Please describe the purpose of the new feature or describe the problem to solve.

When trying to perform a loop over the cells in a series to check the cell values, it's nice to use `enumerate(x)` to return both the value and the position.

I would like an `__iter__` method for the `nw.Series` object.

```python
for i, val in enumerate(ser):
    ...
```

This also freely extends to other methods like `zip()`. 

### Suggest a solution if possible.

_No response_

### If you have tried alternatives, please describe them below.

I can use a counter on my own

```python
counter = -1
for val in ser:
    counter = counter + 1
    ...
```

### Additional information that may help us understand your needs.

_No response_

Discussion:

Patch:
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 9868e7b9..5adaa57a 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -6,6 +6,7 @@
       members:
         - __arrow_c_stream__
         - __getitem__
+        - __iter__
         - abs
         - alias
         - all
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 09fade68..8c864379 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
+from typing import Iterator
 from typing import Literal
 from typing import Sequence
 from typing import overload
@@ -702,6 +703,9 @@ class ArrowSeries:
             plx.col(col_token) == plx.col(col_token).max()
         )[self.name]
 
+    def __iter__(self: Self) -> Iterator[Any]:
+        yield from self._native_series.__iter__()
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 10d686ab..3ccf906f 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -12,7 +12,6 @@ from typing import cast
 from typing import overload
 
 from narwhals.dependencies import is_numpy_array
-from narwhals.utils import flatten
 
 if TYPE_CHECKING:
     from narwhals._arrow.dataframe import ArrowDataFrame
@@ -95,7 +94,7 @@ def evaluate_into_exprs(
     """Evaluate each expr into Series."""
     series: ListOfCompliantSeries = [  # type: ignore[assignment]
         item
-        for sublist in (evaluate_into_expr(df, into_expr) for into_expr in flatten(exprs))
+        for sublist in (evaluate_into_expr(df, into_expr) for into_expr in exprs)
         for item in sublist
     ]
     for name, expr in named_exprs.items():
@@ -157,9 +156,7 @@ def parse_into_exprs(
 ) -> ListOfCompliantExpr:
     """Parse each input as an expression (if it's not already one). See `parse_into_expr` for
     more details."""
-    return [
-        parse_into_expr(into_expr, namespace=namespace) for into_expr in flatten(exprs)
-    ] + [
+    return [parse_into_expr(into_expr, namespace=namespace) for into_expr in exprs] + [
         parse_into_expr(expr, namespace=namespace).alias(name)
         for name, expr in named_exprs.items()
     ]
@@ -181,7 +178,6 @@ def parse_into_expr(
     - if it's a string, then convert it to an expression
     - else, raise
     """
-
     if hasattr(into_expr, "__narwhals_expr__"):
         return into_expr  # type: ignore[return-value]
     if hasattr(into_expr, "__narwhals_series__"):
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 1b40d69d..873683dd 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
+from typing import Iterator
 from typing import Literal
 from typing import Sequence
 from typing import overload
@@ -665,6 +666,9 @@ class PandasLikeSeries:
         result.name = native_series.name
         return self._from_native_series(result)
 
+    def __iter__(self: Self) -> Iterator[Any]:
+        yield from self._native_series.__iter__()
+
     @property
     def str(self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 619e4b55..ae78eef7 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
+from typing import Iterator
 from typing import Literal
 from typing import Sequence
 from typing import overload
@@ -2407,6 +2408,9 @@ class Series:
         """
         return self._from_compliant_series(self._compliant_series.mode())
 
+    def __iter__(self: Self) -> Iterator[Any]:
+        yield from self._compliant_series.__iter__()
+
     @property
     def str(self) -> SeriesStringNamespace:
         return SeriesStringNamespace(self)
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index a56b28c5..d11c2bc9 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -28,6 +28,7 @@ SERIES_ONLY_METHODS = {
     "item",
     "scatter",
     "to_native",
+    "__iter__",
 }
 BASE_DTYPES = {"NumericType", "DType", "TemporalType"}
 


Test patch:
diff --git a/tests/series_only/__iter___test.py b/tests/series_only/__iter___test.py
new file mode 100644
index 00000000..d190cd80
--- /dev/null
+++ b/tests/series_only/__iter___test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+data = [1, 2, 3]
+
+
+def test_to_list(constructor_eager: Any) -> None:
+    s = nw.from_native(constructor_eager({"a": data}), eager_only=True)["a"]
+
+    assert isinstance(s, Iterable)
+    compare_dicts({"a": [x for x in s]}, {"a": [1, 2, 3]})  # noqa: C416