Repository: narwhals-dev/narwhals
Pull request #762 (2024-08-09 16:58:55)

feat: dask expr `len` method
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #637 

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes



Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index d37a1266..cc1290a8 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -33,6 +33,7 @@
         - null_count
         - n_unique
         - over
+        - pipe
         - quantile
         - round
         - sample
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 011e4357..2479196a 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -37,6 +37,7 @@
         - name
         - null_count
         - n_unique
+        - pipe
         - quantile
         - round
         - sample
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index cb619b2f..3cbfab8e 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -473,6 +473,13 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def len(self: Self) -> Self:
+        return self._from_call(
+            lambda _input: _input.size,
+            "len",
+            returns_scalar=True,
+        )
+
     @property
     def str(self: Self) -> DaskExprStringNamespace:
         return DaskExprStringNamespace(self)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 324eb636..f8a12a39 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -1999,8 +1999,8 @@ class DataFrame(BaseFrame[FrameT]):
             starting from a offset of 1:
 
             >>> @nw.narwhalify
-            ... def func(df_any):
-            ...     return df_any.gather_every(n=2, offset=1)
+            ... def func(df):
+            ...     return df.gather_every(n=2, offset=1)
 
             >>> func(df_pd)
                a  b
@@ -3299,8 +3299,8 @@ class LazyFrame(BaseFrame[FrameT]):
             starting from a offset of 1:
 
             >>> @nw.narwhalify
-            ... def func(df_any):
-            ...     return df_any.gather_every(n=2, offset=1)
+            ... def func(df):
+            ...     return df.gather_every(n=2, offset=1)
 
             >>> func(df_pd)
                a  b
diff --git a/narwhals/expr.py b/narwhals/expr.py
index e51bd267..2a00b6da 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -77,6 +77,47 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).alias(name))
 
