Repository: narwhals-dev/narwhals
Pull request #2856 (2025-07-19 20:51:41)

feat: Allow `all` and `any` in group by context
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #2385

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below

Opening as draft: I am expecting some old versions to break 


Patch:
diff --git a/README.md b/README.md
index f4d7247e..3f6a8c1c 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,7 @@ Join the party!
 - [bokeh](https://github.com/bokeh/bokeh)
 - [darts](https://github.com/unit8co/darts)
 - [formulaic](https://github.com/matthewwardrop/formulaic)
+- [gt-extras](https://github.com/posit-dev/gt-extras)
 - [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 - [marimo](https://github.com/marimo-team/marimo)
 - [metalearners](https://github.com/Quantco/metalearners)
diff --git a/docs/ecosystem.md b/docs/ecosystem.md
index 2f41c1a1..13d7909c 100644
--- a/docs/ecosystem.md
+++ b/docs/ecosystem.md
@@ -9,6 +9,7 @@ for their dataframe interoperability needs:
 * [bokeh](https://github.com/bokeh/bokeh)
 * [darts](https://github.com/unit8co/darts)
 * [formulaic](https://github.com/matthewwardrop/formulaic)
+* [gt-extras](https://github.com/posit-dev/gt-extras)
 * [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 * [marimo](https://github.com/marimo-team/marimo)
 * [metalearners](https://github.com/Quantco/metalearners)
diff --git a/narwhals/_arrow/group_by.py b/narwhals/_arrow/group_by.py
index 76512c9d..325acad6 100644
--- a/narwhals/_arrow/group_by.py
+++ b/narwhals/_arrow/group_by.py
@@ -37,6 +37,8 @@ class ArrowGroupBy(EagerGroupBy["ArrowDataFrame", "ArrowExpr", "Aggregation"]):
         "len": "count",
         "n_unique": "count_distinct",
         "count": "count",
+        "all": "all",
+        "any": "any",
     }
     _REMAP_UNIQUE: ClassVar[Mapping[UniqueKeepStrategy, Aggregation]] = {
         "any": "min",
@@ -89,6 +91,8 @@ class ArrowGroupBy(EagerGroupBy["ArrowDataFrame", "ArrowExpr", "Aggregation"]):
                 option = pc.CountOptions(mode="all")
             elif function_name == "count":
                 option = pc.CountOptions(mode="only_valid")
+            elif function_name in {"all", "any"}:
+                option = pc.ScalarAggregateOptions(min_count=0)
             else:
                 option = None
 
diff --git a/narwhals/_compliant/typing.py b/narwhals/_compliant/typing.py
index f1a5375b..0fb3c301 100644
--- a/narwhals/_compliant/typing.py
+++ b/narwhals/_compliant/typing.py
@@ -182,6 +182,8 @@ NarwhalsAggregation: TypeAlias = Literal[
     "n_unique",
     "count",
     "quantile",
+    "all",
+    "any",
 ]
 """`Expr` methods we aim to support in `DepthTrackingGroupBy`.
 
diff --git a/narwhals/_dask/group_by.py b/narwhals/_dask/group_by.py
index 1a227ed0..cb2df388 100644
--- a/narwhals/_dask/group_by.py
+++ b/narwhals/_dask/group_by.py
@@ -44,6 +44,26 @@ def n_unique() -> dd.Aggregation:
     return dd.Aggregation(name="nunique", chunk=chunk, agg=agg)
 
 
+def _all() -> dd.Aggregation:
+    def chunk(s: PandasSeriesGroupBy) -> pd.Series[Any]:
+        return s.all(skipna=True)
+
+    def agg(s0: PandasSeriesGroupBy) -> pd.Series[Any]:
+        return s0.all(skipna=True)
+
+    return dd.Aggregation(name="all", chunk=chunk, agg=agg)
+
+
+def _any() -> dd.Aggregation:
+    def chunk(s: PandasSeriesGroupBy) -> pd.Series[Any]:
+        return s.any(skipna=True)
+
+    def agg(s0: PandasSeriesGroupBy) -> pd.Series[Any]:
+        return s0.any(skipna=True)
+
+    return dd.Aggregation(name="any", chunk=chunk, agg=agg)
+
+
 def var(ddof: int) -> _AggFn:
     return partial(_DaskGroupBy.var, ddof=ddof)
 
@@ -65,6 +85,8 @@ class DaskLazyGroupBy(DepthTrackingGroupBy["DaskLazyFrame", "DaskExpr", Aggregat
         "n_unique": n_unique,
         "count": "count",
         "quantile": "quantile",
+        "all": _all,
+        "any": _any,
     }
 
     def __init__(
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 8e53af36..bcb64427 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -156,6 +156,8 @@ class PandasLikeGroupBy(
         "n_unique": "nunique",
         "count": "count",
         "quantile": "quantile",
+        "all": "all",
+        "any": "any",
     }
     _original_columns: tuple[str, ...]
     """Column names *prior* to any aliasing in `ParseKeysGroupBy`."""


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 44a4f9e5..976f9f73 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -558,3 +558,44 @@ jobs:
           pytest darts/tests/test_timeseries.py
           pytest darts/tests/test_timeseries_multivariate.py
           pytest darts/tests/test_timeseries_static_covariates.py
+
+  gt-extras:
+    strategy:
+      matrix:
+        python-version: ["3.12"]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          enable-cache: "true"
+          cache-suffix: gt-extras-${{ matrix.python-version }}
+          cache-dependency-glob: "pyproject.toml"
+      - name: clone-gt-extras
+        run: |
+          git clone https://github.com/posit-dev/gt-extras.git --depth 1
+          cd gt-extras
+          git log
+      - name: install-gt-extras-dev
+        run: |
+          cd gt-extras
+          uv sync --all-extras --dev
+      - name: install-narwhals-dev
+        run: |
+          cd gt-extras
+          uv pip uninstall narwhals
+          uv pip install -e ./..
+      - name: show-deps
+        run: |
+          cd gt-extras
+          uv pip freeze
+      - name: run-pytest
+        run: |
+          cd gt-extras
+          uv run --no-sync pytest
diff --git a/tests/frame/group_by_test.py b/tests/frame/group_by_test.py
index 770f2595..b73a0a91 100644
--- a/tests/frame/group_by_test.py
+++ b/tests/frame/group_by_test.py
@@ -140,6 +140,39 @@ def test_group_by_depth_1_agg(
     assert_equal_data(result, expected)
 
 
+@pytest.mark.parametrize(
+    ("values", "expected"),
+    [
+        (
+            {"x": [True, True, True, False, False, False]},
+            {"all": [True, False, False], "any": [True, True, False]},
+        ),
+        (
+            {"x": [True, None, False, None, None, None]},
+            {"all": [True, False, True], "any": [True, False, False]},
+        ),
+    ],
+    ids=["not-nullable", "nullable"],
+)
+def test_group_by_depth_1_agg_bool_ops(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    values: dict[str, list[bool]],
+    expected: dict[str, list[bool]],
+) -> None:
+    if "dask-nullable" in request.node.callspec.id:
+        request.applymarker(pytest.mark.xfail(strict=True))
+
+    data = {"a": [1, 1, 2, 2, 3, 3], **values}
+    result = (
+        nw.from_native(constructor(data))
+        .group_by("a")
+        .agg(nw.col("x").all().alias("all"), nw.col("x").any().alias("any"))
+        .sort("a")
+    )
+    assert_equal_data(result, {"a": [1, 2, 3], **expected})
+
+
 @pytest.mark.parametrize(
     ("attr", "ddof"), [("std", 0), ("var", 0), ("std", 2), ("var", 2)]
 )