Repository: narwhals-dev/narwhals (python)

Issue:
feat: add is_pandas_index, is_modin_index, is_cudf_index, is_pandas_like_index utility functions
Similar to the current `is_pandas_series` / `is_pandas_like_series` functions, this should be very similar to those

Pull request description:
feat: add is_pandas_index, is_modin_index, is_cudf_index, is_pandas_like_index utility functions
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue # 
- Closes #1258

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below.



Patch:
diff --git a/docs/api-reference/dependencies.md b/docs/api-reference/dependencies.md
index 204dac06..75ef4b27 100644
--- a/docs/api-reference/dependencies.md
+++ b/docs/api-reference/dependencies.md
@@ -11,15 +11,19 @@
         - get_polars
         - get_pyarrow
         - is_cudf_dataframe
+        - is_cudf_index
         - is_cudf_series
         - is_dask_dataframe
         - is_ibis_table
         - is_into_series
         - is_modin_dataframe
+        - is_modin_index
         - is_modin_series
         - is_numpy_array
         - is_pandas_dataframe
+        - is_pandas_index
         - is_pandas_like_dataframe
+        - is_pandas_like_index
         - is_pandas_like_series
         - is_pandas_series
         - is_polars_dataframe
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index eb5be38e..f1c057b3 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -98,6 +98,11 @@ def is_pandas_series(ser: Any) -> TypeGuard[pd.Series[Any]]:
     return (pd := get_pandas()) is not None and isinstance(ser, pd.Series)
 
 
+def is_pandas_index(index: Any) -> TypeGuard[pd.Index]:
+    """Check whether `index` is a pandas Index without importing pandas."""
+    return (pd := get_pandas()) is not None and isinstance(index, pd.Index)
+
+
 def is_modin_dataframe(df: Any) -> TypeGuard[mpd.DataFrame]:
     """Check whether `df` is a modin DataFrame without importing modin."""
     return (mpd := get_modin()) is not None and isinstance(df, mpd.DataFrame)
@@ -108,6 +113,13 @@ def is_modin_series(ser: Any) -> TypeGuard[mpd.Series]:
     return (mpd := get_modin()) is not None and isinstance(ser, mpd.Series)
 
 
+def is_modin_index(index: Any) -> TypeGuard[mpd.Index]:
+    """Check whether `index` is a modin Index without importing modin."""
+    return (mpd := get_modin()) is not None and isinstance(
+        index, mpd.Index
+    )  # pragma: no cover
+
+
 def is_cudf_dataframe(df: Any) -> TypeGuard[cudf.DataFrame]:
     """Check whether `df` is a cudf DataFrame without importing cudf."""
     return (cudf := get_cudf()) is not None and isinstance(df, cudf.DataFrame)
@@ -118,6 +130,13 @@ def is_cudf_series(ser: Any) -> TypeGuard[cudf.Series[Any]]:
     return (cudf := get_cudf()) is not None and isinstance(ser, cudf.Series)
 
 
+def is_cudf_index(index: Any) -> TypeGuard[cudf.Index]:
+    """Check whether `index` is a cudf Index without importing cudf."""
+    return (cudf := get_cudf()) is not None and isinstance(
+        index, cudf.Index
+    )  # pragma: no cover
+
+
 def is_dask_dataframe(df: Any) -> TypeGuard[dd.DataFrame]:
     """Check whether `df` is a Dask DataFrame without importing Dask."""
     return (dd := get_dask_dataframe()) is not None and isinstance(df, dd.DataFrame)
@@ -174,13 +193,24 @@ def is_pandas_like_dataframe(df: Any) -> bool:
     return is_pandas_dataframe(df) or is_modin_dataframe(df) or is_cudf_dataframe(df)
 
 
-def is_pandas_like_series(arr: Any) -> bool:
+def is_pandas_like_series(ser: Any) -> bool:
     """
-    Check whether `arr` is a pandas-like Series without doing any imports
+    Check whether `ser` is a pandas-like Series without doing any imports
 
     By "pandas-like", we mean: pandas, Modin, cuDF.
     """
-    return is_pandas_series(arr) or is_modin_series(arr) or is_cudf_series(arr)
+    return is_pandas_series(ser) or is_modin_series(ser) or is_cudf_series(ser)
+
+
+def is_pandas_like_index(index: Any) -> bool:
+    """
+    Check whether `index` is a pandas-like Index without doing any imports
+
+    By "pandas-like", we mean: pandas, Modin, cuDF.
+    """
+    return (
+        is_pandas_index(index) or is_modin_index(index) or is_cudf_index(index)
+    )  # pragma: no cover
 
 
 def is_into_series(native_series: IntoSeries) -> bool:
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 331b87d8..a1b0e232 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -395,27 +395,35 @@ def _from_native_impl(  # noqa: PLR0915
     # Extensions
     if hasattr(native_object, "__narwhals_dataframe__"):
         if series_only:
-            msg = "Cannot only use `series_only` with dataframe"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with dataframe"
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             native_object.__narwhals_dataframe__(),
             level="full",
         )
     elif hasattr(native_object, "__narwhals_lazyframe__"):
         if series_only:
-            msg = "Cannot only use `series_only` with lazyframe"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with lazyframe"
+                raise TypeError(msg)
+            return native_object
         if eager_only or eager_or_interchange_only:
-            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
+                raise TypeError(msg)
+            return native_object
         return LazyFrame(
             native_object.__narwhals_lazyframe__(),
             level="full",
         )
     elif hasattr(native_object, "__narwhals_series__"):
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         return Series(
             native_object.__narwhals_series__(),
             level="full",
@@ -424,8 +432,10 @@ def _from_native_impl(  # noqa: PLR0915
     # Polars
     elif is_polars_dataframe(native_object):
         if series_only:
-            msg = "Cannot only use `series_only` with polars.DataFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with polars.DataFrame"
+                raise TypeError(msg)
+            return native_object
         pl = get_polars()
         return DataFrame(
             PolarsDataFrame(
@@ -437,11 +447,15 @@ def _from_native_impl(  # noqa: PLR0915
         )
     elif is_polars_lazyframe(native_object):
         if series_only:
-            msg = "Cannot only use `series_only` with polars.LazyFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with polars.LazyFrame"
+                raise TypeError(msg)
+            return native_object
         if eager_only or eager_or_interchange_only:
-            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame"
+                raise TypeError(msg)
+            return native_object
         pl = get_polars()
         return LazyFrame(
             PolarsLazyFrame(
@@ -454,8 +468,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_polars_series(native_object):
         pl = get_polars()
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         return Series(
             PolarsSeries(
                 native_object,
@@ -468,8 +484,10 @@ def _from_native_impl(  # noqa: PLR0915
     # pandas
     elif is_pandas_dataframe(native_object):
         if series_only:
-            msg = "Cannot only use `series_only` with dataframe"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with dataframe"
+                raise TypeError(msg)
+            return native_object
         pd = get_pandas()
         return DataFrame(
             PandasLikeDataFrame(
@@ -482,8 +500,10 @@ def _from_native_impl(  # noqa: PLR0915
         )
     elif is_pandas_series(native_object):
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         pd = get_pandas()
         return Series(
             PandasLikeSeries(
@@ -499,8 +519,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_modin_dataframe(native_object):  # pragma: no cover
         mpd = get_modin()
         if series_only:
-            msg = "Cannot only use `series_only` with modin.DataFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with modin.DataFrame"
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             PandasLikeDataFrame(
                 native_object,
@@ -513,8 +535,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_modin_series(native_object):  # pragma: no cover
         mpd = get_modin()
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         return Series(
             PandasLikeSeries(
                 native_object,
@@ -529,8 +553,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_cudf_dataframe(native_object):  # pragma: no cover
         cudf = get_cudf()
         if series_only:
-            msg = "Cannot only use `series_only` with cudf.DataFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with cudf.DataFrame"
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             PandasLikeDataFrame(
                 native_object,
@@ -543,8 +569,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_cudf_series(native_object):  # pragma: no cover
         cudf = get_cudf()
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         return Series(
             PandasLikeSeries(
                 native_object,
@@ -559,8 +587,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_pyarrow_table(native_object):
         pa = get_pyarrow()
         if series_only:
-            msg = "Cannot only use `series_only` with arrow table"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with arrow table"
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             ArrowDataFrame(
                 native_object,
@@ -572,8 +602,10 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_pyarrow_chunked_array(native_object):
         pa = get_pyarrow()
         if not allow_series:
-            msg = "Please set `allow_series=True`"
-            raise TypeError(msg)
+            if strict:
+                msg = "Please set `allow_series=True`"
+                raise TypeError(msg)
+            return native_object
         return Series(
             ArrowSeries(
                 native_object,
@@ -587,11 +619,15 @@ def _from_native_impl(  # noqa: PLR0915
     # Dask
     elif is_dask_dataframe(native_object):
         if series_only:
-            msg = "Cannot only use `series_only` with dask DataFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `series_only` with dask DataFrame"
+                raise TypeError(msg)
+            return native_object
         if eager_only or eager_or_interchange_only:
-            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with dask DataFrame"
-            raise TypeError(msg)
+            if strict:
+                msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with dask DataFrame"
+                raise TypeError(msg)
+            return native_object
         if get_dask_expr() is None:  # pragma: no cover
             msg = "Please install dask-expr"
             raise ImportError(msg)
@@ -607,10 +643,13 @@ def _from_native_impl(  # noqa: PLR0915
     # DuckDB
     elif is_duckdb_relation(native_object):
         if eager_only or series_only:  # pragma: no cover
-            msg = (
-                "Cannot only use `series_only=True` or `eager_only=False` "
-                "with DuckDB Relation"
-            )
+            if strict:
+                msg = (
+                    "Cannot only use `series_only=True` or `eager_only=False` "
+                    "with DuckDB Relation"
+                )
+            else:
+                return native_object
             raise TypeError(msg)
         return DataFrame(
             DuckDBInterchangeFrame(native_object, dtypes=dtypes),
@@ -620,11 +659,13 @@ def _from_native_impl(  # noqa: PLR0915
     # Ibis
     elif is_ibis_table(native_object):  # pragma: no cover
         if eager_only or series_only:
-            msg = (
-                "Cannot only use `series_only=True` or `eager_only=False` "
-                "with Ibis table"
-            )
-            raise TypeError(msg)
+            if strict:
+                msg = (
+                    "Cannot only use `series_only=True` or `eager_only=False` "
+                    "with Ibis table"
+                )
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             IbisInterchangeFrame(native_object, dtypes=dtypes),
             level="interchange",
@@ -633,11 +674,13 @@ def _from_native_impl(  # noqa: PLR0915
     # Interchange protocol
     elif hasattr(native_object, "__dataframe__"):
         if eager_only or series_only:
-            msg = (
-                "Cannot only use `series_only=True` or `eager_only=False` "
-                "with object which only implements __dataframe__"
-            )
-            raise TypeError(msg)
+            if strict:
+                msg = (
+                    "Cannot only use `series_only=True` or `eager_only=False` "
+                    "with object which only implements __dataframe__"
+                )
+                raise TypeError(msg)
+            return native_object
         return DataFrame(
             InterchangeFrame(native_object, dtypes=dtypes),
             level="interchange",


Test patch:
diff --git a/tests/dependencies/is_pandas_index_test.py b/tests/dependencies/is_pandas_index_test.py
new file mode 100644
index 00000000..1d97dd82
--- /dev/null
+++ b/tests/dependencies/is_pandas_index_test.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+import pandas as pd
+
+from narwhals.dependencies import is_pandas_index
+
+
+def test_is_pandas_index() -> None:
+    data = [1, 2]
+    s_pd = pd.Series(data)
+    assert is_pandas_index(s_pd.index)
+    assert not is_pandas_index(data)
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index c996b2a2..53a35087 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -99,6 +99,8 @@ def test_eager_only_lazy(dframe: Any, eager_only: Any, context: Any) -> None:
     with context:
         res = nw.from_native(dframe, eager_only=eager_only)
         assert isinstance(res, nw.LazyFrame)
+    if eager_only:
+        assert nw.from_native(dframe, eager_only=eager_only, strict=False) is dframe
 
 
 @pytest.mark.parametrize("dframe", eager_frames)
@@ -122,6 +124,9 @@ def test_series_only(obj: Any, context: Any) -> None:
     with context:
         res = nw.from_native(obj, series_only=True)
         assert isinstance(res, nw.Series)
+    assert nw.from_native(obj, series_only=True, strict=False) is obj or isinstance(
+        res, nw.Series
+    )
 
 
 @pytest.mark.parametrize("series", all_series)
@@ -136,6 +141,8 @@ def test_allow_series(series: Any, allow_series: Any, context: Any) -> None:
     with context:
         res = nw.from_native(series, allow_series=allow_series)
         assert isinstance(res, nw.Series)
+    if not allow_series:
+        assert nw.from_native(series, allow_series=allow_series, strict=False) is series
 
 
 def test_invalid_series_combination() -> None:
@@ -184,6 +191,7 @@ def test_series_only_dask() -> None:
 
     with pytest.raises(TypeError, match="Cannot only use `series_only`"):
         nw.from_native(dframe, series_only=True)
+    assert nw.from_native(dframe, series_only=True, strict=False) is dframe
 
 
 @pytest.mark.parametrize(
@@ -203,6 +211,8 @@ def test_eager_only_lazy_dask(eager_only: Any, context: Any) -> None:
     with context:
         res = nw.from_native(dframe, eager_only=eager_only)
         assert isinstance(res, nw.LazyFrame)
+    if eager_only:
+        assert nw.from_native(dframe, eager_only=eager_only, strict=False) is dframe
 
 
 def test_from_native_strict_false_typing() -> None:
@@ -214,3 +224,13 @@ def test_from_native_strict_false_typing() -> None:
     unstable_nw.from_native(df, strict=False)
     unstable_nw.from_native(df, strict=False, eager_only=True)
     unstable_nw.from_native(df, strict=False, eager_or_interchange_only=True)
+
+
+def test_from_mock_interchange_protocol_non_strict() -> None:
+    class MockDf:
+        def __dataframe__(self) -> None:  # pragma: no cover
+            pass
+
+    mockdf = MockDf()
+    result = nw.from_native(mockdf, eager_only=True, strict=False)
+    assert result is mockdf  # type: ignore[comparison-overlap]