Repository: narwhals-dev/narwhals

Issue:
RFC: Series aggregations which return booleans should always return Python booleans
In general, we're trying to let libraries handle their own scalars, so that they can choose to preserve precision or keep data on a different device

But, for functions which aggregate to a single boolean, I think we should just return a Python bool. If someone calls a function which returns a boolean, then they're almost certainly using it with `if` and would probably not think to call `nw.to_py_scalar`. I think here the risks outweight the benefits, and so for booleans, we should always return Python bool

What's really unintuitive about pyarrow scalars is this:
```
In [4]: if pa.scalar(False): print('hey')
hey
```

Discussion:

Patch:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index ee06f1fa..f286b300 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -14,6 +14,7 @@ from narwhals._arrow.utils import narwhals_to_native_dtype
 from narwhals._arrow.utils import native_to_narwhals_dtype
 from narwhals._arrow.utils import parse_datetime_format
 from narwhals._arrow.utils import validate_column_comparand
+from narwhals.translate import to_py_scalar
 from narwhals.utils import Implementation
 from narwhals.utils import generate_temporary_column_name
 
@@ -437,12 +438,12 @@ class ArrowSeries:
     def any(self) -> bool:
         import pyarrow.compute as pc  # ignore-banned-import()
 
-        return pc.any(self._native_series)  # type: ignore[no-any-return]
+        return to_py_scalar(pc.any(self._native_series))  # type: ignore[no-any-return]
 
     def all(self) -> bool:
         import pyarrow.compute as pc  # ignore-banned-import()
 
