Repository: narwhals-dev/narwhals

Issue:
"random versions" ci failure
```
______________________ test_string[dask_lazy_constructor] ______________________

constructor = <function dask_lazy_constructor at 0x7f2fa4682af0>

    def test_string(constructor: Any) -> None:
        df = nw.from_native(constructor(data))
        result = df.select(string())
        expected = {"b": ["a", "b", "c"]}
>       compare_dicts(result, expected)

tests/test_selectors.py:53: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/utils.py:27: in compare_dicts
    for lhs, rhs in zip_strict(result[key], expected[key]):
narwhals/stable/v1.py:92: in __getitem__
    return _stableify(super().__getitem__(item))
narwhals/dataframe.py:576: in __getitem__
    self._compliant_frame[item],
narwhals/_pandas_like/dataframe.py:121: in __getitem__
    self._native_frame.loc[:, item],
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexing.py:1147: in __getitem__
    return self._getitem_tuple(key)
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexing.py:1330: in _getitem_tuple
    return self._getitem_lowerdim(tup)
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexing.py:1039: in _getitem_lowerdim
    section = self._getitem_axis(key, axis=i)
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexing.py:1393: in _getitem_axis
    return self._get_label(key, axis=axis)
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexing.py:1343: in _get_label
    return self.obj.xs(label, axis=axis)
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/generic.py:4222: in xs
    return self[key]
/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/frame.py:3893: in __getitem__
    indexer = self.columns.get_loc(key)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = RangeIndex(start=0, stop=0, step=1), key = 'b'

    @doc(Index.get_loc)
    def get_loc(self, key):
        if is_integer(key) or (is_float(key) and key.is_integer()):
            new_key = int(key)
            try:
                return self._range.index(new_key)
            except ValueError as err:
                raise KeyError(key) from err
        if isinstance(key, Hashable):
>           raise KeyError(key)
E           KeyError: 'b'

/opt/hostedtoolcache/Python/3.9.19/x64/lib/python3.9/site-packages/pandas/core/indexes/range.py:418: KeyError

---------- coverage: platform linux, python 3.9.19-final-0 -----------
Name                                             Stmts   Miss Branch BrPart  Cover   Missing
--------------------------------------------------------------------------------------------
narwhals/_arrow/selectors.py                        56      1     16      0    99%   54
narwhals/_arrow/utils.py                           150      1     92      1    99%   95
narwhals/_pandas_like/group_by.py                   85      1     28      0    99%   211
narwhals/_pandas_like/utils.py                     237      1    152      0    99%   496
narwhals/_polars/dataframe.py                      109      4     18      0    97%   70, 134, 179, 200
narwhals/_polars/series.py                         133      2      4      0    99%   99, 225
narwhals/dataframe.py                              256      7     30      0    97%   263-275
narwhals/series.py                                 270      7      6      0    97%   74-86
tests/expr_and_series/arithmetic_test.py            78     22      8      0    74%   163-189, 203-220
tests/expr_and_series/dt/ordinal_day_test.py        22      9      0      0    59%   22-37
tests/expr_and_series/dt/total_minutes_test.py      22      9      0      0    59%   27-46
tests/frame/arrow_c_stream_test.py                  25     13      2      0    44%   14-17, 25-30, 38-42
tests/frame/test_invalid.py                         30      9      0      0    70%   34-48
tests/hypothesis/test_basic_arithmetic.py           17      6      0      0    65%   30-49
tests/series_only/arrow_c_stream_test.py            26     14      2      0    43%   14-17, 25-28, 36-41
tests/test_group_by.py                             100      1     10      1    98%   181
tests/utils.py                                      35      3     14      0    94%   49-51
--------------------------------------------------------------------------------------------
TOTAL                                             9114    110   1753      2    99%

195 files skipped due to complete coverage.

Required test coverage of 80% reached. Total coverage: 98.90%
=========================== short test summary info ============================
FAILED tests/test_selectors.py::test_string[dask_lazy_constructor] - KeyError: 'b'
```

