Repository: narwhals-dev/narwhals
Pull request #406 (2024-07-04 18:03:45)

feat: add `clone` for dataframes
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

 Mentioned in #355 

## 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.

I am not very familiar with pyarrow Dataframes. What could be a clean way to implement copy/clone?
I added `NotImplementedError` as a placeholder, if we keep the error I will add a test

EdAbati: (BTW the test are looking very nice now with the new `pytest.fixture`s)

Patch:
diff --git a/docs/api-reference/dataframe.md b/docs/api-reference/dataframe.md
index 67520527..a0003729 100644
--- a/docs/api-reference/dataframe.md
+++ b/docs/api-reference/dataframe.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - clone
         - columns
         - drop
         - drop_nulls
diff --git a/docs/api-reference/lazyframe.md b/docs/api-reference/lazyframe.md
index 96e1a3ff..8869e5aa 100644
--- a/docs/api-reference/lazyframe.md
+++ b/docs/api-reference/lazyframe.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - clone
         - collect
         - columns
         - drop
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 6c9c390d..e8dd7dab 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -151,3 +151,6 @@ class ArrowDataFrame:
 
     def collect(self) -> ArrowDataFrame:
         return ArrowDataFrame(self._dataframe)
+
+    def clone(self) -> Self:
+        raise NotImplementedError("clone is not yet supported on PyArrow tables")
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 443e2add..a38f1bd4 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -452,3 +452,6 @@ class PandasDataFrame:
 
         _col = self.columns.index(column) if isinstance(column, str) else column
         return self._dataframe.iat[row, _col]
+
+    def clone(self: Self) -> Self:
+        return self._from_dataframe(self._dataframe.copy())
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 239fcd2c..399b61cf 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -198,6 +198,9 @@ class BaseFrame:
             )
         )
 
+    def clone(self) -> Self:
+        return self._from_dataframe(self._dataframe.clone())
+
 
 class DataFrame(BaseFrame):
     """
@@ -1732,6 +1735,42 @@ class DataFrame(BaseFrame):
         """
         return self._dataframe.item(row=row, column=column)
 
+    def clone(self) -> Self:
+        r"""
+        Create a copy of this DataFrame.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {"a": [1, 2], "b": [3, 4]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a dataframe-agnostic function in which we clone the DataFrame:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.clone()
+
+            >>> func(df_pd)
+               a  b
+            0  1  3
+            1  2  4
+
+            >>> func(df_pl)
+            shape: (2, 2)
+            ┌─────┬─────┐
+            │ a   ┆ b   │
+            │ --- ┆ --- │
+            │ i64 ┆ i64 │
+            ╞═════╪═════╡
+            │ 1   ┆ 3   │
+            │ 2   ┆ 4   │
+            └─────┴─────┘
+        """
+        return super().clone()
+
 
 class LazyFrame(BaseFrame):
     """
@@ -2889,3 +2928,38 @@ class LazyFrame(BaseFrame):
             └─────┴─────┴─────┴───────┘
         """
         return super().join(other, how=how, left_on=left_on, right_on=right_on)
+
+    def clone(self) -> Self:
+        r"""
+        Create a copy of this DataFrame.
+
+        >>> import narwhals as nw
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> data = {"a": [1, 2], "b": [3, 4]}
+        >>> df_pd = pd.DataFrame(data)
+        >>> df_pl = pl.LazyFrame(data)
+
+        Let's define a dataframe-agnostic function in which we copy the DataFrame:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.clone()
+
+        >>> func(df_pd)
+           a  b
+        0  1  3
+        1  2  4
+
+        >>> func(df_pl).collect()
+        shape: (2, 2)
+        ┌─────┬─────┐
+        │ a   ┆ b   │
+        │ --- ┆ --- │
+        │ i64 ┆ i64 │
+        ╞═════╪═════╡
+        │ 1   ┆ 3   │
+        │ 2   ┆ 4   │
+        └─────┴─────┘
+        """
+        return super().clone()


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9b7fc942..5b71cdc0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -52,6 +52,10 @@ def polars_constructor(obj: Any) -> IntoDataFrame:
     return pl.DataFrame(obj)
 
 
+def polars_lazy_constructor(obj: Any) -> pl.LazyFrame:
+    return pl.LazyFrame(obj)
+
+
 if parse_version(pd.__version__) >= parse_version("2.0.0"):
     params = [pandas_constructor, pandas_nullable_constructor, pandas_pyarrow_constructor]
 else:  # pragma: no cover
@@ -66,6 +70,11 @@ def constructor(request: Any) -> Callable[[Any], IntoDataFrame]:
     return request.param  # type: ignore[no-any-return]
 
 
+@pytest.fixture(params=[*params, polars_lazy_constructor])
+def constructor_with_lazy(request: Any) -> Callable[[Any], Any]:
+    return request.param  # type: ignore[no-any-return]
+
+
 # TODO: once pyarrow has complete coverage, we can remove this one,
 # and just put `pa.table` into `constructor`
 @pytest.fixture(params=[*params, pa.table])
diff --git a/tests/frame/clone_test.py b/tests/frame/clone_test.py
new file mode 100644
index 00000000..9d983506
--- /dev/null
+++ b/tests/frame/clone_test.py
@@ -0,0 +1,13 @@
+from typing import Any
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+
+def test_clone(constructor_with_lazy: Any) -> None:
+    expected = {"a": [1, 2], "b": [3, 4]}
+    df = nw.from_native(constructor_with_lazy(expected))
+    df_clone = df.clone()
+    assert df is not df_clone
+    assert df._dataframe is not df_clone._dataframe
+    compare_dicts(df_clone, expected)