-        return pc.all(self._native_series)  # type: ignore[no-any-return]
+        return to_py_scalar(pc.all(self._native_series))  # type: ignore[no-any-return]
 
     def is_between(
         self, lower_bound: Any, upper_bound: Any, closed: str = "both"
@@ -719,9 +720,10 @@ class ArrowSeries:
 
         ser = self._native_series
         if descending:
-            return pc.all(pc.greater_equal(ser[:-1], ser[1:]))  # type: ignore[no-any-return]
+            result = pc.all(pc.greater_equal(ser[:-1], ser[1:]))
         else:
-            return pc.all(pc.less_equal(ser[:-1], ser[1:]))  # type: ignore[no-any-return]
+            result = pc.all(pc.less_equal(ser[:-1], ser[1:]))
+        return to_py_scalar(result)  # type: ignore[no-any-return]
 
     def unique(self: Self, *, maintain_order: bool = False) -> ArrowSeries:
         # The param `maintain_order` is only here for compatibility with the Polars API
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index 0a9fd2b8..0f773dfd 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Sequence
 
-from narwhals.dependencies import get_polars
 from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
@@ -77,17 +76,6 @@ def native_to_narwhals_dtype(dtype: pa.DataType, dtypes: DTypes) -> DType:
 
 
 def narwhals_to_native_dtype(dtype: DType | type[DType], dtypes: DTypes) -> Any:
-    if (pl := get_polars()) is not None and isinstance(
-        dtype, (pl.DataType, pl.DataType.__class__)
-    ):
-        msg = (
-            f"Expected Narwhals object, got: {type(dtype)}.\n\n"
-            "Perhaps you:\n"
-            "- Forgot a `nw.from_native` somewhere?\n"
-            "- Used `pl.Int64` instead of `nw.Int64`?"
-        )
-        raise TypeError(msg)
-
     import pyarrow as pa  # ignore-banned-import
 
     if isinstance_or_issubclass(dtype, dtypes.Float64):
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index ef9e7439..88f2fdb7 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals.dependencies import get_pandas
-from narwhals.dependencies import get_polars
 from narwhals.dependencies import get_pyarrow
 from narwhals.exceptions import InvalidIntoExprError
 from narwhals.utils import isinstance_or_issubclass
@@ -86,17 +85,6 @@ def validate_comparand(lhs: dask_expr.Series, rhs: dask_expr.Series) -> None:
 
 
 def narwhals_to_native_dtype(dtype: DType | type[DType], dtypes: DTypes) -> Any:
-    if (pl := get_polars()) is not None and isinstance(
-        dtype, (pl.DataType, pl.DataType.__class__)
-    ):
-        msg = (
-            f"Expected Narwhals object, got: {type(dtype)}.\n\n"
-            "Perhaps you:\n"
-            "- Forgot a `nw.from_native` somewhere?\n"
-            "- Used `pl.Int64` instead of `nw.Int64`?"
-        )
-        raise TypeError(msg)
-
     if isinstance_or_issubclass(dtype, dtypes.Float64):
         return "float64"
     if isinstance_or_issubclass(dtype, dtypes.Float32):
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index ba5eece4..637df9e0 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -11,7 +11,6 @@ from typing import TypeVar
 from narwhals._arrow.utils import (
     native_to_narwhals_dtype as arrow_native_to_narwhals_dtype,
 )
-from narwhals.dependencies import get_polars
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.utils import Implementation
 from narwhals.utils import isinstance_or_issubclass
@@ -384,17 +383,6 @@ def narwhals_to_native_dtype(  # noqa: PLR0915
     backend_version: tuple[int, ...],
     dtypes: DTypes,
 ) -> Any:
-    if (pl := get_polars()) is not None and isinstance(
-        dtype, (pl.DataType, pl.DataType.__class__)
-    ):
-        msg = (
-            f"Expected Narwhals object, got: {type(dtype)}.\n\n"
-            "Perhaps you:\n"
-            "- Forgot a `nw.from_native` somewhere?\n"
-            "- Used `pl.Int64` instead of `nw.Int64`?"
-        )
-        raise TypeError(msg)
-
     dtype_backend = get_dtype_backend(starting_dtype, implementation)
     if isinstance_or_issubclass(dtype, dtypes.Float64):
         if dtype_backend == "pyarrow-nullable":
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 79528af1..c25b59db 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -99,15 +99,6 @@ def native_to_narwhals_dtype(dtype: pl.DataType, dtypes: DTypes) -> DType:
 def narwhals_to_native_dtype(dtype: DType | type[DType], dtypes: DTypes) -> pl.DataType:
     import polars as pl  # ignore-banned-import()
 
-    if isinstance(dtype, (pl.DataType, pl.DataType.__class__)):  # type: ignore[arg-type]
-        msg = (
-            f"Expected Narwhals object, got: {type(dtype)}.\n\n"
-            "Perhaps you:\n"
-            "- Forgot a `nw.from_native` somewhere?\n"
-            "- Used `pl.Int64` instead of `nw.Int64`?"
-        )
-        raise TypeError(msg)
-
     if dtype == dtypes.Float64:
         return pl.Float64()
     if dtype == dtypes.Float32:
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index 719bd989..e214213c 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -5,6 +5,8 @@ from datetime import timezone
 from typing import TYPE_CHECKING
 from typing import Mapping
 
+from narwhals.utils import isinstance_or_issubclass
+
 if TYPE_CHECKING:
     from typing import Iterator
     from typing import Literal
@@ -13,6 +15,15 @@ if TYPE_CHECKING:
     from typing_extensions import Self
 
 
+def _validate_dtype(dtype: DType | type[DType]) -> None:
+    if not isinstance_or_issubclass(dtype, DType):
+        msg = (
+            f"Expected Narwhals dtype, got: {type(dtype)}.\n\n"
+            "Hint: if you were trying to cast to a type, use e.g. nw.Int64 instead of 'int64'."
+        )
+        raise TypeError(msg)
+
+
 class DType:
     def __repr__(self) -> str:  # pragma: no cover
         return self.__class__.__qualname__
diff --git a/narwhals/expr.py b/narwhals/expr.py
index a43485e0..9c0a484a 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -11,6 +11,7 @@ from typing import Sequence
 from typing import TypeVar
 
 from narwhals.dependencies import is_numpy_array
+from narwhals.dtypes import _validate_dtype
 from narwhals.utils import _validate_rolling_arguments
 from narwhals.utils import flatten
 
@@ -202,6 +203,7 @@ class Expr:
             foo: [[1,2,3]]
             bar: [[6,7,8]]
         """
+        _validate_dtype(dtype)
         return self.__class__(
             lambda plx: self._call(plx).cast(dtype),
         )
diff --git a/narwhals/series.py b/narwhals/series.py
index a5224cea..8ffe0b0f 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -11,6 +11,7 @@ from typing import Sequence
 from typing import TypeVar
 from typing import overload
 
+from narwhals.dtypes import _validate_dtype
 from narwhals.utils import _validate_rolling_arguments
 from narwhals.utils import parse_version
 
@@ -516,6 +517,7 @@ class Series:
                1
             ]
         """
+        _validate_dtype(dtype)
         return self._from_compliant_series(self._compliant_series.cast(dtype))
 
     def to_frame(self) -> DataFrame[Any]:
diff --git a/narwhals/utils.py b/narwhals/utils.py
index d3cb0dda..37160038 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -162,7 +162,7 @@ def isinstance_or_issubclass(obj: Any, cls: Any) -> bool:
 
     if isinstance(obj, DType):
         return isinstance(obj, cls)
-    return isinstance(obj, cls) or issubclass(obj, cls)
+    return isinstance(obj, cls) or (isinstance(obj, type) and issubclass(obj, cls))
 
 
 def validate_laziness(items: Iterable[Any]) -> None:


Test patch:
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 14e77d68..992ea5f5 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -175,16 +175,11 @@ def test_cast_string() -> None:
 
 
 def test_cast_raises_for_unknown_dtype(
-    constructor: Constructor,
-    request: pytest.FixtureRequest,
+    constructor: Constructor, request: pytest.FixtureRequest
 ) -> None:
-    if "pyarrow_table_constructor" in str(constructor) and PYARROW_VERSION <= (
-        15,
-    ):  # pragma: no cover
+    if "pyarrow_table" in str(constructor) and PYARROW_VERSION < (15,):
+        # Unsupported cast from string to dictionary using function cast_dictionary
         request.applymarker(pytest.mark.xfail)
-    if "polars" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     df = nw.from_native(constructor(data)).select(
         nw.col(key).cast(value) for key, value in schema.items()
     )
@@ -192,7 +187,7 @@ def test_cast_raises_for_unknown_dtype(
     class Banana:
         pass
 
-    with pytest.raises(AssertionError, match=r"Unknown dtype"):
+    with pytest.raises(TypeError, match="Expected Narwhals dtype"):
         df.select(nw.col("a").cast(Banana))  # type: ignore[arg-type]
 
 
@@ -229,5 +224,5 @@ def test_cast_datetime_tz_aware(
 @pytest.mark.parametrize("dtype", [pl.String, pl.String()])
 def test_raise_if_polars_dtype(constructor: Constructor, dtype: Any) -> None:
     df = nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
-    with pytest.raises(TypeError, match="Expected Narwhals object, got:"):
+    with pytest.raises(TypeError, match="Expected Narwhals dtype, got:"):
         df.select(nw.col("a").cast(dtype))
diff --git a/tests/frame/invalid_test.py b/tests/frame/invalid_test.py
index 811d0430..9abf4bd2 100644
--- a/tests/frame/invalid_test.py
+++ b/tests/frame/invalid_test.py
@@ -19,7 +19,7 @@ def test_invalid() -> None:
         df.select(nw.all() + nw.all())
     with pytest.raises(TypeError, match="Perhaps you:"):
         df.select([pl.col("a")])  # type: ignore[list-item]
-    with pytest.raises(TypeError, match="Perhaps you:"):
+    with pytest.raises(TypeError, match="Expected Narwhals dtype"):
         df.select([nw.col("a").cast(pl.Int64)])  # type: ignore[arg-type]