```
attrs==24.2.0
cachetools==5.5.0
cfgv==3.4.0
chardet==5.2.0
click==8.1.7
cloudpickle==3.0.0
colorama==0.4.6
covdefaults==2.3.0
coverage==7.6.1
dask==2024.8.0
dask-expr==1.1.10
distlib==0.3.8
exceptiongroup==1.2.2
filelock==3.15.4
fsspec==2024.6.1
hypothesis==6.111.1
identify==2.6.0
importlib-metadata==8.2.0
iniconfig==2.0.0
joblib==1.4.2
locket==1.0.0
-e file:///home/runner/work/narwhals/narwhals
nodeenv==1.9.1
numpy==1.25.2
packaging==24.1
pandas==2.1.4
partd==1.4.2
pip==24.2
platformdirs==4.2.2
pluggy==1.5.0
polars==0.20.17
pre-commit==3.8.0
pyarrow==11.0.0
pyproject-api==1.7.1
pytest==8.3.2
pytest-cov==5.0.0
pytest-env==1.1.3
python-dateutil==2.9.0.post0
pytz==2024.1
pyyaml==6.0.2
setuptools==72.2.0
six==1.16.0
sortedcontainers==2.4.0
threadpoolctl==3.5.0
tomli==2.0.1
toolz==0.12.1
tox==4.18.0
tzdata==2024.1
virtualenv==20.26.3
zipp==3.20.0
```

noticed in https://github.com/narwhals-dev/narwhals/pull/811

Discussion:

Patch:
diff --git a/.github/workflows/extremes.yml b/.github/workflows/extremes.yml
index e9d0d673..ae9c7900 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -99,8 +99,8 @@ jobs:
         run: uv pip install --upgrade tox virtualenv setuptools pip -r requirements-dev.txt --system
       - name: uninstall pyarrow
         run: uv pip uninstall pyarrow --system
-      - name: install pyarrow nightly
-        run: uv pip install --pre --extra-index-url https://pypi.fury.io/arrow-nightlies/ pyarrow --system
+      # - name: install pyarrow nightly
+      #   run: uv pip install --extra-index-url https://pypi.fury.io/arrow-nightlies/ --pre pyarrow --system
       - name: uninstall pandas
         run: uv pip uninstall pandas --system
       - name: install-pandas-nightly
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 2b5d1689..faedb609 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -475,7 +475,9 @@ class DaskExpr:
         )
 
     def clip(
-        self: Self, lower_bound: Any | None = None, upper_bound: Any | None = None
+        self: Self,
+        lower_bound: Any | None = None,
+        upper_bound: Any | None = None,
     ) -> Self:
         return self._from_call(
             lambda _input, _lower, _upper: _input.clip(lower=_lower, upper=_upper),
@@ -798,6 +800,14 @@ class DaskExprDateTimeNamespace:
             returns_scalar=False,
         )
 
+    def to_string(self, format: str) -> DaskExpr:  # noqa: A002
+        return self._expr._from_call(
+            lambda _input, _format: _input.dt.strftime(_format),
+            "strftime",
+            format.replace("%.f", ".%f"),
+            returns_scalar=False,
+        )
+
     def total_minutes(self) -> DaskExpr:
         return self._expr._from_call(
             lambda _input: _input.dt.total_seconds() // 60,
diff --git a/pyproject.toml b/pyproject.toml
index d05e07c8..57ae93a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,8 @@ exclude = [
 ]
 
 [project.optional-dependencies]
+cudf = ["cudf>=23.08.00"]
+modin = ["modin"]
 pandas = ["pandas>=0.25.3"]
 polars = ["polars>=0.20.3"]
 pyarrow = ['pyarrow>=11.0.0']
@@ -94,6 +96,7 @@ filterwarnings = [
   'ignore:.*Do not use the `random` module inside strategies',
   'ignore:.*You are using pyarrow version',
   'ignore:.*but when imported by',
+  'ignore:Distributing .*This may take some time',
 ]
 xfail_strict = true
 markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]


