diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py index 35f4e210d8..621cb9ea11 100644 --- a/narwhals/_arrow/series.py +++ b/narwhals/_arrow/series.py @@ -23,6 +23,7 @@ ) from narwhals._compliant import EagerSeries from narwhals._expression_parsing import ExprKind +from narwhals._typing_compat import assert_never from narwhals._utils import ( Implementation, generate_temporary_column_name, @@ -564,8 +565,8 @@ def is_between( ge = pc.greater_equal(self.native, lower_bound) le = pc.less_equal(self.native, upper_bound) res = pc.and_kleene(ge, le) - else: # pragma: no cover - raise AssertionError + else: + assert_never(closed) return self._with_native(res) def is_null(self) -> Self: diff --git a/narwhals/_compliant/dataframe.py b/narwhals/_compliant/dataframe.py index 1e2537de71..bc8a3442b8 100644 --- a/narwhals/_compliant/dataframe.py +++ b/narwhals/_compliant/dataframe.py @@ -23,7 +23,7 @@ ToNarwhals, ToNarwhalsT_co, ) -from narwhals._typing_compat import deprecated +from narwhals._typing_compat import assert_never, deprecated from narwhals._utils import ( Version, _StoresNative, @@ -479,9 +479,8 @@ def __getitem__( # noqa: C901, PLR0912 compliant = self._select_multi_name(columns.native) elif is_sequence_like(columns): compliant = self._select_multi_name(columns) - else: # pragma: no cover - msg = f"Unreachable code, got unexpected type: {type(columns)}" - raise AssertionError(msg) + else: + assert_never(columns) if not is_slice_none(rows): if isinstance(rows, int): @@ -492,8 +491,7 @@ def __getitem__( # noqa: C901, PLR0912 compliant = compliant._gather(rows.native) elif is_sized_multi_index_selector(rows): compliant = compliant._gather(rows) - else: # pragma: no cover - msg = f"Unreachable code, got unexpected type: {type(rows)}" - raise AssertionError(msg) + else: + assert_never(rows) return compliant diff --git a/narwhals/_compliant/series.py b/narwhals/_compliant/series.py index 0e90885939..b5f192b04c 100644 --- a/narwhals/_compliant/series.py +++ b/narwhals/_compliant/series.py @@ -16,6 +16,7 @@ NativeSeriesT_co, ) from narwhals._translate import FromIterable, FromNative, NumpyConvertible, ToNarwhals +from narwhals._typing_compat import assert_never from narwhals._utils import ( _StoresCompliant, _StoresNative, @@ -330,9 +331,8 @@ def __getitem__(self, item: MultiIndexSelector[Self]) -> Self: return self._gather(item.native) elif is_sized_multi_index_selector(item): return self._gather(item) - else: # pragma: no cover - msg = f"Unreachable code, got unexpected type: {type(item)}" - raise AssertionError(msg) + else: + assert_never(item) @property def str(self) -> EagerSeriesStringNamespace[Self, NativeSeriesT]: ... diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py index ebb0906c74..1d8ec2160e 100644 --- a/narwhals/_pandas_like/series.py +++ b/narwhals/_pandas_like/series.py @@ -21,6 +21,7 @@ select_columns_by_name, set_index, ) +from narwhals._typing_compat import assert_never from narwhals._utils import ( Implementation, is_list_of, @@ -375,8 +376,8 @@ def is_between( res = ser.gt(lower_bound) & ser.lt(upper_bound) elif closed == "both": res = ser.ge(lower_bound) & ser.le(upper_bound) - else: # pragma: no cover - raise AssertionError + else: + assert_never(closed) return self._with_native(res).alias(ser.name) def is_in(self, other: Any) -> Self: diff --git a/narwhals/_typing_compat.py b/narwhals/_typing_compat.py index 56c1fb6096..ebf3dc16ec 100644 --- a/narwhals/_typing_compat.py +++ b/narwhals/_typing_compat.py @@ -33,6 +33,11 @@ else: from typing_extensions import TypeVar, deprecated + if sys.version_info >= (3, 11): + from typing import Never, assert_never + else: + from typing_extensions import Never, assert_never + _Fn = TypeVar("_Fn", bound=Callable[..., Any]) @@ -65,9 +70,24 @@ def wrapper(func: _Fn, /) -> _Fn: return wrapper + _ASSERT_NEVER_REPR_MAX_LENGTH = 100 + _BUG_URL = ( + "https://github.com/narwhals-dev/narwhals/issues/new?template=bug_report.yml" + ) + + def assert_never(arg: Never, /) -> Never: + value = repr(arg) + if len(value) > _ASSERT_NEVER_REPR_MAX_LENGTH: + value = value[:_ASSERT_NEVER_REPR_MAX_LENGTH] + "..." + msg = ( + f"Expected code to be unreachable, but got: {value}.\n" + f"Please report an issue at {_BUG_URL}" + ) + raise AssertionError(msg) + # TODO @dangotbanned: Remove after dropping `3.8` (#2084) # - https://github.com/narwhals-dev/narwhals/pull/2064#discussion_r1965921386 from typing import Protocol as Protocol38 -__all__ = ["Protocol38", "TypeVar", "deprecated"] +__all__ = ["Protocol38", "TypeVar", "assert_never", "deprecated"] diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py index 706b5e64f5..4877e958a1 100644 --- a/narwhals/stable/v1/__init__.py +++ b/narwhals/stable/v1/__init__.py @@ -6,7 +6,7 @@ import narwhals as nw from narwhals import exceptions, functions as nw_f -from narwhals._typing_compat import TypeVar +from narwhals._typing_compat import TypeVar, assert_never from narwhals._utils import ( Implementation, Version, @@ -482,8 +482,7 @@ def _stableify( return Series(obj._compliant_series._with_version(Version.V1), level=obj._level) if isinstance(obj, NwExpr): return Expr(obj._to_compliant_expr, obj._metadata) - msg = f"Expected DataFrame, LazyFrame, Series, or Expr, got: {type(obj)}" # pragma: no cover - raise AssertionError(msg) + assert_never(obj) @overload diff --git a/pyproject.toml b/pyproject.toml index fc7f4f409d..6c8daafa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -285,6 +285,7 @@ exclude_also = [ 'if "pyspark" in str\(constructor', 'if "pyspark_connect" in str\(constructor', 'pytest.skip\(', + 'assert_never\(', ] [tool.mypy] diff --git a/tests/typing_compat_test.py b/tests/typing_compat_test.py new file mode 100644 index 0000000000..ffb026e8dc --- /dev/null +++ b/tests/typing_compat_test.py @@ -0,0 +1,28 @@ +"""Ensuring backports/extensions to new `typing` features are understood correctly.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Literal + +import pytest + +from narwhals._typing_compat import assert_never + + +def test_assert_never() -> None: + pattern = re.compile( + r"expected.+unreachable.+got.+'a'.+report.+issue.+github.+narwhals", + re.DOTALL | re.IGNORECASE, + ) + some: Literal["a"] = "a" + if some != "a": + assigned = "b" + assert_never(assigned) + else: + assigned = some + if not TYPE_CHECKING: + # NOTE: Trying to avoid the assert influencing narrowing + assert assigned == "a" + with pytest.raises(AssertionError, match=pattern): + assert_never(assigned) # type: ignore[arg-type]