Repository: narwhals-dev/narwhals
Pull request #1155 (2024-10-08 18:30:13)

enh: Add `.rows(named=False)` support for pyarrow
I'm in the camp of _slow support is better than no support_.

If there ever comes a time that pyarrow supports retrieving row values more natively, then this code can be updated.

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue # NA
- Closes # NA

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.

posit-dev/py-shiny uses `.rows(named=False)` to retrieve the values 



Patch:
diff --git a/README.md b/README.md
index bcf314a4..44fc31e5 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ Join the party!
 - [scikit-lego](https://github.com/koaning/scikit-lego)
 - [scikit-playtime](https://github.com/koaning/scikit-playtime)
 - [timebasedcv](https://github.com/FBruzzesi/timebasedcv)
+- [marimo](https://github.com/marimo-team/marimo)
 
 Feel free to add your project to the list if it's missing, and/or
 [chat with us on Discord](https://discord.gg/V3PqtB4VA4) if you'd like any support.
diff --git a/docs/api-reference/expr_dt.md b/docs/api-reference/expr_dt.md
index e04e9288..5c9ab41f 100644
--- a/docs/api-reference/expr_dt.md
+++ b/docs/api-reference/expr_dt.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - convert_time_zone
         - date
         - year
         - month
@@ -15,6 +16,7 @@
         - millisecond
         - microsecond
         - nanosecond
+        - replace_time_zone
         - total_minutes
         - total_seconds
         - total_milliseconds
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index ba342ad3..c9259241 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - convert_time_zone
         - date
         - year
         - month
@@ -15,6 +16,7 @@
         - millisecond
         - microsecond
         - nanosecond
+        - replace_time_zone
         - total_minutes
         - total_seconds
         - total_milliseconds
diff --git a/docs/installation.md b/docs/installation.md
index 0fc41013..76ff0504 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -13,7 +13,7 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.9.1'
+'1.9.2'
 ```
 then installation worked correctly!
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 10c3741e..75e2b0fd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -19,12 +19,12 @@ nav:
   - extending.md
   - how_it_works.md
   - Roadmap and related projects: roadmap_and_related.md
-  # Commented-out until https://github.com/narwhals-dev/narwhals/issues/1004 is addressed
-  # - API Completeness:
-  #   - api-completeness/index.md
-  #   - Supported DataFrame methods: api-completeness/dataframe.md
-  #   - Supported Expr methods: api-completeness/expr.md
-  #   - Supported Series methods: api-completeness/series.md
+  - API Completeness:
+    - api-completeness/index.md
+    - Supported DataFrame methods: api-completeness/dataframe.md
+    - Supported LazyFrame methods: api-completeness/lazyframe.md
+    - Supported Expr methods: api-completeness/expr.md
+    - Supported Series methods: api-completeness/series.md
   - API Reference:
     - api-reference/narwhals.md
     - api-reference/dataframe.md
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 3e367460..03d857d0 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -59,7 +59,7 @@ from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_reset_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.9.1"
+__version__ = "1.9.2"
 
 __all__ = [
     "dependencies",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 4bab24a2..a0a4e16c 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -87,8 +87,7 @@ class ArrowDataFrame:
         self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
         if not named:
-            msg = "Unnamed rows are not yet supported on PyArrow tables"
-            raise NotImplementedError(msg)
+            return list(self.iter_rows(named=False))  # type: ignore[return-value]
         return self._native_frame.to_pylist()  # type: ignore[no-any-return]
 
     def iter_rows(
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index c70425ef..55c529d3 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -410,6 +410,16 @@ class ArrowExprDateTimeNamespace:
             self._expr, "dt", "to_string", format
         )
 
+    def replace_time_zone(self: Self, time_zone: str | None) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "replace_time_zone", time_zone
+        )
+
+    def convert_time_zone(self: Self, time_zone: str) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "convert_time_zone", time_zone
+        )
+
     def date(self: Self) -> ArrowExpr:
         return reuse_series_namespace_implementation(self._expr, "dt", "date")
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 50702349..65a393ca 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -755,6 +755,31 @@ class ArrowSeriesDateTimeNamespace:
             pc.strftime(self._arrow_series._native_series, format)
         )
 
+    def replace_time_zone(self: Self, time_zone: str | None) -> ArrowSeries:
+        import pyarrow.compute as pc  # ignore-banned-import()
+
+        if time_zone is not None:
+            result = pc.assume_timezone(
+                pc.local_timestamp(self._arrow_series._native_series), time_zone
+            )
+        else:
+            result = pc.local_timestamp(self._arrow_series._native_series)
+        return self._arrow_series._from_native_series(result)
+
+    def convert_time_zone(self: Self, time_zone: str) -> ArrowSeries:
+        import pyarrow as pa  # ignore-banned-import
+
+        if self._arrow_series.dtype.time_zone is None:  # type: ignore[attr-defined]
+            result = self.replace_time_zone("UTC")._native_series.cast(
+                pa.timestamp(self._arrow_series._native_series.type.unit, time_zone)
+            )
+        else:
+            result = self._arrow_series._native_series.cast(
+                pa.timestamp(self._arrow_series._native_series.type.unit, time_zone)
+            )
+
+        return self._arrow_series._from_native_series(result)
+
     def date(self: Self) -> ArrowSeries:
         import pyarrow as pa  # ignore-banned-import()
 
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 10b95bc8..693fcad5 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -10,6 +10,7 @@ from typing import NoReturn
 from narwhals._dask.utils import add_row_index
 from narwhals._dask.utils import maybe_evaluate
 from narwhals._dask.utils import narwhals_to_native_dtype
+from narwhals._pandas_like.utils import native_to_narwhals_dtype
 from narwhals.utils import generate_unique_token
 
 if TYPE_CHECKING:
@@ -925,6 +926,33 @@ class DaskExprDateTimeNamespace:
             returns_scalar=False,
         )
 
+    def replace_time_zone(self, time_zone: str | None) -> DaskExpr:
+        return self._expr._from_call(
+            lambda _input, _time_zone: _input.dt.tz_localize(None).dt.tz_localize(
+                _time_zone
+            )
+            if _time_zone is not None
+            else _input.dt.tz_localize(None),
+            "tz_localize",
+            time_zone,
+            returns_scalar=False,
+        )
+
+    def convert_time_zone(self, time_zone: str) -> DaskExpr:
+        def func(s: dask_expr.Series, time_zone: str) -> dask_expr.Series:
+            dtype = native_to_narwhals_dtype(s, self._expr._dtypes)
+            if dtype.time_zone is None:  # type: ignore[attr-defined]
+                return s.dt.tz_localize("UTC").dt.tz_convert(time_zone)
+            else:
+                return s.dt.tz_convert(time_zone)
+
+        return self._expr._from_call(
+            func,
+            "tz_convert",
+            time_zone,
+            returns_scalar=False,
+        )
+
     def total_minutes(self) -> DaskExpr:
         return self._expr._from_call(
             lambda _input: _input.dt.total_seconds() // 60,
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/expr.py b/narwhals/_pandas_like/expr.py
index 2ebadbe1..07ba3e56 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -572,6 +572,16 @@ class PandasLikeExprDateTimeNamespace:
             self._expr, "dt", "to_string", format
         )
 
+    def replace_time_zone(self, time_zone: str | None) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "replace_time_zone", time_zone
+        )
+
+    def convert_time_zone(self, time_zone: str) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "convert_time_zone", time_zone
+        )
+
 
 class PandasLikeExprNameNamespace:
     def __init__(self: Self, expr: PandasLikeExpr) -> None:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 9cca6640..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)
@@ -928,3 +928,21 @@ class PandasLikeSeriesDateTimeNamespace:
         return self._pandas_series._from_native_series(
             self._pandas_series._native_series.dt.strftime(format)
         )
+
+    def replace_time_zone(self, time_zone: str | None) -> PandasLikeSeries:
+        if time_zone is not None:
+            result = self._pandas_series._native_series.dt.tz_localize(
+                None
+            ).dt.tz_localize(time_zone)
+        else:
+            result = self._pandas_series._native_series.dt.tz_localize(None)
+        return self._pandas_series._from_native_series(result)
+
+    def convert_time_zone(self, time_zone: str) -> PandasLikeSeries:
+        if self._pandas_series.dtype.time_zone is None:  # type: ignore[attr-defined]
+            result = self._pandas_series._native_series.dt.tz_localize(
+                "UTC"
+            ).dt.tz_convert(time_zone)
+        else:
+            result = self._pandas_series._native_series.dt.tz_convert(time_zone)
+        return self._pandas_series._from_native_series(result)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index d7ecc98f..381a78c8 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -218,8 +218,8 @@ def set_axis(
     return obj.set_axis(index, axis=0, **kwargs)  # type: ignore[attr-defined, no-any-return]
 
 
-def native_to_narwhals_dtype(column: Any, dtypes: DTypes) -> DType:
-    dtype = str(column.dtype)
+def native_to_narwhals_dtype(native_column: Any, dtypes: DTypes) -> DType:
+    dtype = str(native_column.dtype)
 
     pd_datetime_rgx = (
         r"^datetime64\[(?P<time_unit>s|ms|us|ns)(?:, (?P<time_zone>[a-zA-Z\/]+))?\]$"
@@ -282,26 +282,30 @@ def native_to_narwhals_dtype(column: Any, dtypes: DTypes) -> DType:
         return dtypes.Date()
     if dtype.startswith(("large_list", "list")):
         return dtypes.List(
-            arrow_native_to_narwhals_dtype(column.dtype.pyarrow_dtype.value_type, dtypes)
+            arrow_native_to_narwhals_dtype(
+                native_column.dtype.pyarrow_dtype.value_type, dtypes
+            )
         )
     if dtype.startswith("fixed_size_list"):
         return dtypes.Array(
-            arrow_native_to_narwhals_dtype(column.dtype.pyarrow_dtype.value_type, dtypes),
-            column.dtype.pyarrow_dtype.list_size,
+            arrow_native_to_narwhals_dtype(
+                native_column.dtype.pyarrow_dtype.value_type, dtypes
+            ),
+            native_column.dtype.pyarrow_dtype.list_size,
         )
     if dtype.startswith("struct"):
         return dtypes.Struct()
     if dtype == "object":
         if (  # pragma: no cover  TODO(unassigned): why does this show as uncovered?
-            idx := getattr(column, "first_valid_index", lambda: None)()
-        ) is not None and isinstance(column.loc[idx], str):
+            idx := getattr(native_column, "first_valid_index", lambda: None)()
+        ) is not None and isinstance(native_column.loc[idx], str):
             # Infer based on first non-missing value.
             # For pandas pre 3.0, this isn't perfect.
             # After pandas 3.0, pandas has a dedicated string dtype
             # which is inferred by default.
             return dtypes.String()
         else:
-            df = column.to_frame()
+            df = native_column.to_frame()
             if hasattr(df, "__dataframe__"):
                 from narwhals._interchange.dataframe import (
                     map_interchange_dtype_to_narwhals_dtype,
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/dataframe.py b/narwhals/dataframe.py
index e4ad31b3..b0ac1c32 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -7,6 +7,7 @@ from typing import Generic
 from typing import Iterable
 from typing import Iterator
 from typing import Literal
+from typing import NoReturn
 from typing import Sequence
 from typing import TypeVar
 from typing import overload
@@ -2787,7 +2788,7 @@ class LazyFrame(BaseFrame[FrameT]):
             + "┘"
         )
 
-    def __getitem__(self, item: str | slice) -> Series | Self:
+    def __getitem__(self, item: str | slice) -> NoReturn:
         msg = "Slicing is not supported on LazyFrame"
         raise TypeError(msg)
 
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 8446d81c..abf9c104 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3501,6 +3501,119 @@ class ExprDateTimeNamespace:
             lambda plx: self._expr._call(plx).dt.to_string(format)
         )
 
+    def replace_time_zone(self, time_zone: str | None) -> Expr:
+        """
+        Replace time zone.
+
+        Arguments:
+            time_zone: Target time zone.
+
+        Examples:
+            >>> from datetime import datetime, timezone
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {
+            ...     "a": [
+            ...         datetime(2024, 1, 1, tzinfo=timezone.utc),
+            ...         datetime(2024, 1, 2, tzinfo=timezone.utc),
+            ...     ]
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a").dt.replace_time_zone("Asia/Kathmandu"))
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(df_pd)
+                                      a
+            0 2024-01-01 00:00:00+05:45
+            1 2024-01-02 00:00:00+05:45
+            >>> func(df_pl)
+            shape: (2, 1)
+            ┌──────────────────────────────┐
+            │ a                            │
+            │ ---                          │
+            │ datetime[μs, Asia/Kathmandu] │
+            ╞══════════════════════════════╡
+            │ 2024-01-01 00:00:00 +0545    │
+            │ 2024-01-02 00:00:00 +0545    │
+            └──────────────────────────────┘
+            >>> func(df_pa)  # doctest:+SKIP
+            pyarrow.Table
+            a: timestamp[us, tz=Asia/Kathmandu]
+            ----
+            a: [[2023-12-31 18:15:00.000000Z,2024-01-01 18:15:00.000000Z]]
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).dt.replace_time_zone(time_zone)
+        )
+
+    def convert_time_zone(self, time_zone: str) -> Expr:
+        """
+        Convert to a new time zone.
+
+        Arguments:
+            time_zone: Target time zone.
+
+        Examples:
+            >>> from datetime import datetime, timezone
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = {
+            ...     "a": [
+            ...         datetime(2024, 1, 1, tzinfo=timezone.utc),
+            ...         datetime(2024, 1, 2, tzinfo=timezone.utc),
+            ...     ]
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> df_pa = pa.table(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a").dt.convert_time_zone("Asia/Kathmandu"))
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(df_pd)
+                                      a
+            0 2024-01-01 05:45:00+05:45
+            1 2024-01-02 05:45:00+05:45
+            >>> func(df_pl)
+            shape: (2, 1)
+            ┌──────────────────────────────┐
+            │ a                            │
+            │ ---                          │
+            │ datetime[μs, Asia/Kathmandu] │
+            ╞══════════════════════════════╡
+            │ 2024-01-01 05:45:00 +0545    │
+            │ 2024-01-02 05:45:00 +0545    │
+            └──────────────────────────────┘
+            >>> func(df_pa)  # doctest:+SKIP
+            pyarrow.Table
+            a: timestamp[us, tz=Asia/Kathmandu]
+            ----
+            a: [[2024-01-01 00:00:00.000000Z,2024-01-02 00:00:00.000000Z]]
+        """
+        if time_zone is None:
+            msg = "Target `time_zone` cannot be `None` in `convert_time_zone`. Please use `replace_time_zone(None)` if you want to remove the time zone."
+            raise TypeError(msg)
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).dt.convert_time_zone(time_zone)
+        )
+
 
 class ExprNameNamespace:
     def __init__(self: Self, expr: Expr) -> None:
diff --git a/narwhals/series.py b/narwhals/series.py
index 1753598c..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
@@ -3880,3 +3894,112 @@ class SeriesDateTimeNamespace:
         return self._narwhals_series._from_compliant_series(
             self._narwhals_series._compliant_series.dt.to_string(format)
         )
+
+    def replace_time_zone(self, time_zone: str | None) -> Series:
+        """
+        Replace time zone.
+
+        Arguments:
+            time_zone: Target time zone.
+
+        Examples:
+            >>> from datetime import datetime, timezone
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [
+            ...     datetime(2024, 1, 1, tzinfo=timezone.utc),
+            ...     datetime(2024, 1, 2, tzinfo=timezone.utc),
+            ... ]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.dt.replace_time_zone("Asia/Kathmandu")
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(s_pd)
+            0   2024-01-01 00:00:00+05:45
+            1   2024-01-02 00:00:00+05:45
+            dtype: datetime64[ns, Asia/Kathmandu]
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [datetime[μs, Asia/Kathmandu]]
+            [
+                2024-01-01 00:00:00 +0545
+                2024-01-02 00:00:00 +0545
+            ]
+            >>> func(s_pa)  # doctest: +SKIP
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                2023-12-31 18:15:00.000000Z,
+                2024-01-01 18:15:00.000000Z
+              ]
+            ]
+        """
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.dt.replace_time_zone(time_zone)
+        )
+
+    def convert_time_zone(self, time_zone: str) -> Series:
+        """
+        Convert time zone.
+
+        Arguments:
+            time_zone: Target time zone.
+
+        Examples:
+            >>> from datetime import datetime, timezone
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import pyarrow as pa
+            >>> data = [
+            ...     datetime(2024, 1, 1, tzinfo=timezone.utc),
+            ...     datetime(2024, 1, 2, tzinfo=timezone.utc),
+            ... ]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+            >>> s_pa = pa.chunked_array([data])
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.dt.convert_time_zone("Asia/Kathmandu")
+
+            We can then pass pandas / PyArrow / Polars / any other supported library:
+
+            >>> func(s_pd)
+            0   2024-01-01 05:45:00+05:45
+            1   2024-01-02 05:45:00+05:45
+            dtype: datetime64[ns, Asia/Kathmandu]
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [datetime[μs, Asia/Kathmandu]]
+            [
+                2024-01-01 05:45:00 +0545
+                2024-01-02 05:45:00 +0545
+            ]
+            >>> func(s_pa)  # doctest: +SKIP
+            <pyarrow.lib.ChunkedArray object at ...>
+            [
+              [
+                2024-01-01 00:00:00.000000Z,
+                2024-01-02 00:00:00.000000Z
+              ]
+            ]
+        """
+        if time_zone is None:
+            msg = "Target `time_zone` cannot be `None` in `convert_time_zone`. Please use `replace_time_zone(None)` if you want to remove the time zone."
+            raise TypeError(msg)
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.dt.convert_time_zone(time_zone)
+        )
diff --git a/pyproject.toml b/pyproject.toml
index 48cae220..92866a6a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.9.1"
+version = "1.9.2"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
diff --git a/utils/generate_backend_completeness.py b/utils/generate_backend_completeness.py
index 7f2e23ca..d7d05daa 100644
--- a/utils/generate_backend_completeness.py
+++ b/utils/generate_backend_completeness.py
@@ -64,9 +64,43 @@ def parse_module(module_name: str, backend: str, nw_class_name: str) -> list[str
     return methods_
 
 
+def render_table_and_write_to_output(
+    results: list[pl.DataFrame], title: str, output_filename: str
+) -> None:
+    results = (
+        pl.concat(results)  # noqa: PD010
+        .with_columns(supported=pl.lit(":white_check_mark:"))
+        .pivot(on="Backend", values="supported", index=["Class", "Method"])
+        .filter(pl.col("narwhals").is_not_null())
+        .drop("narwhals")
+        .fill_null(":x:")
+        .sort("Class", "Method")
+    )
+
+    with pl.Config(
+        tbl_formatting="ASCII_MARKDOWN",
+        tbl_hide_column_data_types=True,
+        tbl_hide_dataframe_shape=True,
+        set_tbl_rows=results.shape[0],
+        set_tbl_width_chars=1_000,
+    ):
+        table = str(results)
+
+    with TEMPLATE_PATH.open(mode="r") as stream:
+        new_content = Template(stream.read()).render(
+            {"backend_table": table, "title": title}
+        )
+
+    with (DESTINATION_PATH / f"{output_filename}.md").open(mode="w") as destination:
+        destination.write(new_content)
+
+    return table
+
+
 def get_backend_completeness_table() -> None:
     for module_name in MODULES:
         results = []
+        processed_classes = set()
 
         nw_namespace = f"narwhals.{module_name}"
 
@@ -82,7 +116,7 @@ def get_backend_completeness_table() -> None:
 
             nw_methods = get_class_methods(nw_class)
 
-            narhwals = pl.DataFrame(
+            narwhals = pl.DataFrame(
                 {"Class": nw_class_name, "Backend": "narwhals", "Method": nw_methods}
             )
 
@@ -102,34 +136,22 @@ def get_backend_completeness_table() -> None:
                 for backend in BACKENDS
             ]
 
-            results.extend([narhwals, *backend_methods])
+            results.extend([narwhals, *backend_methods])
 
-        results = (
-            pl.concat(results)  # noqa: PD010
-            .with_columns(supported=pl.lit(":white_check_mark:"))
-            .pivot(on="Backend", values="supported", index=["Class", "Method"])
-            .filter(pl.col("narwhals").is_not_null())
-            .drop("narwhals")
-            .fill_null(":x:")
-            .sort("Class", "Method")
-        )
+            if nw_class_name in {"DataFrame", "LazyFrame"}:
+                render_table_and_write_to_output(
+                    results=[narwhals, *backend_methods],
+                    title=nw_class_name,
+                    output_filename=nw_class_name.lower(),
+                )
+                processed_classes.add(nw_class_name)
 
-        with pl.Config(
-            tbl_formatting="ASCII_MARKDOWN",
-            tbl_hide_column_data_types=True,
-            tbl_hide_dataframe_shape=True,
-            set_tbl_rows=results.shape[0],
-            set_tbl_width_chars=1_000,
-        ):
-            table = str(results)
-
-        with TEMPLATE_PATH.open(mode="r") as stream:
-            new_content = Template(stream.read()).render(
-                {"backend_table": table, "title": module_name.capitalize()}
-            )
+        if processed_classes == {"DataFrame", "LazyFrame"}:
+            continue
 
-        with (DESTINATION_PATH / f"{module_name}.md").open(mode="w") as destination:
-            destination.write(new_content)
+        render_table_and_write_to_output(
+            results=results, title=module_name.capitalize(), output_filename=module_name
+        )
 
 
 _ = get_backend_completeness_table()


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index bf4f7d39..20058a43 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -31,6 +31,7 @@ jobs:
       - name: Run pytest
         run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=85
       - name: Run doctests
+        if: startsWith(matrix.os, 'windows') != true
         run: pytest narwhals --doctest-modules
 
   pytest-windows:
diff --git a/tests/expr_and_series/convert_time_zone_test.py b/tests/expr_and_series/convert_time_zone_test.py
new file mode 100644
index 00000000..ee4ccaec
--- /dev/null
+++ b/tests/expr_and_series/convert_time_zone_test.py
@@ -0,0 +1,120 @@
+from datetime import datetime
+from datetime import timezone
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pyarrow as pa
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
+from tests.utils import Constructor
+from tests.utils import compare_dicts
+from tests.utils import is_windows
+
+
+def test_convert_time_zone(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows()) or (
+        "pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (2, 1)
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").dt.convert_time_zone("Asia/Kathmandu"))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
+    expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
+    compare_dicts(result_str, expected)
+
+
+def test_convert_time_zone_series(
+    constructor_eager: Any, request: pytest.FixtureRequest
+) -> None:
+    if (
+        any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows()
+    ) or (
+        "pandas_pyarrow" in str(constructor_eager)
+        and parse_version(pd.__version__) < (2, 1)
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].dt.convert_time_zone("Asia/Kathmandu"))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
+    expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
+    compare_dicts(result_str, expected)
+
+
+def test_convert_time_zone_from_none(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if (
+        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        or (
+            "pandas_pyarrow" in str(constructor)
+            and parse_version(pd.__version__) < (2, 1)
+        )
+        or ("pyarrow_table" in str(constructor) and parse_version(pa.__version__) < (12,))
+    ):
+        request.applymarker(pytest.mark.xfail)
+    if "polars" in str(constructor) and parse_version(pl.__version__) < (0, 20, 7):
+        # polars used to disallow this
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor(data))
+    result = df.select(
+        nw.col("a").dt.replace_time_zone(None).dt.convert_time_zone("Asia/Kathmandu")
+    )
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
+    expected = {"a": ["2020-01-01T05:45+0545", "2020-01-02T05:45+0545"]}
+    compare_dicts(result_str, expected)
+
+
+def test_convert_time_zone_to_none(constructor: Constructor) -> None:
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor(data))
+    with pytest.raises(TypeError, match="Target `time_zone` cannot be `None`"):
+        df.select(nw.col("a").dt.convert_time_zone(None))  # type: ignore[arg-type]
+
+
+def test_convert_time_zone_to_none_series(constructor_eager: Any) -> None:
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor_eager(data))
+    with pytest.raises(TypeError, match="Target `time_zone` cannot be `None`"):
+        df["a"].dt.convert_time_zone(None)  # type: ignore[arg-type]
diff --git a/tests/expr_and_series/replace_time_zone_test.py b/tests/expr_and_series/replace_time_zone_test.py
new file mode 100644
index 00000000..560fcfe8
--- /dev/null
+++ b/tests/expr_and_series/replace_time_zone_test.py
@@ -0,0 +1,125 @@
+from datetime import datetime
+from datetime import timezone
+from typing import Any
+
+import pandas as pd
+import pyarrow as pa
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
+from tests.utils import Constructor
+from tests.utils import compare_dicts
+from tests.utils import is_windows
+
+
+def test_replace_time_zone(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if (
+        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        or ("pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (2,))
+        or ("pyarrow_table" in str(constructor) and parse_version(pa.__version__) < (12,))
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").dt.replace_time_zone("Asia/Kathmandu"))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
+    expected = {"a": ["2020-01-01T00:00+0545", "2020-01-02T00:00+0545"]}
+    compare_dicts(result_str, expected)
+
+
+def test_replace_time_zone_none(
+    constructor: Constructor, request: pytest.FixtureRequest
+) -> None:
+    if (
+        (any(x in str(constructor) for x in ("pyarrow", "modin")) and is_windows())
+        or ("pandas_pyarrow" in str(constructor) and parse_version(pd.__version__) < (2,))
+        or ("pyarrow_table" in str(constructor) and parse_version(pa.__version__) < (12,))
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").dt.replace_time_zone(None))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone is None  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M"))
+    expected = {"a": ["2020-01-01T00:00", "2020-01-02T00:00"]}
+    compare_dicts(result_str, expected)
+
+
+def test_replace_time_zone_series(
+    constructor_eager: Any, request: pytest.FixtureRequest
+) -> None:
+    if (
+        (any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows())
+        or (
+            "pandas_pyarrow" in str(constructor_eager)
+            and parse_version(pd.__version__) < (2,)
+        )
+        or (
+            "pyarrow_table" in str(constructor_eager)
+            and parse_version(pa.__version__) < (12,)
+        )
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].dt.replace_time_zone("Asia/Kathmandu"))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone == "Asia/Kathmandu"  # type: ignore[attr-defined]
+    result_str = result.select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M%z"))
+    expected = {"a": ["2020-01-01T00:00+0545", "2020-01-02T00:00+0545"]}
+    compare_dicts(result_str, expected)
+
+
+def test_replace_time_zone_none_series(
+    constructor_eager: Any, request: pytest.FixtureRequest
+) -> None:
+    if (
+        (any(x in str(constructor_eager) for x in ("pyarrow", "modin")) and is_windows())
+        or (
+            "pandas_pyarrow" in str(constructor_eager)
+            and parse_version(pd.__version__) < (2,)
+        )
+        or (
+            "pyarrow_table" in str(constructor_eager)
+            and parse_version(pa.__version__) < (12,)
+        )
+    ):
+        request.applymarker(pytest.mark.xfail)
+    data = {
+        "a": [
+            datetime(2020, 1, 1, tzinfo=timezone.utc),
+            datetime(2020, 1, 2, tzinfo=timezone.utc),
+        ]
+    }
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].dt.replace_time_zone(None))
+    result_dtype = result.collect_schema()["a"]
+    assert result_dtype == nw.Datetime
+    assert result_dtype.time_zone is None  # type: ignore[attr-defined]
+    result_str = result.select(df["a"].dt.to_string("%Y-%m-%dT%H:%M"))
+    expected = {"a": ["2020-01-01T00:00", "2020-01-02T00:00"]}
+    compare_dicts(result_str, expected)
diff --git a/tests/frame/rows_test.py b/tests/frame/rows_test.py
index 2d94ab18..744f6606 100644
--- a/tests/frame/rows_test.py
+++ b/tests/frame/rows_test.py
@@ -92,14 +92,6 @@ def test_rows(
     expected: list[tuple[Any, ...]] | list[dict[str, Any]],
 ) -> None:
     df = nw.from_native(df_raw, eager_only=True)
-    if isinstance(df_raw, pa.Table) and not named:
-        with pytest.raises(
-            NotImplementedError,
-            match="Unnamed rows are not yet supported on PyArrow tables",
-        ):
-            df.rows(named=named)
-        return
-
     result = df.rows(named=named)
     assert result == expected
 
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)