Repository: narwhals-dev/narwhals
Pull request #245 (2024-06-01 17:00:25)

DOC: sort API Reference
xref #215

sorted alphabetically API Reference




Patch:
diff --git a/docs/api-reference/selectors.md b/docs/api-reference/selectors.md
index 4b56afdc..6480a869 100644
--- a/docs/api-reference/selectors.md
+++ b/docs/api-reference/selectors.md
@@ -1,5 +1,13 @@
 # `narwhals.selectors`
 
+The following selectors are all supported. In addition, just like in Polars, the following
+set operations are supported:
+
+- set intersection: `&`
+- set union: `|`
+- set difference: `-`
+- complement: `~`
+
 ::: narwhals.selectors
     handler: python
     options:
diff --git a/docs/installation.md b/docs/installation.md
index d73a9e93..67ee0bf5 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'0.8.21'
+'0.9.0'
 ```
 then installation worked correctly!
diff --git a/mkdocs.yml b/mkdocs.yml
index 077a3abb..36b4c9b3 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -16,20 +16,20 @@ nav:
   - Roadmap: roadmap.md
   - Related projects: related.md
   - API Reference:
-    - api-reference/index.md
-    - api-reference/narwhals.md
     - api-reference/dataframe.md
+    - api-reference/expressions.md
+    - api-reference/expressions_dt.md
+    - api-reference/expressions_str.md
+    - api-reference/group_by.md
     - api-reference/lazyframe.md
     - api-reference/series.md
     - api-reference/series_dt.md
     - api-reference/series_str.md
-    - api-reference/expressions.md
-    - api-reference/expressions_dt.md
-    - api-reference/expressions_str.md
-    - api-reference/dtypes.md
     - api-reference/dependencies.md
+    - api-reference/dtypes.md
+    - api-reference/index.md
+    - api-reference/narwhals.md
     - api-reference/selectors.md
-    - api-reference/group_by.md
 theme:
   name: material
   font: false
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index ce03bcab..0530e151 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -1,3 +1,4 @@
+from narwhals import selectors
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.dtypes import Boolean
@@ -31,9 +32,10 @@ from narwhals.translate import to_native
 from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "0.8.21"
+__version__ = "0.9.0"
 
 __all__ = [
+    "selectors",
     "concat",
     "to_native",
     "from_native",
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 687b1134..73ba692b 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -127,6 +127,9 @@ class PandasDataFrame:
         **named_exprs: IntoPandasExpr,
     ) -> Self:
         new_series = evaluate_into_exprs(self, *exprs, **named_exprs)
+        if not new_series:
+            # return empty dataframe, like Polars does
+            return self._from_dataframe(self._dataframe.__class__())
         new_series = validate_indices(new_series)
         df = horizontal_concat(
             new_series,
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 1bf12c61..0606ecdc 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -9,7 +9,7 @@ from typing import Iterable
 from narwhals import dtypes
 from narwhals._pandas_like.dataframe import PandasDataFrame
 from narwhals._pandas_like.expr import PandasExpr
-from narwhals._pandas_like.selectors import PandasSelector
+from narwhals._pandas_like.selectors import PandasSelectorNamespace
 from narwhals._pandas_like.series import PandasSeries
 from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import parse_into_exprs
@@ -38,8 +38,8 @@ class PandasNamespace:
     Datetime = dtypes.Datetime
 
     @property
-    def selectors(self) -> PandasSelector:
-        return PandasSelector(self._implementation)
+    def selectors(self) -> PandasSelectorNamespace:
+        return PandasSelectorNamespace(self._implementation)
 
     # --- not in spec ---
     def __init__(self, implementation: str) -> None:
diff --git a/narwhals/_pandas_like/selectors.py b/narwhals/_pandas_like/selectors.py
index df065517..91036b9b 100644
--- a/narwhals/_pandas_like/selectors.py
+++ b/narwhals/_pandas_like/selectors.py
@@ -1,7 +1,10 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING
+from typing import Any
+from typing import NoReturn
 
+from narwhals import dtypes
 from narwhals._pandas_like.expr import PandasExpr
 
 if TYPE_CHECKING:
@@ -10,15 +13,53 @@ if TYPE_CHECKING:
     from narwhals.dtypes import DType
 
 
-class PandasSelector:
+class PandasSelectorNamespace:
     def __init__(self, implementation: str) -> None:
         self._implementation = implementation
 
-    def by_dtype(self, dtypes: list[DType]) -> PandasExpr:
+    def by_dtype(self, dtypes: list[DType | type[DType]]) -> PandasSelector:
         def func(df: PandasDataFrame) -> list[PandasSeries]:
             return [df[col] for col in df.columns if df.schema[col] in dtypes]
 
-        return PandasExpr(
+        return PandasSelector(
+            func,
+            depth=0,
+            function_name="type_selector",
+            root_names=None,
+            output_names=None,
+            implementation=self._implementation,
+        )
+
+    def numeric(self) -> PandasSelector:
+        return self.by_dtype(
+            [
+                dtypes.Int64,
+                dtypes.Int32,
+                dtypes.Int16,
+                dtypes.Int8,
+                dtypes.UInt64,
+                dtypes.UInt32,
+                dtypes.UInt16,
+                dtypes.UInt8,
+                dtypes.Float64,
+                dtypes.Float32,
+            ],
+        )
+
+    def categorical(self) -> PandasSelector:
+        return self.by_dtype([dtypes.Categorical])
+
+    def string(self) -> PandasSelector:
+        return self.by_dtype([dtypes.String])
+
+    def boolean(self) -> PandasSelector:
+        return self.by_dtype([dtypes.Boolean])
+
+    def all(self) -> PandasSelector:
+        def func(df: PandasDataFrame) -> list[PandasSeries]:
+            return [df[col] for col in df.columns]
+
+        return PandasSelector(
             func,
             depth=0,
             function_name="type_selector",
@@ -26,3 +67,93 @@ class PandasSelector:
             output_names=None,
             implementation=self._implementation,
         )
+
+
+class PandasSelector(PandasExpr):
+    def __repr__(self) -> str:  # pragma: no cover
+        return (
+            f"PandasSelector("
+            f"depth={self._depth}, "
+            f"function_name={self._function_name}, "
+            f"root_names={self._root_names}, "
+            f"output_names={self._output_names}"
+        )
+
+    def _to_expr(self) -> PandasExpr:
+        return PandasExpr(
+            self._call,
+            depth=self._depth,
+            function_name=self._function_name,
+            root_names=self._root_names,
+            output_names=self._output_names,
+            implementation=self._implementation,
+        )
+
+    def __sub__(self, other: PandasSelector | Any) -> PandasSelector | Any:
+        if isinstance(other, PandasSelector):
+
+            def call(df: PandasDataFrame) -> list[PandasSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name not in [x.name for x in rhs]]
+
+            return PandasSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                implementation=self._implementation,
+            )
+        else:
+            return self._to_expr() - other
+
+    def __or__(self, other: PandasSelector | Any) -> PandasSelector | Any:
+        if isinstance(other, PandasSelector):
+
+            def call(df: PandasDataFrame) -> list[PandasSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name not in [x.name for x in rhs]] + rhs
+
+            return PandasSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                implementation=self._implementation,
+            )
+        else:
+            return self._to_expr() | other
+
+    def __and__(self, other: PandasSelector | Any) -> PandasSelector | Any:
+        if isinstance(other, PandasSelector):
+
+            def call(df: PandasDataFrame) -> list[PandasSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name in [x.name for x in rhs]]
+
+            return PandasSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                implementation=self._implementation,
+            )
+        else:
+            return self._to_expr() & other
+
+    def __invert__(self) -> PandasSelector:
+        return PandasSelectorNamespace(self._implementation).all() - self
+
+    def __rsub__(self, other: Any) -> NoReturn:
+        raise NotImplementedError
+
+    def __rand__(self, other: Any) -> NoReturn:
+        raise NotImplementedError
+
+    def __ror__(self, other: Any) -> NoReturn:
+        raise NotImplementedError
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 1e5f53a3..bd87ba16 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -2358,9 +2358,6 @@ def len() -> Expr:
     """
     Return the number of rows.
 
-    Arguments:
-        *columns: Name(s) of the columns to use in the aggregation function.
-
     Examples:
         >>> import polars as pl
         >>> import pandas as pd
diff --git a/narwhals/selectors.py b/narwhals/selectors.py
index b6580b69..19bad5d3 100644
--- a/narwhals/selectors.py
+++ b/narwhals/selectors.py
@@ -2,12 +2,14 @@ from __future__ import annotations
 
 from typing import Any
 
-from narwhals import dtypes
 from narwhals.dtypes import translate_dtype
 from narwhals.expression import Expr
 from narwhals.utils import flatten
 
 
+class Selector(Expr): ...
+
+
 def by_dtype(*dtypes: Any) -> Expr:
     """
     Select columns based on their dtype.
@@ -50,7 +52,7 @@ def by_dtype(*dtypes: Any) -> Expr:
         │ 4   ┆ 4.6 │
         └─────┴─────┘
     """
-    return Expr(
+    return Selector(
         lambda plx: plx.selectors.by_dtype(
             [translate_dtype(plx, dtype) for dtype in flatten(dtypes)]
         )
@@ -96,18 +98,7 @@ def numeric() -> Expr:
         │ 4   ┆ 4.6 │
         └─────┴─────┘
     """
-    return by_dtype(
-        dtypes.Int64,
-        dtypes.Int32,
-        dtypes.Int16,
-        dtypes.Int8,
-        dtypes.UInt64,
-        dtypes.UInt32,
-        dtypes.UInt16,
-        dtypes.UInt8,
-        dtypes.Float64,
-        dtypes.Float32,
-    )
+    return Selector(lambda plx: plx.selectors.numeric())
 
 
 def boolean() -> Expr:
@@ -149,9 +140,7 @@ def boolean() -> Expr:
         │ true  │
         └───────┘
     """
-    return by_dtype(
-        dtypes.Boolean,
-    )
+    return Selector(lambda plx: plx.selectors.boolean())
 
 
 def string() -> Expr:
@@ -193,9 +182,7 @@ def string() -> Expr:
         │ y   │
         └─────┘
     """
-    return by_dtype(
-        dtypes.String,
-    )
+    return Selector(lambda plx: plx.selectors.string())
 
 
 def categorical() -> Expr:
@@ -237,6 +224,46 @@ def categorical() -> Expr:
         │ y   │
         └─────┘
     """
-    return by_dtype(
-        dtypes.Categorical,
-    )
+    return Selector(lambda plx: plx.selectors.categorical())
+
+
+def all() -> Expr:
+    """
+    Select all columns.
+
+    Examples:
+        >>> import narwhals as nw
+        >>> import narwhals.selectors as ncs
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>>
+        >>> data = {'a': [1, 2], 'b': ['x', 'y'], 'c': [False, True]}
+        >>> df_pd = pd.DataFrame(data).astype({'b': 'category'})
+        >>> df_pl = pl.DataFrame(data, schema_overrides={'b': pl.Categorical})
+
+        Let's define a dataframe-agnostic function to select string
+        dtypes:
+
+        >>> def func(df_any):
+        ...     df = nw.from_native(df_any)
+        ...     df = df.select(ncs.all())
+        ...     return nw.to_native(df)
+
+        We can then pass either pandas or Polars dataframes:
+
+        >>> func(df_pd)
+           a  b      c
+        0  1  x  False
+        1  2  y   True
+        >>> func(df_pl)
+        shape: (2, 3)
+        ┌─────┬─────┬───────┐
+        │ a   ┆ b   ┆ c     │
+        │ --- ┆ --- ┆ ---   │
+        │ i64 ┆ cat ┆ bool  │
+        ╞═════╪═════╪═══════╡
+        │ 1   ┆ x   ┆ false │
+        │ 2   ┆ y   ┆ true  │
+        └─────┴─────┴───────┘
+    """
+    return Selector(lambda plx: plx.selectors.all())
diff --git a/pyproject.toml b/pyproject.toml
index efc6d9ea..be00ae0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "0.8.21"
+version = "0.9.0"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]


Test patch:
diff --git a/tests/selectors_test.py b/tests/selectors_test.py
index 931ad49d..28694402 100644
--- a/tests/selectors_test.py
+++ b/tests/selectors_test.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from typing import Any
 
 import pandas as pd
@@ -5,6 +7,7 @@ import polars as pl
 import pytest
 
 import narwhals as nw
+from narwhals.selectors import all
 from narwhals.selectors import boolean
 from narwhals.selectors import by_dtype
 from narwhals.selectors import categorical
@@ -61,3 +64,36 @@ def test_categorical() -> None:
     result = nw.to_native(df.select(categorical()))
     expected = {"b": ["a", "b", "c"]}
     compare_dicts(result, expected)
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+@pytest.mark.parametrize(
+    ("selector", "expected"),
+    [
+        (numeric() | boolean(), ["a", "c", "d"]),
+        (numeric() & boolean(), []),
+        (numeric() & by_dtype(nw.Int64), ["a"]),
+        (numeric() | by_dtype(nw.Int64), ["a", "c"]),
+        (~numeric(), ["b", "d"]),
+        (boolean() & True, ["d"]),
+        (boolean() | True, ["d"]),
+        (numeric() - 1, ["a", "c"]),
+        (all(), ["a", "b", "c", "d"]),
+    ],
+)
+def test_set_ops(
+    constructor: Any, selector: nw.selectors.Selector, expected: list[str]
+) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.select(selector).columns
+    assert sorted(result) == expected
+
+
+def test_set_ops_invalid() -> None:
+    df = nw.from_native(pd.DataFrame(data))
+    with pytest.raises(NotImplementedError):
+        df.select(1 - numeric())
+    with pytest.raises(NotImplementedError):
+        df.select(1 | numeric())
+    with pytest.raises(NotImplementedError):
+        df.select(1 & numeric())