Repository: narwhals-dev/narwhals

Issue:
[Bug]: pandas-like `drop` fails with positional arguments
### Describe the bug

pandas like dataframes raise error when dropping multiple columns as positional arguments

### Steps or code to reproduce the bug

```py
import narwhals as nw
import pandas as pd

df_raw = pd.DataFrame({
    "a": [1, 3, 2],
    "b": [4, 4, 6],
    "z": [7.0, 8, 9]
})

df = nw.from_native(df_raw)

df.drop(["a", "b"]) # -> all good

df.drop("a", "b")
```

> TypeError: PandasDataFrame.drop() takes 2 positional arguments but 3 were given

### Expected results

Same as `.drop(["a", "b"])`

### Actual results

Error is raised

### Please run narwhals.show_version() and enter the output below.

```shell
Python dependencies:
     narwhals: 0.9.26
       pandas: 2.2.2
       polars: 0.20.31
         cudf: 
        modin: 0.31.0
      pyarrow: 16.1.0
        numpy: 2.0.0
```


### Relevant log output

_No response_

Discussion:

Patch:
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 2b68e846..443e2add 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -249,8 +249,8 @@ class PandasDataFrame:
     def rename(self, mapping: dict[str, str]) -> Self:
         return self._from_dataframe(self._dataframe.rename(columns=mapping))
 
-    def drop(self, columns: str | Iterable[str]) -> Self:
-        return self._from_dataframe(self._dataframe.drop(columns=flatten(columns)))
+    def drop(self, *columns: str | Iterable[str]) -> Self:
+        return self._from_dataframe(self._dataframe.drop(columns=list(flatten(columns))))
 
     # --- transform ---
     def sort(
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 2422df8a..e2ac36c6 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -65,6 +65,7 @@ class PandasGroupBy:
             output_names,
             self._from_dataframe,
             implementation,
+            dataframe_is_empty=self._df._dataframe.empty,
         )
 
     def _from_dataframe(self, df: PandasDataFrame) -> PandasDataFrame:
@@ -86,6 +87,8 @@ def agg_pandas(  # noqa: PLR0913
     output_names: list[str],
     from_dataframe: Callable[[Any], PandasDataFrame],
     implementation: Any,
+    *,
+    dataframe_is_empty: bool,
 ) -> PandasDataFrame:
     """
     This should be the fastpath, but cuDF is too far behind to use it.
@@ -140,6 +143,20 @@ def agg_pandas(  # noqa: PLR0913
         result_simple = result_simple.rename(columns=name_mapping).reset_index()
         return from_dataframe(result_simple.loc[:, output_names])
 
+    if dataframe_is_empty:
+        # Don't even attempt this, it's way too inconsistent across pandas versions.
+        msg = (
+            "No results for group-by aggregation.\n\n"
+            "Hint: you were probably trying to apply a non-elementary aggregation with a "
+            "pandas-like API.\n"
+            "Please rewrite your query such that group-by aggregations "
+            "are elementary. For example, instead of:\n\n"
+            "    df.group_by('a').agg(nw.col('b').round(2).mean())\n\n"
+            "use:\n\n"
+            "    df.with_columns(nw.col('b').round(2)).group_by('a').agg(nw.col('b').mean())\n\n"
+        )
+        raise ValueError(msg)
+
     warnings.warn(
         "Found complex group-by expression, which can't be expressed efficiently with the "
         "pandas API. If you can, please rewrite your query such that group-by aggregations "
@@ -171,4 +188,5 @@ def agg_pandas(  # noqa: PLR0913
         result_complex = grouped.apply(func)
 
     result = result_complex.reset_index()
+
     return from_dataframe(result.loc[:, output_names])
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b69688d8..2882d874 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -1103,16 +1103,15 @@ class DataFrame(BaseFrame):
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.drop("ham")
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.drop("ham")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1132,6 +1131,29 @@ class DataFrame(BaseFrame):
             │ 2   ┆ 7.0 │
             │ 3   ┆ 8.0 │
             └─────┴─────┘
+
+            Use positional arguments to drop multiple columns.
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.drop("foo", "ham")
+
+            >>> func(df_pd)
+               bar
+            0  6.0
+            1  7.0
+            2  8.0
+            >>> func(df_pl)
+            shape: (3, 1)
+            ┌─────┐
+            │ bar │
+            │ --- │
+            │ f64 │
+            ╞═════╡
+            │ 6.0 │
+            │ 7.0 │
+            │ 8.0 │
+            └─────┘
         """
         return super().drop(*columns)
 
