Repository: narwhals-dev/narwhals
Pull request #363 (2024-06-30 17:51:24)

Use enums for implementations
## What type of PR is this? (check all applicable)

- [X] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Checklist

- [X] Code follows style guide (ruff)
- [N/A] Tests added 
- [N/A] Documented the changes

## Rationale

By using enums instead of strings we achieve:

1. Single source of truth: now to add a new backend, one only needs to modify the `narwhals/dependencies.py` file
2. Simplify contribution: by having enums in a single location and using `assert_never`, once a new value is added to the Enum, MyPy will flag all the places where modifications are needed, this is much easier to navigate than finding failed tests
3. For multi-backend implementations (such as pandas), a file called `implementations.py` is added showing the subset of implementations supported for that specific platform
4. Reduce boilerplate: with Enums and a couple of mappings, there is no longer a need for many if/elif/else logic, thus making the code easier to extend. There are several cases not yet addressed in this PR but now the only function used from `dependencies` is the `get_backend` rather than the individual `get_pandas`, etc.
5. Less error prone: by using enums, MyPy becomes much more helpful by actually detecting missing cases in the logic when new backends are added.

## Open questions:

This PR assumes `typing_extensions` is installed, this should be a relatively common case as most projects use this library, however, to keep narwhals dependency free, we would need to add `assert_never` to the utils. 

