diff --git a/src/narwhals/dependencies.py b/src/narwhals/dependencies.py index 710e4609ad..3b1f9ae760 100644 --- a/src/narwhals/dependencies.py +++ b/src/narwhals/dependencies.py @@ -590,6 +590,7 @@ def is_cudf_dtype( def _is_native_series(obj: Any | IntoSeriesT) -> TypeIs[IntoSeriesT]: + from narwhals import plugins from narwhals._utils import _hasattr_static return ( @@ -597,10 +598,12 @@ def _is_native_series(obj: Any | IntoSeriesT) -> TypeIs[IntoSeriesT]: or _is_polars_series(obj) or _is_pyarrow_chunked_array(obj) or _is_pandas_like_series(obj) + or plugins.is_native_series(obj) ) def _is_native_dataframe(obj: Any | IntoDataFrameT) -> TypeIs[IntoDataFrameT]: + from narwhals import plugins from narwhals._utils import _hasattr_static return ( @@ -608,10 +611,12 @@ def _is_native_dataframe(obj: Any | IntoDataFrameT) -> TypeIs[IntoDataFrameT]: or _is_polars_dataframe(obj) or _is_pyarrow_table(obj) or _is_pandas_like_dataframe(obj) + or plugins.is_native_dataframe(obj) ) def _is_native_lazyframe(obj: Any | IntoLazyFrameT) -> TypeIs[IntoLazyFrameT]: + from narwhals import plugins from narwhals._utils import _hasattr_static return ( @@ -623,6 +628,7 @@ def _is_native_lazyframe(obj: Any | IntoLazyFrameT) -> TypeIs[IntoLazyFrameT]: or _is_pyspark_dataframe(obj) or _is_pyspark_connect_dataframe(obj) or _is_sqlframe_dataframe(obj) + or plugins.is_native_lazyframe(obj) ) diff --git a/src/narwhals/plugins.py b/src/narwhals/plugins.py index e550bebca0..a247fc6b22 100644 --- a/src/narwhals/plugins.py +++ b/src/narwhals/plugins.py @@ -110,6 +110,27 @@ def from_native(native_object: Any, version: Version) -> CompliantAny | None: return next(_iter_from_native(native_object, version), None) +def is_native_dataframe(native_object: Any) -> bool: + """Check whether an installed plugin converts `native_object` to an eager DataFrame.""" + from narwhals._utils import Version, is_compliant_dataframe + + return is_compliant_dataframe(from_native(native_object, Version.MAIN)) + + +def is_native_lazyframe(native_object: Any) -> bool: + """Check whether an installed plugin converts `native_object` to a LazyFrame.""" + from narwhals._utils import Version, is_compliant_lazyframe + + return is_compliant_lazyframe(from_native(native_object, Version.MAIN)) + + +def is_native_series(native_object: Any) -> bool: + """Check whether an installed plugin converts `native_object` to a Series.""" + from narwhals._utils import Version, is_compliant_series + + return is_compliant_series(from_native(native_object, Version.MAIN)) + + def _show_suggestions(native_object_type: type) -> str | None: if _might_be(native_object_type, "daft"): # pragma: no cover return ( diff --git a/tests/dependencies/is_into_lazyframe_test.py b/tests/dependencies/is_into_lazyframe_test.py index b9cabdea83..7ef98cc3a2 100644 --- a/tests/dependencies/is_into_lazyframe_test.py +++ b/tests/dependencies/is_into_lazyframe_test.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib.util import find_spec from typing import TYPE_CHECKING, Any import pytest @@ -76,9 +77,11 @@ def test_is_into_lazyframe_numpy() -> None: def test_is_into_lazyframe_other(always_has_attr: AlwaysHasAttr) -> None: - assert not is_into_lazyframe(data) - assert not v1_is_into_lazyframe(data) - assert not v2_is_into_lazyframe(data) + # If `test_plugin` is installed, a plain `dict` is convertible to a LazyFrame. + expected = find_spec("test_plugin") is not None + assert is_into_lazyframe(data) is expected + assert v1_is_into_lazyframe(data) is expected + assert v2_is_into_lazyframe(data) is expected assert is_into_lazyframe(DictLazyFrame(data)) assert v1_is_into_lazyframe(DictLazyFrame(data)) diff --git a/tests/plugins_test.py b/tests/plugins_test.py index eb304910f2..6de21e53f1 100644 --- a/tests/plugins_test.py +++ b/tests/plugins_test.py @@ -1,13 +1,71 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest import narwhals as nw +import narwhals.stable.v1.dependencies as nw_v1_dependencies +import narwhals.stable.v2.dependencies as nw_v2_dependencies +from narwhals import dependencies as nw_dependencies if TYPE_CHECKING: + from typing_extensions import Self + from narwhals.plugins import Plugin + from narwhals.utils import Version + +DEPENDENCIES_MODULES = (nw_dependencies, nw_v1_dependencies, nw_v2_dependencies) + + +class FakeNative: + """Native object of an imaginary plugin-backed library.""" + + +class FakeCompliantDataFrame: + def __narwhals_dataframe__(self) -> Self: # pragma: no cover + return self + + +class FakeCompliantLazyFrame: + def __narwhals_lazyframe__(self) -> Self: # pragma: no cover + return self + + +class FakeCompliantSeries: + def __narwhals_series__(self) -> Self: # pragma: no cover + return self + + +class FakeNamespace: + def __init__(self, compliant_cls: type, version: Version) -> None: + self._compliant_cls = compliant_cls + self._version = version + + def from_native(self, native_object: object) -> Any: + assert isinstance(native_object, FakeNative) + return self._compliant_cls() + + +class FakePlugin: + NATIVE_PACKAGE = "builtins" + + def __init__(self, compliant_cls: type) -> None: + self._compliant_cls = compliant_cls + + def is_native(self, native_object: object) -> bool: + return isinstance(native_object, FakeNative) + + def __narwhals_namespace__(self, version: Version) -> FakeNamespace: + return FakeNamespace(self._compliant_cls, version) + + +class FakeEntryPoint: + def __init__(self, plugin: FakePlugin) -> None: + self._plugin = plugin + + def load(self) -> FakePlugin: + return self._plugin def test_plugin() -> None: @@ -28,6 +86,47 @@ def test_not_implemented() -> None: lf.select(nw.col("a").ewm_mean()) +def test_is_into_lazyframe() -> None: + # https://github.com/narwhals-dev/narwhals/issues/3714 + pytest.importorskip("test_plugin") + df_native = {"a": [1, 1, 2], "b": [4, 5, 6]} + for dependencies in DEPENDENCIES_MODULES: + assert dependencies.is_into_lazyframe(df_native) + + +def test_is_into_dataframe() -> None: + # `test_plugin` converts to a LazyFrame, so `is_into_dataframe` should not match. + pytest.importorskip("test_plugin") + df_native = {"a": [1, 1, 2], "b": [4, 5, 6]} + for dependencies in DEPENDENCIES_MODULES: + assert not dependencies.is_into_dataframe(df_native) + + +@pytest.mark.parametrize( + ("compliant_cls", "expected_kind"), + [ + (FakeCompliantDataFrame, "dataframe"), + (FakeCompliantLazyFrame, "lazyframe"), + (FakeCompliantSeries, "series"), + ], +) +def test_is_into_mocked_plugin( + monkeypatch: pytest.MonkeyPatch, compliant_cls: type, expected_kind: str +) -> None: + from narwhals import plugins + + monkeypatch.setattr( + plugins, + "_discover_entrypoints", + lambda: (FakeEntryPoint(FakePlugin(compliant_cls)),), + ) + native = FakeNative() + for dependencies in DEPENDENCIES_MODULES: + assert dependencies.is_into_dataframe(native) is (expected_kind == "dataframe") + assert dependencies.is_into_lazyframe(native) is (expected_kind == "lazyframe") + assert dependencies.is_into_series(native) is (expected_kind == "series") + + def test_typing() -> None: pytest.importorskip("test_plugin") import test_plugin