Repository: narwhals-dev/narwhals

Issue:
[Bug]: when-then-otherwise breaks for pyarrow with `nw.lit`
### Describe the bug

Using `nw.lit` in pyarrow when-then-otherwise raises an exception:

> ArrowInvalid: Array arguments must all be the same length

### Steps or code to reproduce the bug

```py
import pyarrow as pa
import narwhals as nw

data = {"a": [1,2,3], "b": [True, False, True]}
df = nw.from_native(pa.table(data))
(df
 .with_columns(
     c=(
         nw.when(nw.col("b"))
         .then(nw.col("a"))
         .otherwise(nw.lit(None))
     )
 )
)
```

### Expected results

Propagate lit value

### Actual results

Unexpected exception is raised

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

```shell
latest
```


### Relevant log output

_No response_

Discussion:

Patch:
diff --git a/docs/backcompat.md b/docs/backcompat.md
index 3c4a19a7..55f31aad 100644
--- a/docs/backcompat.md
+++ b/docs/backcompat.md
@@ -74,8 +74,8 @@ users of `narwhals.stable.v1` will have their code unaffected.
 Which should you use? In general we recommend:
 
 - When prototyping, use `import narwhals as nw`, so you can iterate quickly.
-- Once you're happy with what you've got and what to release something production-ready and stable,
-  when switch out your `import narwhals as nw` usage for `import narwhals.stable.v1 as nw`.
+- Once you're happy with what you've got and want to release something production-ready and stable,
+  then switch out your `import narwhals as nw` usage for `import narwhals.stable.v1 as nw`.
 
 ## Exceptions
 
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 905ada59..4bab24a2 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -682,6 +682,11 @@ class ArrowDataFrame:
 
         n_rows = len(self)
 
+        promote_kwargs = (
+            {"promote_options": "permissive"}
+            if self._backend_version >= (14, 0, 0)
+            else {}
+        )
         return self._from_native_frame(
             pa.concat_tables(
                 [
@@ -694,6 +699,9 @@ class ArrowDataFrame:
                         names=[*index_, variable_name, value_name],
                     )
                     for on_col in on_
-                ]
+                ],
+                **promote_kwargs,
             )
         )
+        # TODO(Unassigned): Even with promote_options="permissive", pyarrow does not
+        # upcast numeric to non-numeric (e.g. string) datatypes
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 7f0bcbec..c0e86de6 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -11,6 +11,7 @@ from narwhals._arrow.dataframe import ArrowDataFrame
 from narwhals._arrow.expr import ArrowExpr
 from narwhals._arrow.selectors import ArrowSelectorNamespace
 from narwhals._arrow.series import ArrowSeries
+from narwhals._arrow.utils import broadcast_series
 from narwhals._arrow.utils import horizontal_concat
 from narwhals._arrow.utils import vertical_concat
 from narwhals._expression_parsing import combine_root_names
@@ -353,7 +354,8 @@ class ArrowWhen:
                 self._otherwise_value, namespace=plx
             )._call(df)[0]  # type: ignore[arg-type]
         except TypeError:
-            # `self._otherwise_value` is a scalar and can't be converted to an expression
+            # `self._otherwise_value` is a scalar and can't be converted to an expression.
+            # Remark that string values _are_ converted into expressions!
             return [
                 value_series._from_native_series(
                     pc.if_else(
@@ -364,7 +366,14 @@ class ArrowWhen:
         else:
             otherwise_series = cast(ArrowSeries, otherwise_series)
             condition = cast(ArrowSeries, condition)
-            return [value_series.zip_with(condition, otherwise_series)]
+            condition_native, otherwise_native = broadcast_series(
+                [condition, otherwise_series]
+            )
+            return [
+                value_series._from_native_series(
+                    pc.if_else(condition_native, value_series_native, otherwise_native)
+                )
+            ]
 
     def then(self, value: ArrowExpr | ArrowSeries | Any) -> ArrowThen:
         self._then_value = value
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index f2038346..366a52e1 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -212,7 +212,9 @@ def agg_pandas(  # noqa: PLR0915
             result_aggs = result_simple_aggs
         else:
             # No aggregation provided
-            result_aggs = native_namespace.DataFrame(grouped.groups.keys(), columns=keys)
+            result_aggs = native_namespace.DataFrame(
+                list(grouped.groups.keys()), columns=keys
+            )
         return from_dataframe(result_aggs.loc[:, output_names])
 
     if dataframe_is_empty:


Test patch:
diff --git a/tests/expr_and_series/when_test.py b/tests/expr_and_series/when_test.py
index 99398874..6fabaa68 100644
--- a/tests/expr_and_series/when_test.py
+++ b/tests/expr_and_series/when_test.py
@@ -138,3 +138,10 @@ def test_when_then_otherwise_into_expr(constructor: Constructor) -> None:
     result = df.select(nw.when(nw.col("a") > 1).then("c").otherwise("e"))
     expected = {"c": [7, 5, 6]}
     compare_dicts(result, expected)
+
+
+def test_when_then_otherwise_lit_str(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.when(nw.col("a") > 1).then(nw.col("b")).otherwise(nw.lit("z")))
+    expected = {"b": ["z", "b", "c"]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/unpivot_test.py b/tests/frame/unpivot_test.py
index 2adad646..d169d5ca 100644
--- a/tests/frame/unpivot_test.py
+++ b/tests/frame/unpivot_test.py
@@ -1,11 +1,19 @@
 from __future__ import annotations
 
+from typing import TYPE_CHECKING
+from typing import Any
+
+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
 
+if TYPE_CHECKING:
+    from narwhals.stable.v1.dtypes import DType
+
 data = {
     "a": ["x", "y", "z"],
     "b": [1, 3, 5],
@@ -70,3 +78,29 @@ def test_unpivot_default_var_value_names(constructor: Constructor) -> None:
     result = df.unpivot(on=["b", "c"], index=["a"])
 
     assert result.collect_schema().names()[-2:] == ["variable", "value"]
+
+
+@pytest.mark.parametrize(
+    ("data", "expected_dtypes"),
+    [
+        (
+            {"idx": [0, 1], "a": [1, 2], "b": [1.5, 2.5]},
+            [nw.Int64(), nw.String(), nw.Float64()],
+        ),
+    ],
+)
+def test_unpivot_mixed_types(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    data: dict[str, Any],
+    expected_dtypes: list[DType],
+) -> None:
+    if "dask" in str(constructor) or (
+        "pyarrow_table" in str(constructor)
+        and parse_version(pa.__version__) < parse_version("14.0.0")
+    ):
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor(data))
+    result = df.unpivot(on=["a", "b"], index="idx")
+
+    assert result.collect_schema().dtypes() == expected_dtypes