```python
import enum
from typing_extensions import NoReturn

def assert_never(arg: NoReturn) -> NoReturn:
    raise AssertionError("Expected code to be unreachable")
```
[Source](https://typing.readthedocs.io/en/latest/source/unreachable.html#assert-never-and-exhaustiveness-checking)

The `assert_never` and the `Never` type are included in Python 3.11 so this functionality should be explicitly made available to keep the library compatible with 3.8+

An interesting read on this topic is the following article by [Adam Johnson](https://adamj.eu/tech/2022/10/14/python-type-hints-exhuastiveness-checking/)

---

Would like to hear your thoughts on these changes @MarcoGorelli / @EdAbati 



ELC: @MarcoGorelli - This should be now ready to merge, however, there is a file without 100% coverage (not related to this PR):

![image](https://github.com/narwhals-dev/narwhals/assets/14986783/a20deac3-eb61-47e9-ba63-c99ed3a78323)

ELC: All the tests are passing now, however seems like there is some coverage issues in my local setup using ubuntu latest on a Github Codespace.

This is the main branch:
![image](https://github.com/narwhals-dev/narwhals/assets/14986783/b744be3f-c897-4db7-8953-70d52c5c3e93)

![image](https://github.com/narwhals-dev/narwhals/assets/14986783/4d6dbe8e-26a7-4eff-bf06-46d6b94cc340)

This is related to the recently merged cross join PR (#347)

Could we merge this anyway or should we wait until the coverage is 100% in main?
MarcoGorelli: > Could we merge this anyway or should we wait until the coverage is 100% in main?

sorry about that - it's 100% in CI, but some tests don't run locally. I've addressed that in https://github.com/narwhals-dev/narwhals/pull/379
ELC: I will resolve the conflicts later today. They all are about the same theme, the files need to use the `get_backend` facade instead of the concrete `get_*`
ELC: Finished with all modifications, tests are passing with 100% coverage and I simplify some of the functions as well to leverage the enums.

Let me know your thoughts

@MarcoGorelli / @EdAbati 
ELC: This then becomes a design question, I can revert back to continue exposing the `get_*` functions but internally we should only use the `get_backend`

The value of narwhals is to provide an abstraction over the dataframe engine, if one can access individual engines, it is leaking the abstraction. 

For the internal package use case, adding a new engine should not require changing if/elif/else in many places, there should be abstractions in place so that only one place (like `dependencies.py`) needs to be modified, in addition to the concrete folder of that implementation. 

If a consumer code needs a particular engine, they should import it directly, not through narwhals, specially because narwhals cannot guarantee the engine will be available.

I don't know exactly what is the vision but I assumed that someone using narwhals will like to remove most of the engine specifics and just use the engine for I/O and narwhals for expressions, hence using something like `get_pandas` from consumer code seemed unexpected to me
ELC: @MarcoGorelli is this ready to merge or are there other changes you would like to include? 
MarcoGorelli: thanks for your pr 🙏  there's some changes I agree with, others not so much - I'll add a commit later before merging

Patch:
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index b93b19b4..aade22c2 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -11,6 +11,7 @@ from typing import overload
 
 from narwhals._expression_parsing import evaluate_into_exprs
 from narwhals._pandas_like.expr import PandasExpr
+from narwhals._pandas_like.utils import Implementation
 from narwhals._pandas_like.utils import create_native_series
 from narwhals._pandas_like.utils import generate_unique_token
 from narwhals._pandas_like.utils import horizontal_concat
@@ -39,7 +40,7 @@ class PandasDataFrame:
         self,
         native_dataframe: Any,
         *,
-        implementation: str,
+        implementation: Implementation,
         backend_version: tuple[int, ...],
     ) -> None:
         self._validate_columns(native_dataframe.columns)
@@ -59,11 +60,11 @@ class PandasDataFrame:
         return PandasNamespace(self._implementation, self._backend_version)
 
     def __native_namespace__(self) -> Any:
-        if self._implementation == "pandas":
+        if self._implementation is Implementation.PANDAS:
             return get_pandas()
-        if self._implementation == "modin":  # pragma: no cover
+        if self._implementation is Implementation.MODIN:  # pragma: no cover
             return get_modin()
-        if self._implementation == "cudf":  # pragma: no cover
+        if self._implementation is Implementation.CUDF:  # pragma: no cover
             return get_cudf()
         msg = f"Expected pandas/modin/cudf, got: {type(self._implementation)}"  # pragma: no cover
         raise AssertionError(msg)
@@ -304,8 +305,12 @@ class PandasDataFrame:
             right_on = [right_on]
 
         if how == "cross":
-            if self._implementation in {"modin", "cudf"} or (
-                self._implementation == "pandas" and self._backend_version < (1, 4)
+            if (
+                self._implementation is Implementation.MODIN
+                or self._implementation is Implementation.CUDF
+            ) or (
+                self._implementation is Implementation.PANDAS
+                and self._backend_version < (1, 4)
             ):
                 key_token = generate_unique_token(
                     n_bytes=8, columns=[*self.columns, *other.columns]
@@ -416,9 +421,9 @@ class PandasDataFrame:
         return self._native_dataframe.to_numpy()
 
     def to_pandas(self) -> Any:
-        if self._implementation == "pandas":
+        if self._implementation is Implementation.PANDAS:
             return self._native_dataframe
-        if self._implementation == "modin":  # pragma: no cover
+        if self._implementation is Implementation.MODIN:  # pragma: no cover
             return self._native_dataframe._to_pandas()
         return self._native_dataframe.to_pandas()  # pragma: no cover
 
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 29f43259..80f54949 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
 
     from narwhals._pandas_like.dataframe import PandasDataFrame
     from narwhals._pandas_like.namespace import PandasNamespace
+    from narwhals._pandas_like.utils import Implementation
 
 
 class PandasExpr:
@@ -25,7 +26,7 @@ class PandasExpr:
         function_name: str,
         root_names: list[str] | None,
         output_names: list[str] | None,
-        implementation: str,
+        implementation: Implementation,
         backend_version: tuple[int, ...],
     ) -> None:
         self._call = call
@@ -57,7 +58,7 @@ class PandasExpr:
     def from_column_names(
         cls: type[Self],
         *column_names: str,
-        implementation: str,
+        implementation: Implementation,
         backend_version: tuple[int, ...],
     ) -> Self:
         def func(df: PandasDataFrame) -> list[PandasSeries]:
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 3b6136cf..ddc25686 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -9,6 +9,7 @@ from typing import Callable
 from typing import Iterator
 
 from narwhals._expression_parsing import parse_into_exprs
+from narwhals._pandas_like.utils import Implementation
 from narwhals._pandas_like.utils import is_simple_aggregation
 from narwhals._pandas_like.utils import native_series_from_iterable
 from narwhals.utils import remove_prefix
@@ -43,7 +44,7 @@ class PandasGroupBy:
             namespace=self._df.__narwhals_namespace__(),
             **named_aggs,
         )
-        implementation: str = self._df._implementation
+        implementation: Implementation = self._df._implementation
         output_names: list[str] = copy(self._keys)
         for expr in exprs:
             if expr._output_names is None:
@@ -189,7 +190,7 @@ def agg_pandas(  # noqa: PLR0913
             implementation=implementation,
         )
 
-    if implementation == "pandas" and backend_version >= (2, 2):
+    if implementation is Implementation.PANDAS and backend_version >= (2, 2):
         result_complex = grouped.apply(func, include_groups=False)
     else:  # pragma: no cover
         result_complex = grouped.apply(func)
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 02d0433f..7028b9b3 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -19,6 +19,7 @@ from narwhals.utils import flatten
 
 if TYPE_CHECKING:
     from narwhals._pandas_like.typing import IntoPandasExpr
+    from narwhals._pandas_like.utils import Implementation
 
 
 class PandasNamespace:
@@ -49,7 +50,9 @@ class PandasNamespace:
         )
 
     # --- not in spec ---
-    def __init__(self, implementation: str, backend_version: tuple[int, ...]) -> None:
+    def __init__(
+        self, implementation: Implementation, backend_version: tuple[int, ...]
+    ) -> None:
         self._implementation = implementation
         self._backend_version = backend_version
 
diff --git a/narwhals/_pandas_like/selectors.py b/narwhals/_pandas_like/selectors.py
index a78f54ca..fc72e28a 100644
--- a/narwhals/_pandas_like/selectors.py
+++ b/narwhals/_pandas_like/selectors.py
@@ -10,11 +10,14 @@ from narwhals._pandas_like.expr import PandasExpr
 if TYPE_CHECKING:
     from narwhals._pandas_like.dataframe import PandasDataFrame
     from narwhals._pandas_like.series import PandasSeries
+    from narwhals._pandas_like.utils import Implementation
     from narwhals.dtypes import DType
 
 
 class PandasSelectorNamespace:
-    def __init__(self, *, implementation: str, backend_version: tuple[int, ...]) -> None:
+    def __init__(
+        self, *, implementation: Implementation, backend_version: tuple[int, ...]
+    ) -> None:
         self._implementation = implementation
         self._backend_version = backend_version
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 40082c5f..486ee072 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -6,6 +6,7 @@ from typing import Iterable
 from typing import Literal
 from typing import Sequence
 
+from narwhals._pandas_like.utils import Implementation
 from narwhals._pandas_like.utils import int_dtype_mapper
 from narwhals._pandas_like.utils import native_series_from_iterable
 from narwhals._pandas_like.utils import reverse_translate_dtype
@@ -74,7 +75,7 @@ class PandasSeries:
         self,
         native_series: Any,
         *,
-        implementation: str,
+        implementation: Implementation,
         backend_version: tuple[int, ...],
     ) -> None:
         self._name = native_series.name
@@ -85,7 +86,11 @@ class PandasSeries:
         # In pandas, copy-on-write becomes the default in version 3.
         # So, before that, we need to explicitly avoid unnecessary
         # copies by using `copy=False` sometimes.
-        if self._implementation == "pandas" and self._backend_version < (3, 0, 0):
+        if self._implementation is Implementation.PANDAS and self._backend_version < (
+            3,
+            0,
+            0,
+        ):
             self._use_copy_false = True
         else:
             self._use_copy_false = False
@@ -96,11 +101,11 @@ class PandasSeries:
         return PandasNamespace(self._implementation, self._backend_version)
 
     def __native_namespace__(self) -> Any:
-        if self._implementation == "pandas":
+        if self._implementation is Implementation.PANDAS:
             return get_pandas()
-        if self._implementation == "modin":  # pragma: no cover
+        if self._implementation is Implementation.MODIN:  # pragma: no cover
             return get_modin()
-        if self._implementation == "cudf":  # pragma: no cover
+        if self._implementation is Implementation.CUDF:  # pragma: no cover
             return get_cudf()
         msg = f"Expected pandas/modin/cudf, got: {type(self._implementation)}"  # pragma: no cover
         raise AssertionError(msg)
@@ -132,7 +137,7 @@ class PandasSeries:
         name: str,
         index: Any,
         *,
-        implementation: str,
+        implementation: Implementation,
         backend_version: tuple[int, ...],
     ) -> Self:
         return cls(
@@ -465,7 +470,7 @@ class PandasSeries:
             has_missing
             and str(self._native_series.dtype) in PANDAS_TO_NUMPY_DTYPE_MISSING
         ):
-            if self._implementation == "pandas" and self._backend_version < (
+            if self._implementation is Implementation.PANDAS and self._backend_version < (
                 1,
             ):  # pragma: no cover
                 kwargs = {}
@@ -489,11 +494,11 @@ class PandasSeries:
         return self._native_series.to_numpy(dtype=dtype, copy=copy)
 
     def to_pandas(self) -> Any:
-        if self._implementation == "pandas":
+        if self._implementation is Implementation.PANDAS:
             return self._native_series
-        elif self._implementation == "cudf":  # pragma: no cover
+        elif self._implementation is Implementation.CUDF:  # pragma: no cover
             return self._native_series.to_pandas()
-        elif self._implementation == "modin":  # pragma: no cover
+        elif self._implementation is Implementation.MODIN:  # pragma: no cover
             return self._native_series._to_pandas()
         msg = f"Unknown implementation: {self._implementation}"  # pragma: no cover
         raise AssertionError(msg)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index ab5f237c..32ceeab6 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
 import secrets
+from enum import Enum
+from enum import auto
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -21,6 +23,12 @@ if TYPE_CHECKING:
     ExprT = TypeVar("ExprT", bound=PandasExpr)
 
 
+class Implementation(Enum):
+    PANDAS = auto()
+    MODIN = auto()
+    CUDF = auto()
+
+
 def validate_column_comparand(index: Any, other: Any) -> Any:
     """Validate RHS of binary operation.
 
@@ -86,18 +94,18 @@ def create_native_series(
     iterable: Any,
     index: Any = None,
     *,
-    implementation: str,
+    implementation: Implementation,
     backend_version: tuple[int, ...],
 ) -> PandasSeries:
     from narwhals._pandas_like.series import PandasSeries
 
-    if implementation == "pandas":
+    if implementation is Implementation.PANDAS:
         pd = get_pandas()
         series = pd.Series(iterable, index=index, name="")
-    elif implementation == "modin":
+    elif implementation is Implementation.MODIN:
         mpd = get_modin()
         series = mpd.Series(iterable, index=index, name="")
-    elif implementation == "cudf":
+    elif implementation is Implementation.CUDF:
         cudf = get_cudf()
         series = cudf.Series(iterable, index=index, name="")
     return PandasSeries(
@@ -123,24 +131,24 @@ def is_simple_aggregation(expr: PandasExpr) -> bool:
 
 
 def horizontal_concat(
-    dfs: list[Any], *, implementation: str, backend_version: tuple[int, ...]
+    dfs: list[Any], *, implementation: Implementation, backend_version: tuple[int, ...]
 ) -> Any:
     """
     Concatenate (native) DataFrames horizontally.
 
     Should be in namespace.
     """
-    if implementation == "pandas":
+    if implementation is Implementation.PANDAS:
         pd = get_pandas()
 
         if backend_version < (3,):
             return pd.concat(dfs, axis=1, copy=False)
         return pd.concat(dfs, axis=1)  # pragma: no cover
-    if implementation == "cudf":  # pragma: no cover
+    if implementation is Implementation.CUDF:  # pragma: no cover
         cudf = get_cudf()
 
         return cudf.concat(dfs, axis=1)
-    if implementation == "modin":  # pragma: no cover
+    if implementation is Implementation.MODIN:  # pragma: no cover
         mpd = get_modin()
 
         return mpd.concat(dfs, axis=1)
@@ -149,7 +157,7 @@ def horizontal_concat(
 
 
 def vertical_concat(
-    dfs: list[Any], *, implementation: str, backend_version: tuple[int, ...]
+    dfs: list[Any], *, implementation: Implementation, backend_version: tuple[int, ...]
 ) -> Any:
     """
     Concatenate (native) DataFrames vertically.
@@ -165,17 +173,17 @@ def vertical_concat(
         if cols_current != cols:
             msg = "unable to vstack, column names don't match"
             raise TypeError(msg)
-    if implementation == "pandas":
+    if implementation is Implementation.PANDAS:
         pd = get_pandas()
 
         if backend_version < (3,):
             return pd.concat(dfs, axis=0, copy=False)
         return pd.concat(dfs, axis=0)  # pragma: no cover
-    if implementation == "cudf":  # pragma: no cover
+    if implementation is Implementation.CUDF:  # pragma: no cover
         cudf = get_cudf()
 
         return cudf.concat(dfs, axis=0)
-    if implementation == "modin":  # pragma: no cover
+    if implementation is Implementation.MODIN:  # pragma: no cover
         mpd = get_modin()
 
         return mpd.concat(dfs, axis=0)
@@ -187,18 +195,18 @@ def native_series_from_iterable(
     data: Iterable[Any],
     name: str,
     index: Any,
-    implementation: str,
+    implementation: Implementation,
 ) -> Any:
     """Return native series."""
-    if implementation == "pandas":
+    if implementation is Implementation.PANDAS:
         pd = get_pandas()
 
         return pd.Series(data, name=name, index=index, copy=False)
-    if implementation == "cudf":  # pragma: no cover
+    if implementation is Implementation.CUDF:  # pragma: no cover
         cudf = get_cudf()
 
         return cudf.Series(data, name=name, index=index)
-    if implementation == "modin":  # pragma: no cover
+    if implementation is Implementation.MODIN:  # pragma: no cover
         mpd = get_modin()
 
         return mpd.Series(data, name=name, index=index)
@@ -207,13 +215,22 @@ def native_series_from_iterable(
 
 
 def set_axis(
-    obj: T, index: Any, *, implementation: str, backend_version: tuple[int, ...]
+    obj: T,
+    index: Any,
+    *,
+    implementation: Implementation,
+    backend_version: tuple[int, ...],
 ) -> T:
-    if implementation == "pandas" and backend_version < (1,):  # pragma: no cover
+    if implementation is Implementation.PANDAS and backend_version < (
+        1,
+    ):  # pragma: no cover
         kwargs = {"inplace": False}
     else:
         kwargs = {}
-    if implementation == "pandas" and backend_version >= (1, 5):  # pragma: no cover
+    if implementation is Implementation.PANDAS and backend_version >= (
+        1,
+        5,
+    ):  # pragma: no cover
         kwargs["copy"] = False
     else:  # pragma: no cover
         pass
@@ -292,8 +309,8 @@ def translate_dtype(column: Any) -> DType:
     return dtypes.Unknown()
 
 
-def get_dtype_backend(dtype: Any, implementation: str) -> str:
-    if implementation == "pandas":
+def get_dtype_backend(dtype: Any, implementation: Implementation) -> str:
+    if implementation is Implementation.PANDAS:
         pd = get_pandas()
         if hasattr(pd, "ArrowDtype") and isinstance(dtype, pd.ArrowDtype):
             return "pyarrow-nullable"
@@ -310,7 +327,7 @@ def get_dtype_backend(dtype: Any, implementation: str) -> str:
 
 
 def reverse_translate_dtype(  # noqa: PLR0915
-    dtype: DType | type[DType], starting_dtype: Any, implementation: str
+    dtype: DType | type[DType], starting_dtype: Any, implementation: Implementation
 ) -> Any:
     from narwhals import dtypes
 
@@ -441,12 +458,12 @@ def validate_indices(series: list[PandasSeries]) -> list[Any]:
     return reindexed
 
 
-def to_datetime(implementation: str) -> Any:
-    if implementation == "pandas":
+def to_datetime(implementation: Implementation) -> Any:
+    if implementation is Implementation.PANDAS:
         return get_pandas().to_datetime
-    if implementation == "modin":
+    if implementation is Implementation.MODIN:
         return get_modin().to_datetime
-    if implementation == "cudf":
+    if implementation is Implementation.CUDF:
         return get_cudf().to_datetime
     raise AssertionError
 
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 0683668b..0481aa15 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -243,6 +243,7 @@ def from_native(  # noqa: PLR0915
     from narwhals._arrow.series import ArrowSeries
     from narwhals._pandas_like.dataframe import PandasDataFrame
     from narwhals._pandas_like.series import PandasSeries
+    from narwhals._pandas_like.utils import Implementation
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.series import Series
@@ -277,7 +278,7 @@ def from_native(  # noqa: PLR0915
             PandasDataFrame(
                 native_dataframe,
                 backend_version=parse_version(pd.__version__),
-                implementation="pandas",
+                implementation=Implementation.PANDAS,
             ),
             is_polars=False,
             backend_version=parse_version(pd.__version__),
@@ -290,7 +291,7 @@ def from_native(  # noqa: PLR0915
         return DataFrame(
             PandasDataFrame(
                 native_dataframe,
-                implementation="modin",
+                implementation=Implementation.MODIN,
                 backend_version=parse_version(mpd.__version__),
             ),
             is_polars=False,
@@ -304,7 +305,7 @@ def from_native(  # noqa: PLR0915
         return DataFrame(
             PandasDataFrame(
                 native_dataframe,
-                implementation="cudf",
+                implementation=Implementation.CUDF,
                 backend_version=parse_version(cudf.__version__),
             ),
             is_polars=False,
@@ -354,7 +355,7 @@ def from_native(  # noqa: PLR0915
         return Series(
             PandasSeries(
                 native_dataframe,
-                implementation="pandas",
+                implementation=Implementation.PANDAS,
                 backend_version=parse_version(pd.__version__),
             ),
             is_polars=False,
@@ -368,7 +369,7 @@ def from_native(  # noqa: PLR0915
         return Series(
             PandasSeries(
                 native_dataframe,
-                implementation="modin",
+                implementation=Implementation.MODIN,
                 backend_version=parse_version(mpd.__version__),
             ),
             is_polars=False,
@@ -382,7 +383,7 @@ def from_native(  # noqa: PLR0915
         return Series(
             PandasSeries(
                 native_dataframe,
-                implementation="cudf",
+                implementation=Implementation.CUDF,
                 backend_version=parse_version(cudf.__version__),
             ),
             is_polars=False,
diff --git a/pyproject.toml b/pyproject.toml
index da328c08..3b2a5a47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -102,8 +102,8 @@ omit = ['narwhals/typing.py']
 exclude_also = [
   "> POLARS_VERSION",
   "if sys.version_info() <",
-  "if implementation == \"modin\"",
-  "if implementation == \"cudf\"",
+  "if implementation is Implementation.MODIN",
+  "if implementation is Implementation.CUDF",
   'request.applymarker\(pytest.mark.xfail\)'
 ]
 


Test patch:
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 58ab5ab4..df43d7b2 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -13,6 +13,7 @@ import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals._pandas_like.utils import Implementation
 from narwhals.functions import _get_deps_info
 from narwhals.functions import _get_sys_info
 from narwhals.functions import show_versions
@@ -172,7 +173,7 @@ def test_cross_join(df_raw: Any) -> None:
 def test_cross_join_non_pandas() -> None:
     df = nw.from_native(df_pandas).select("a")
     # HACK to force testing for a non-pandas codepath
-    df._dataframe._implementation = "modin"
+    df._dataframe._implementation = Implementation.MODIN
     result = df.join(df, how="cross")  # type: ignore[arg-type]
     expected = {"a": [1, 1, 1, 3, 3, 3, 2, 2, 2], "a_right": [1, 3, 2, 1, 3, 2, 1, 3, 2]}
     compare_dicts(result, expected)