Test patch:
diff --git a/tests/expr_and_series/dt/to_string_test.py b/tests/expr_and_series/dt/to_string_test.py
index b78f2d1f..8cd7ae4c 100644
--- a/tests/expr_and_series/dt/to_string_test.py
+++ b/tests/expr_and_series/dt/to_string_test.py
@@ -6,6 +6,7 @@ from typing import Any
 import pytest
 
 import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
 from tests.utils import is_windows
 
 data = {
@@ -17,31 +18,71 @@ data = {
 
 
 @pytest.mark.parametrize(
-    "fmt", ["%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%G-W%V-%u", "%G-W%V"]
+    "fmt",
+    [
+        "%Y-%m-%d",
+        "%Y-%m-%d %H:%M:%S",
+        "%Y/%m/%d %H:%M:%S",
+        "%G-W%V-%u",
+        "%G-W%V",
+    ],
 )
 @pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
-def test_dt_to_string(constructor_eager: Any, fmt: str) -> None:
+def test_dt_to_string_series(constructor_eager: Any, fmt: str) -> None:
     input_frame = nw.from_native(constructor_eager(data), eager_only=True)
     input_series = input_frame["a"]
 
     expected_col = [datetime.strftime(d, fmt) for d in data["a"]]
 
-    result = input_series.dt.to_string(fmt).to_list()
+    result = {"a": input_series.dt.to_string(fmt)}
+
     if any(
         x in str(constructor_eager) for x in ["pandas_pyarrow", "pyarrow_table", "modin"]
     ):
         # PyArrow differs from other libraries, in that %S also shows
         # the fraction of a second.
-        result = [x[: x.find(".")] if "." in x else x for x in result]
-    assert result == expected_col
-    result = input_frame.select(nw.col("a").dt.to_string(fmt))["a"].to_list()
-    if any(
-        x in str(constructor_eager) for x in ["pandas_pyarrow", "pyarrow_table", "modin"]
-    ):
+        result = {"a": input_series.dt.to_string(fmt).str.replace(r"\.\d+$", "")}
+
+    compare_dicts(result, {"a": expected_col})
+
+
+@pytest.mark.parametrize(
+    "fmt",
+    [
+        "%Y-%m-%d",
+        "%Y-%m-%d %H:%M:%S",
+        "%Y/%m/%d %H:%M:%S",
+        "%G-W%V-%u",
+        "%G-W%V",
+    ],
+)
+@pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
+def test_dt_to_string_expr(constructor: Any, fmt: str) -> None:
+    input_frame = nw.from_native(constructor(data))
+
+    expected_col = [datetime.strftime(d, fmt) for d in data["a"]]
+
+    result = input_frame.select(nw.col("a").dt.to_string(fmt).alias("b"))
+    if any(x in str(constructor) for x in ["pandas_pyarrow", "pyarrow_table", "modin"]):
         # PyArrow differs from other libraries, in that %S also shows
         # the fraction of a second.
-        result = [x[: x.find(".")] if "." in x else x for x in result]
-    assert result == expected_col
+        result = input_frame.select(
+            nw.col("a").dt.to_string(fmt).str.replace(r"\.\d+$", "").alias("b")
+        )
+    compare_dicts(result, {"b": expected_col})
+
+
+def _clean_string(result: str) -> str:
+    # rstrip '0' to remove trailing zeros, as different libraries handle this differently
+    # if there's then a trailing `.`, remove that too.
+    if "." in result:
+        result = result.rstrip("0").rstrip(".")
+    return result
+
+
+def _clean_string_expr(e: Any) -> Any:
+    # Same as `_clean_string` but for Expr
+    return e.str.replace_all(r"0+$", "").str.replace_all(r"\.$", "")
 
 
 @pytest.mark.parametrize(
@@ -50,20 +91,16 @@ def test_dt_to_string(constructor_eager: Any, fmt: str) -> None:
         (datetime(2020, 1, 9), "2020-01-09T00:00:00.000000"),
         (datetime(2020, 1, 9, 12, 34, 56), "2020-01-09T12:34:56.000000"),
         (datetime(2020, 1, 9, 12, 34, 56, 123), "2020-01-09T12:34:56.000123"),
-        (datetime(2020, 1, 9, 12, 34, 56, 123456), "2020-01-09T12:34:56.123456"),
+        (
+            datetime(2020, 1, 9, 12, 34, 56, 123456),
+            "2020-01-09T12:34:56.123456",
+        ),
     ],
 )
 @pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
-def test_dt_to_string_iso_local_datetime(
+def test_dt_to_string_iso_local_datetime_series(
     constructor_eager: Any, data: datetime, expected: str
 ) -> None:
-    def _clean_string(result: str) -> str:
-        # rstrip '0' to remove trailing zeros, as different libraries handle this differently
-        # if there's then a trailing `.`, remove that too.
-        if "." in result:
-            result = result.rstrip("0").rstrip(".")
-        return result
-
     df = constructor_eager({"a": [data]})
     result = (
         nw.from_native(df, eager_only=True)["a"]
@@ -72,13 +109,6 @@ def test_dt_to_string_iso_local_datetime(
     )
     assert _clean_string(result) == _clean_string(expected)
 
-    result = (
-        nw.from_native(df, eager_only=True)
-        .select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M:%S.%f"))["a"]
-        .to_list()[0]
-    )
-    assert _clean_string(result) == _clean_string(expected)
-
     result = (
         nw.from_native(df, eager_only=True)["a"]
         .dt.to_string("%Y-%m-%dT%H:%M:%S%.f")
@@ -86,12 +116,35 @@ def test_dt_to_string_iso_local_datetime(
     )
     assert _clean_string(result) == _clean_string(expected)
 
-    result = (
-        nw.from_native(df, eager_only=True)
-        .select(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M:%S%.f"))["a"]
-        .to_list()[0]
+
+@pytest.mark.parametrize(
+    ("data", "expected"),
+    [
+        (datetime(2020, 1, 9, 12, 34, 56), "2020-01-09T12:34:56.000000"),
+        (datetime(2020, 1, 9, 12, 34, 56, 123), "2020-01-09T12:34:56.000123"),
+        (
+            datetime(2020, 1, 9, 12, 34, 56, 123456),
+            "2020-01-09T12:34:56.123456",
+        ),
+    ],
+)
+@pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
+def test_dt_to_string_iso_local_datetime_expr(
+    request: Any, constructor: Any, data: datetime, expected: str
+) -> None:
+    if "modin" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    df = constructor({"a": [data]})
+
+    result = nw.from_native(df).with_columns(
+        _clean_string_expr(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M:%S.%f")).alias("b")
     )
-    assert _clean_string(result) == _clean_string(expected)
+    compare_dicts(result, {"a": [data], "b": [_clean_string(expected)]})
+
+    result = nw.from_native(df).with_columns(
+        _clean_string_expr(nw.col("a").dt.to_string("%Y-%m-%dT%H:%M:%S%.f")).alias("b")
+    )
+    compare_dicts(result, {"a": [data], "b": [_clean_string(expected)]})
 
 
 @pytest.mark.parametrize(
@@ -99,7 +152,7 @@ def test_dt_to_string_iso_local_datetime(
     [(datetime(2020, 1, 9), "2020-01-09")],
 )
 @pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
-def test_dt_to_string_iso_local_date(
+def test_dt_to_string_iso_local_date_series(
     constructor_eager: Any, data: datetime, expected: str
 ) -> None:
     df = constructor_eager({"a": [data]})
@@ -108,9 +161,20 @@ def test_dt_to_string_iso_local_date(
     )
     assert result == expected
 
-    result = (
-        nw.from_native(df, eager_only=True)
-        .select(b=nw.col("a").dt.to_string("%Y-%m-%d"))["b"]
-        .to_list()[0]
+
+@pytest.mark.parametrize(
+    ("data", "expected"),
+    [(datetime(2020, 1, 9), "2020-01-09")],
+)
+@pytest.mark.skipif(is_windows(), reason="pyarrow breaking on windows")
+def test_dt_to_string_iso_local_date_expr(
+    request: Any, constructor: Any, data: datetime, expected: str
+) -> None:
+    if "modin" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+
+    df = constructor({"a": [data]})
+    result = nw.from_native(df).with_columns(
+        nw.col("a").dt.to_string("%Y-%m-%d").alias("b")
     )
-    assert result == expected
+    compare_dicts(result, {"a": [data], "b": [expected]})
diff --git a/tests/test_selectors.py b/tests/selectors_test.py
similarity index 93%
rename from tests/test_selectors.py
rename to tests/selectors_test.py
index 6b00a4d8..128cb73c 100644
--- a/tests/test_selectors.py
+++ b/tests/selectors_test.py
@@ -46,7 +46,10 @@ def test_boolean(constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_string(constructor: Any) -> None:
+def test_string(constructor: Any, request: Any) -> None:
+    if "dask" in str(constructor) and parse_version(pa.__version__) < (12,):
+        # Dask doesn't infer `'b'` as String for old PyArrow versions
+        request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor(data))
     result = df.select(string())
     expected = {"b": ["a", "b", "c"]}