+    def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
+        """
+        Pipe function call.
+
+        Examples:
+            >>> import polars as pl
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> data = {"a": [1, 2, 3, 4]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Lets define a library-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col("a").pipe(lambda x: x + 1))
+
+            We can then pass any supported library:
+
+            >>> func(df_pd)
+               a
+            0  2
+            1  3
+            2  4
+            3  5
+            >>> func(df_pl)
+            shape: (4, 1)
+            ┌─────┐
+            │ a   │
+            │ --- │
+            │ i64 │
+            ╞═════╡
+            │ 2   │
+            │ 3   │
+            │ 4   │
+            │ 5   │
+            └─────┘
+        """
+        return function(self, *args, **kwargs)
+
     def cast(
         self,
         dtype: Any,
@@ -1309,13 +1350,13 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                    a      b
             0   True   True
             1  False   True
             2  False  False
             3   True  False
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌───────┬───────┐
             │ a     ┆ b     │
@@ -1350,13 +1391,13 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                    a      b
             0  False  False
             1   True  False
             2   True   True
             3  False   True
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌───────┬───────┐
             │ a     ┆ b     │
@@ -1431,13 +1472,13 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                    a      b
             0   True   True
             1   True  False
             2   True   True
             3  False   True
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌───────┬───────┐
             │ a     ┆ b     │
@@ -1471,13 +1512,13 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                    a      b
             0  False  False
             1   True   True
             2   True   True
             3   True   True
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌───────┬───────┐
             │ a     ┆ b     │
@@ -1524,11 +1565,11 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-                a   b
+            >>> func(df_pd)
+                  a     b
             0  24.5  74.5
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (1, 2)
             ┌──────┬──────┐
             │ a    ┆ b    │
@@ -1566,12 +1607,12 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                a
             0  0
             1  1
             2  2
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ a   │
@@ -1610,12 +1651,12 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-                 a
+            >>> func(df_pd)
+               a
             7  7
             8  8
             9  9
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ a   │
@@ -1662,12 +1703,12 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                  a
             0  1.1
             1  2.6
             2  3.9
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ a   │
@@ -1707,10 +1748,10 @@ class Expr:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-                a1  a2
-            0    2   1
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
+               a1  a2
+            0   2   1
+            >>> func(df_pl)
             shape: (1, 2)
             ┌─────┬─────┐
             │ a1  ┆ a2  │
@@ -1801,7 +1842,7 @@ class Expr:
             0  2
             1  2
             2  3
-            >>> func_lower(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func_lower(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ s   │
@@ -1826,7 +1867,7 @@ class Expr:
             0  1
             1  2
             2  2
-            >>> func_upper(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func_upper(df_pl)
             shape: (3, 1)
             ┌─────┐
             │ s   │
@@ -1860,7 +1901,7 @@ class Expr:
             3  3
             4 -1
             5  3
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (6, 1)
             ┌─────┐
             │ s   │
@@ -1868,11 +1909,11 @@ class Expr:
             │ i64 │
             ╞═════╡
             │ -1  │
-            │  1  │
+            │ 1   │
             │ -1  │
-            │  3  │
+            │ 3   │
             │ -1  │
-            │  3  │
+            │ 3   │
             └─────┘
         """
         return self.__class__(
@@ -2162,7 +2203,7 @@ class ExprStringNamespace:
             2       papaya       ya
             3  dragonfruit      onf
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌─────────────┬──────────┐
             │ s           ┆ s_sliced │
@@ -2181,14 +2222,14 @@ class ExprStringNamespace:
             ... def func(df):
             ...     return df.with_columns(s_sliced=nw.col("s").str.slice(-3))
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pd)
                          s s_sliced
             0         pear      ear
             1         None     None
             2       papaya      aya
             3  dragonfruit      uit
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (4, 2)
             ┌─────────────┬──────────┐
             │ s           ┆ s_sliced │
@@ -2375,8 +2416,8 @@ class ExprStringNamespace:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-               fruits upper_col
+            >>> func(df_pd)
+              fruits upper_col
             0  apple     APPLE
             1  mango     MANGO
             2   None      None
@@ -2416,13 +2457,13 @@ class ExprStringNamespace:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
-              fruits  lower_col
-            0  APPLE      apple
-            1  MANGO      mango
-            2   None       None
+            >>> func(df_pd)
+              fruits lower_col
+            0  APPLE     apple
+            1  MANGO     mango
+            2   None      None
 
-            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(df_pl)
             shape: (3, 2)
             ┌────────┬───────────┐
             │ fruits ┆ lower_col │
@@ -2453,32 +2494,35 @@ class ExprDateTimeNamespace:
             >>> import polars as pl
             >>> from datetime import datetime
             >>> import narwhals as nw
-            >>> dates = [datetime(2012, 1, 7, 10, 20), datetime(2023, 3, 10, 11, 32)]
-            >>> s_pd = pd.Series(dates).convert_dtypes(
+            >>> data = {"a": [datetime(2012, 1, 7, 10, 20), datetime(2023, 3, 10, 11, 32)]}
+            >>> df_pd = pd.DataFrame(data).convert_dtypes(
             ...     dtype_backend="pyarrow"
             ... )  # doctest:+SKIP
-            >>> s_pl = pl.Series(dates)
+            >>> df_pl = pl.DataFrame(data)
 
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(s):
-            ...     return s.dt.date()
+            ... def func(df):
+            ...     return df.select(nw.col("a").dt.date())
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(s_pd)  # doctest:+SKIP
-            0    2012-01-07
-            1    2023-03-10
-            dtype: date32[day][pyarrow]
+            >>> func(df_pd)  # doctest:+SKIP
+                        a
+            0  2012-01-07
+            1  2023-03-10
 
-            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
-            shape: (2,)
-            Series: '' [date]
-            [
-               2012-01-07
-               2023-03-10
-            ]
+            >>> func(df_pl)  # docetst
+            shape: (2, 1)
+            ┌────────────┐
+            │ a          │
+            │ ---        │
+            │ date       │
+            ╞════════════╡
+            │ 2012-01-07 │
+            │ 2023-03-10 │
+            └────────────┘
         """
         return self._expr.__class__(lambda plx: self._expr._call(plx).dt.date())
 
diff --git a/narwhals/series.py b/narwhals/series.py
index 8027cacf..76dd71e6 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Callable
 from typing import Literal
 from typing import Sequence
 from typing import overload
@@ -100,6 +101,44 @@ class Series:
             level=self._level,
         )
 
+    def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
+        """
+        Pipe function call.
+
+        Examples:
+            >>> import polars as pl
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> s_pd = pd.Series([1, 2, 3, 4])
+            >>> s_pl = pl.Series([1, 2, 3, 4])
+
+            Lets define a function to pipe into
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.pipe(lambda x: x + 2)
+
+            Now apply it to the series
+
+            >>> func(s_pd)
+            0    3
+            1    4
+            2    5
+            3    6
+            dtype: int64
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [i64]
+            [
+               3
+               4
+               5
+               6
+            ]
+
+
+        """
+        return function(self, *args, **kwargs)
+
     def __repr__(self) -> str:  # pragma: no cover
         header = " Narwhals Series                                 "
         length = len(header)
@@ -139,9 +178,9 @@ class Series:
 
             We can then pass either pandas or Polars to `func`:
 
-            >>> func(s_pd)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(s_pd)
             3
-            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            >>> func(s_pl)
             3
         """
         return len(self._compliant_series)
diff --git a/tpch/notebooks/q1/execute.ipynb b/tpch/notebooks/q1/execute.ipynb
index 4760d2df..cc6dd455 100755
--- a/tpch/notebooks/q1/execute.ipynb
+++ b/tpch/notebooks/q1/execute.ipynb
@@ -16,7 +16,7 @@
    },
    "outputs": [],
    "source": [
-    "!pip uninstall apache-beam -y && pip install -U pandas polars pyarrow ibis-framework narwhals"
+    "!pip uninstall apache-beam -y && pip install -U pandas polars pyarrow narwhals dask[dataframe]"
    ]
   },
   {
@@ -46,50 +46,6 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "2",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from datetime import date\n",
-    "\n",
-    "def q1_pandas_native(lineitem):\n",
-    "    VAR1 = date(1998, 9, 2)\n",
-    "\n",
-    "    sel = lineitem.l_shipdate <= VAR1\n",
-    "    lineitem_filtered = lineitem[sel]\n",
-    "\n",
-    "    # This is lenient towards pandas as normally an optimizer should decide\n",
-    "    # that this could be computed before the groupby aggregation.\n",
-    "    # Other implementations don't enjoy this benefit.\n",
-    "    lineitem_filtered[\"disc_price\"] = lineitem_filtered.l_extendedprice * (\n",
-    "        1 - lineitem_filtered.l_discount\n",
-    "    )\n",
-    "    lineitem_filtered[\"charge\"] = (\n",
-    "        lineitem_filtered.l_extendedprice\n",
-    "        * (1 - lineitem_filtered.l_discount)\n",
-    "        * (1 + lineitem_filtered.l_tax)\n",
-    "    )\n",
-    "    gb = lineitem_filtered.groupby([\"l_returnflag\", \"l_linestatus\"], as_index=False)\n",
-    "\n",
-    "    total = gb.agg(\n",
-    "        sum_qty=pd.NamedAgg(column=\"l_quantity\", aggfunc=\"sum\"),\n",
-    "        sum_base_price=pd.NamedAgg(column=\"l_extendedprice\", aggfunc=\"sum\"),\n",
-    "        sum_disc_price=pd.NamedAgg(column=\"disc_price\", aggfunc=\"sum\"),\n",
-    "        sum_charge=pd.NamedAgg(column=\"charge\", aggfunc=\"sum\"),\n",
-    "        avg_qty=pd.NamedAgg(column=\"l_quantity\", aggfunc=\"mean\"),\n",
-    "        avg_price=pd.NamedAgg(column=\"l_extendedprice\", aggfunc=\"mean\"),\n",
-    "        avg_disc=pd.NamedAgg(column=\"l_discount\", aggfunc=\"mean\"),\n",
-    "        count_order=pd.NamedAgg(column=\"l_orderkey\", aggfunc=\"size\"),\n",
-    "    )\n",
-    "\n",
-    "    result_df = total.sort_values([\"l_returnflag\", \"l_linestatus\"])\n",
-    "\n",
-    "    return result_df  # type: ignore[no-any-return]"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "3",
    "metadata": {
     "papermill": {
      "duration": 0.021725,
@@ -119,67 +75,25 @@
     "                * (1.0 + nw.col(\"l_tax\"))\n",
     "            ),\n",
     "        )\n",
-    "        .group_by([\"l_returnflag\", \"l_linestatus\"])\n",
+    "        .group_by(\"l_returnflag\", \"l_linestatus\")\n",
     "        .agg(\n",
-    "            [\n",
-    "                nw.col(\"l_quantity\").sum().alias(\"sum_qty\"),\n",
-    "                nw.col(\"l_extendedprice\").sum().alias(\"sum_base_price\"),\n",
-    "                nw.col(\"disc_price\").sum().alias(\"sum_disc_price\"),\n",
-    "                nw.col(\"charge\").sum().alias(\"sum_charge\"),\n",
-    "                nw.col(\"l_quantity\").mean().alias(\"avg_qty\"),\n",
-    "                nw.col(\"l_extendedprice\").mean().alias(\"avg_price\"),\n",
-    "                nw.col(\"l_discount\").mean().alias(\"avg_disc\"),\n",
-    "                nw.len().alias(\"count_order\"),\n",
-    "            ],\n",
+    "            nw.col(\"l_quantity\").sum().alias(\"sum_qty\"),\n",
+    "            nw.col(\"l_extendedprice\").sum().alias(\"sum_base_price\"),\n",
+    "            nw.col(\"disc_price\").sum().alias(\"sum_disc_price\"),\n",
+    "            nw.col(\"charge\").sum().alias(\"sum_charge\"),\n",
+    "            nw.col(\"l_quantity\").mean().alias(\"avg_qty\"),\n",
+    "            nw.col(\"l_extendedprice\").mean().alias(\"avg_price\"),\n",
+    "            nw.col(\"l_discount\").mean().alias(\"avg_disc\"),\n",
+    "            nw.len().alias(\"count_order\"),\n",
     "        )\n",
-    "        .sort([\"l_returnflag\", \"l_linestatus\"])\n",
+    "        .sort(\"l_returnflag\", \"l_linestatus\")\n",
     "    )"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "4",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def q1_ibis(lineitem: Any, *, tool):\n",
-    "    var1 = datetime(1998, 9, 2)\n",
-    "    lineitem = lineitem.filter(lineitem[\"l_shipdate\"] <= var1)\n",
-    "    lineitem = lineitem.mutate(\n",
-    "        disc_price=lineitem[\"l_extendedprice\"] * (1 - lineitem[\"l_discount\"]),\n",
-    "        charge=(\n",
-    "            lineitem[\"l_extendedprice\"]\n",
-    "            * (1.0 - lineitem[\"l_discount\"])\n",
-    "            * (1.0 + lineitem[\"l_tax\"])\n",
-    "        ),\n",
-    "    )\n",
-    "    q_final = (\n",
-    "        lineitem\n",
-    "        .group_by([\"l_returnflag\", \"l_linestatus\"])\n",
-    "        .aggregate(\n",
-    "            sum_qty=lineitem[\"l_quantity\"].sum(),\n",
-    "            sum_base_price=lineitem[\"l_extendedprice\"].sum(),\n",
-    "            sum_disc_price=(lineitem['disc_price'].sum()),\n",
-    "            sum_charge=(lineitem['charge'].sum()),\n",
-    "            avg_qty=lineitem[\"l_quantity\"].mean(),\n",
-    "            avg_price=lineitem[\"l_extendedprice\"].mean(),\n",
-    "            avg_disc=lineitem[\"l_discount\"].mean(),\n",
-    "            count_order=lambda lineitem: lineitem.count(),\n",
-    "        )\n",
-    "        .order_by([\"l_returnflag\", \"l_linestatus\"])\n",
-    "    )\n",
-    "    if tool == 'pandas':\n",
-    "        return q_final.to_pandas()\n",
-    "    if tool == 'polars':\n",
-    "        return q_final.to_polars()\n",
-    "    raise ValueError(\"expected pandas or polars\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "5",
+   "id": "3",
    "metadata": {
     "papermill": {
      "duration": 0.013325,
@@ -206,7 +120,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "6",
+   "id": "4",
    "metadata": {
     "papermill": {
      "duration": 0.014284,
@@ -219,27 +133,23 @@
    },
    "outputs": [],
    "source": [
-    "import ibis\n",
     "import pyarrow.parquet as pq\n",
-    "\n",
-    "con_pd = ibis.pandas.connect()\n",
-    "con_pl = ibis.polars.connect()\n",
+    "import dask.dataframe as dd\n",
     "\n",
     "IO_FUNCS = {\n",
     "    'pandas': lambda x: pd.read_parquet(x, engine='pyarrow'),\n",
     "    'pandas[pyarrow]': lambda x: pd.read_parquet(x, engine='pyarrow', dtype_backend='pyarrow'),\n",
-    "    'pandas[pyarrow][ibis]': lambda x: con_pd.read_parquet(x, engine='pyarrow', dtype_backend='pyarrow'),\n",
     "    'polars[eager]': lambda x: pl.read_parquet(x),\n",
     "    'polars[lazy]': lambda x: pl.scan_parquet(x),\n",
-    "    'polars[lazy][ibis]': lambda x: con_pl.read_parquet(x),\n",
     "    'pyarrow': lambda x: pq.read_table(x),\n",
+    "    'dask': lambda x: dd.read_parquet(x, engine='pyarrow', dtype_backend='pyarrow'),\n",
     "}"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "7",
+   "id": "5",
    "metadata": {},
    "outputs": [],
    "source": [
@@ -248,7 +158,7 @@
   },
   {
    "cell_type": "markdown",
-   "id": "8",
+   "id": "6",
    "metadata": {},
    "source": [
     "## PyArrow.table"
@@ -257,63 +167,19 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "9",
+   "id": "7",
    "metadata": {},
    "outputs": [],
    "source": [
     "tool = 'pyarrow'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(fn(lineitem))\n",
+    "timings = %timeit -o -q q1(fn(lineitem))\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "10",
-   "metadata": {},
-   "source": [
-    "## pandas, pyarrow dtypes, ibis"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "11",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# very slow, comment-out at your own risk\n",
-    "\n",
-    "# tool = 'pandas[pyarrow][ibis]'\n",
-    "# fn = IO_FUNCS[tool]\n",
-    "# timings = %timeit -o q1_ibis(fn(lineitem), tool='pandas')\n",
-    "# results[tool] = timings.all_runs"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "12",
-   "metadata": {},
-   "source": [
-    "## pandas, pyarrow dtypes, native"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "13",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "tool = 'pandas[pyarrow]'\n",
-    "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1_pandas_native(lineitem=fn(lineitem))\n",
-    "results[tool+'[native]'] = timings.all_runs"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "id": "14",
+   "id": "8",
    "metadata": {
     "papermill": {
      "duration": 0.005113,
@@ -325,13 +191,13 @@
     "tags": []
    },
    "source": [
-    "## pandas via Narwhals"
+    "## pandas"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "15",
+   "id": "9",
    "metadata": {
     "papermill": {
      "duration": 196.786925,
@@ -346,13 +212,13 @@
    "source": [
     "tool = 'pandas'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(lineitem_ds=fn(lineitem))\n",
+    "timings = %timeit -o -q q1(lineitem_ds=fn(lineitem))\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "16",
+   "id": "10",
    "metadata": {
     "papermill": {
      "duration": 0.005184,
@@ -364,13 +230,13 @@
     "tags": []
    },
    "source": [
-    "## pandas, pyarrow dtypes, via Narwhals"
+    "## pandas, pyarrow dtypes"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "17",
+   "id": "11",
    "metadata": {
     "papermill": {
      "duration": 158.748353,
@@ -385,13 +251,13 @@
    "source": [
     "tool = 'pandas[pyarrow]'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(fn(lineitem))\n",
+    "timings = %timeit -o -q q1(fn(lineitem))\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "18",
+   "id": "12",
    "metadata": {
     "papermill": {
      "duration": 0.005773,
@@ -409,7 +275,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "19",
+   "id": "13",
    "metadata": {
     "papermill": {
      "duration": 37.821116,
@@ -424,13 +290,13 @@
    "source": [
     "tool = 'polars[eager]'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(fn(lineitem))\n",
+    "timings = %timeit -o -q q1(fn(lineitem))\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "20",
+   "id": "14",
    "metadata": {
     "papermill": {
      "duration": 0.005515,
@@ -448,7 +314,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "21",
+   "id": "15",
    "metadata": {
     "papermill": {
      "duration": 4.800698,
@@ -463,34 +329,34 @@
    "source": [
     "tool = 'polars[lazy]'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1(fn(lineitem)).collect()\n",
+    "timings = %timeit -o -q q1(fn(lineitem)).collect()\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "22",
+   "id": "16",
    "metadata": {},
    "source": [
-    "## Polars scan_parquet ibis"
+    "## Dask Dataframe"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "23",
+   "id": "17",
    "metadata": {},
    "outputs": [],
    "source": [
-    "tool = 'polars[lazy][ibis]'\n",
+    "tool = 'dask'\n",
     "fn = IO_FUNCS[tool]\n",
-    "timings = %timeit -o q1_ibis(fn(lineitem), tool='polars')\n",
+    "timings = %timeit -o -q q1(fn(lineitem)).collect()\n",
     "results[tool] = timings.all_runs"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "24",
+   "id": "18",
    "metadata": {},
    "source": [
     "## Save"
@@ -499,7 +365,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "25",
+   "id": "19",
    "metadata": {},
    "outputs": [],
    "source": [


Test patch:
diff --git a/tests/expr_and_series/len_test.py b/tests/expr_and_series/len_test.py
index 83abbbe5..93ffb739 100644
--- a/tests/expr_and_series/len_test.py
+++ b/tests/expr_and_series/len_test.py
@@ -6,7 +6,18 @@ import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_len(constructor: Any, request: Any) -> None:
+def test_len_no_filter(constructor: Any) -> None:
+    data = {"a": list("xyz"), "b": [1, 2, 1]}
+    expected = {"l": [3], "l2": [6]}
+    df = nw.from_native(constructor(data)).select(
+        nw.col("a").len().alias("l"),
+        (nw.col("a").len() * 2).alias("l2"),
+    )
+
+    compare_dicts(df, expected)
+
+
+def test_len_len_chaining(constructor: Any, request: Any) -> None:
     data = {"a": list("xyz"), "b": [1, 2, 1]}
     expected = {"a1": [2], "a2": [1]}
     if "dask" in str(constructor):
diff --git a/tests/expr_and_series/pipe_test.py b/tests/expr_and_series/pipe_test.py
new file mode 100644
index 00000000..eb243004
--- /dev/null
+++ b/tests/expr_and_series/pipe_test.py
@@ -0,0 +1,20 @@
+from typing import Any
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+input_list = {"a": [2, 4, 6, 8]}
+expected = [4, 16, 36, 64]
+
+
+def test_pipe_expr(constructor: Any) -> None:
+    df = nw.from_native(constructor(input_list))
+    e = df.select(nw.col("a").pipe(lambda x: x**2))
+    compare_dicts(e, {"a": expected})
+
+
+def test_pipe_series(
+    constructor_eager: Any,
+) -> None:
+    s = nw.from_native(constructor_eager(input_list), eager_only=True)["a"]
+    assert s.pipe(lambda x: x**2).to_list() == expected
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index b356cf1f..40b598de 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -94,9 +94,7 @@ def test_group_by_iter(constructor_eager: Any) -> None:
     assert sorted(keys) == sorted(expected_keys)
 
 
-def test_group_by_len(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_group_by_len(constructor: Any) -> None:
     result = (
         nw.from_native(constructor(data)).group_by("a").agg(nw.col("b").len()).sort("a")
     )