@@ -2413,25 +2435,27 @@ class LazyFrame(BaseFrame):
                       dataframe. Accepts column selector input.
 
         Examples:
-            Drop a single column by passing the name of that column.
-
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "foo": [1, 2, 3],
-            ...         "bar": [6.0, 7.0, 8.0],
-            ...         "ham": ["a", "b", "c"],
-            ...     }
-            ... )
-            >>> lf = nw.from_native(lf_pl)
-            >>> lframe = lf.drop("ham").collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> data = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> lf_pl = pl.LazyFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.drop("ham")
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               foo  bar
+            0    1  6.0
+            1    2  7.0
+            2    3  8.0
+            >>> func(lf_pl).collect()
             shape: (3, 2)
             ┌─────┬─────┐
             │ foo ┆ bar │
@@ -2445,13 +2469,16 @@ class LazyFrame(BaseFrame):
 
             Use positional arguments to drop multiple columns.
 
-            >>> lframe = lf.drop("foo", "ham").collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.drop("foo", "ham")
+
+            >>> func(df_pd)
+               bar
+            0  6.0
+            1  7.0
+            2  8.0
+            >>> func(lf_pl).collect()
             shape: (3, 1)
             ┌─────┐
             │ bar │


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9a2fa2f3..00b9274c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -52,7 +52,7 @@ def polars_constructor(obj: Any) -> IntoDataFrame:
     return pl.DataFrame(obj)
 
 
-if parse_version(pd.__version__) >= parse_version("1.5.0"):
+if parse_version(pd.__version__) >= parse_version("2.0.0"):
     params = [pandas_constructor, pandas_nullable_constructor, pandas_pyarrow_constructor]
 else:  # pragma: no cover
     params = [pandas_constructor]
@@ -94,7 +94,7 @@ def polars_series_constructor(obj: Any) -> Any:
     return pl.Series(obj)
 
 
-if parse_version(pd.__version__) >= parse_version("1.5.0"):
+if parse_version(pd.__version__) >= parse_version("2.0.0"):
     params_series = [
         pandas_series_constructor,
         pandas_series_nullable_constructor,
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 7b1628ce..ce20e4f7 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -620,6 +620,27 @@ def test_drop_nulls(df_raw: Any) -> None:
     compare_dicts(result, expected)
 
 
+@pytest.mark.parametrize(
+    "df_raw",
+    [
+        df_pandas,
+        df_polars,
+        df_mpd,
+    ],
+)
+@pytest.mark.parametrize(
+    ("drop", "left"),
+    [
+        (["a"], ["b", "z"]),
+        (["a", "b"], ["z"]),
+    ],
+)
+def test_drop(df_raw: Any, drop: list[str], left: list[str]) -> None:
+    df = nw.from_native(df_raw)
+    assert df.drop(drop).columns == left
+    assert df.drop(*drop).columns == left
+
+
 @pytest.mark.parametrize(
     ("df_raw", "df_raw_right"), [(df_pandas, df_right_pandas), (df_lazy, df_right_lazy)]
 )
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index 12fbaa42..7c3f4410 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -70,3 +70,12 @@ def test_group_by_len(constructor: Any) -> None:
     )
     expected = {"a": [1, 3], "b": [2, 1]}
     compare_dicts(result, expected)
+
+
+def test_group_by_empty_result_pandas() -> None:
+    df_any = pd.DataFrame({"a": [1, 2, 3], "b": [4, 3, 2]})
+    df = nw.from_native(df_any, eager_only=True)
+    with pytest.raises(ValueError, match="No results"):
+        df.filter(nw.col("a") < 0).group_by("a").agg(
+            nw.col("b").sum().round(2).alias("c")
+        )