Repository: narwhals-dev/narwhals (python)

Issue:
``narwhals.Series`` repr output mangled for Polars-backed Series
```python
import pandas as pd
import polars as pl
import narwhals as nw


df = nw.from_native(pl.DataFrame({"col1": [None, 2], "col2": [3, 7]}))
s = df['col1']

print(s)
```
┌────────────────────┐
|  Narwhals Series   |
|--------------------|
|shape: (2,)         |
|Series: 'col1' [i64]|
|[                   |
|       null               |
|       2                  |
|]                   |
└────────────────────┘
```

I think we just need to replace tabs with spaces

Pull request description:
fix: fix nw.Series repr for Polars
closes #2452 

<!--
# 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
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below


Patch:
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 97502d59..df5c3408 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -70,6 +70,36 @@ Method: TypeAlias = "Callable[..., R]"
 Where `R` is the return type.
 """
 
+# DataFrame methods where PolarsDataFrame just defers to Polars.DataFrame directly.
+INHERITED_METHODS = frozenset(
+    [
+        "clone",
+        "drop_nulls",
+        "estimated_size",
+        "explode",
+        "filter",
+        "gather_every",
+        "head",
+        "is_unique",
+        "item",
+        "iter_rows",
+        "join_asof",
+        "rename",
+        "row",
+        "rows",
+        "sample",
+        "select",
+        "sort",
+        "tail",
+        "to_arrow",
+        "to_pandas",
+        "unique",
+        "with_columns",
+        "write_csv",
+        "write_parquet",
+    ]
+)
+
 
 class PolarsDataFrame:
     clone: Method[Self]
@@ -221,6 +251,10 @@ class PolarsDataFrame:
         return self._with_native(self.native.tail(n))
 
     def __getattr__(self, attr: str) -> Any:
+        if attr not in INHERITED_METHODS:  # pragma: no cover
+            msg = f"{self.__class__.__name__} has not attribute '{attr}'."
+            raise AttributeError(msg)
+
         def func(*args: Any, **kwargs: Any) -> Any:
             pos, kwds = extract_args_kwargs(args, kwargs)
             try:
@@ -562,6 +596,10 @@ class PolarsLazyFrame:
         )
 
     def __getattr__(self, attr: str) -> Any:
+        if attr not in INHERITED_METHODS:  # pragma: no cover
+            msg = f"{self.__class__.__name__} has not attribute '{attr}'."
+            raise AttributeError(msg)
+
         def func(*args: Any, **kwargs: Any) -> Any:
             pos, kwds = extract_args_kwargs(args, kwargs)
             try:
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 1ade34ca..a63a11bb 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -44,6 +44,82 @@ if TYPE_CHECKING:
     T = TypeVar("T")
 
 
+# Series methods where PolarsSeries just defers to Polars.Series directly.
+INHERITED_METHODS = frozenset(
+    [
+        "__add__",
+        "__and__",
+        "__floordiv__",
+        "__invert__",
+        "__iter__",
+        "__mod__",
+        "__mul__",
+        "__or__",
+        "__pow__",
+        "__radd__",
+        "__rand__",
+        "__rfloordiv__",
+        "__rmod__",
+        "__rmul__",
+        "__ror__",
+        "__rsub__",
+        "__rtruediv__",
+        "__sub__",
+        "__truediv__",
+        "abs",
+        "all",
+        "any",
+        "arg_max",
+        "arg_min",
+        "arg_true",
+        "clip",
+        "count",
+        "cum_max",
+        "cum_min",
+        "cum_prod",
+        "cum_sum",
+        "diff",
+        "drop_nulls",
+        "fill_null",
+        "filter",
+        "gather_every",
+        "head",
+        "is_between",
+        "is_finite",
+        "is_first_distinct",
+        "is_in",
+        "is_last_distinct",
+        "is_null",
+        "is_sorted",
+        "is_unique",
+        "item",
+        "len",
+        "max",
+        "mean",
+        "min",
+        "mode",
+        "n_unique",
+        "null_count",
+        "quantile",
+        "rank",
+        "round",
+        "sample",
+        "shift",
+        "skew",
+        "std",
+        "sum",
+        "tail",
+        "to_arrow",
+        "to_frame",
+        "to_list",
+        "to_pandas",
+        "unique",
+        "var",
+        "zip_with",
+    ]
+)
+
+
 class PolarsSeries:
     def __init__(
         self,
@@ -147,8 +223,9 @@ class PolarsSeries:
         return self.__narwhals_namespace__()._expr._from_series(self)
 
     def __getattr__(self, attr: str) -> Any:
-        if attr == "as_py":  # pragma: no cover
-            raise AttributeError
+        if attr not in INHERITED_METHODS:
+            msg = f"{self.__class__.__name__} has not attribute '{attr}'."
+            raise AttributeError(msg)
 
         def func(*args: Any, **kwargs: Any) -> Any:
             pos, kwds = extract_args_kwargs(args, kwargs)
@@ -228,33 +305,6 @@ class PolarsSeries:
     def __lt__(self, other: Any) -> Self:
         return self._with_native(self.native.__lt__(extract_native(other)))  # pyright: ignore[reportArgumentType]
 
-    def __and__(self, other: PolarsSeries | bool | Any) -> Self:
-        return self._with_native(self.native.__and__(extract_native(other)))
-
-    def __or__(self, other: PolarsSeries | bool | Any) -> Self:
-        return self._with_native(self.native.__or__(extract_native(other)))
-
-    def __add__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__add__(extract_native(other)))
-
-    def __radd__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__radd__(extract_native(other)))
-
-    def __sub__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__sub__(extract_native(other)))
-
-    def __rsub__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__rsub__(extract_native(other)))
-
-    def __mul__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__mul__(extract_native(other)))
-
-    def __rmul__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__rmul__(extract_native(other)))
-
-    def __pow__(self, other: PolarsSeries | Any) -> Self:
-        return self._with_native(self.native.__pow__(extract_native(other)))
-
     def __rpow__(self, other: PolarsSeries | Any) -> Self:
         result = self.native.__rpow__(extract_native(other))
         if self._backend_version < (1, 16, 1):
@@ -262,9 +312,6 @@ class PolarsSeries:
             result = result.alias(self.name)
         return self._with_native(result)
 
-    def __invert__(self) -> Self:
-        return self._with_native(self.native.__invert__())
-
     def is_nan(self) -> Self:
         try:
             native_is_nan = self.native.is_nan()
@@ -576,14 +623,24 @@ class PolarsSeries:
     def struct(self) -> PolarsSeriesStructNamespace:
         return PolarsSeriesStructNamespace(self)
 
-    __iter__: Method[Iterator[Any]]
+    __add__: Method[Self]
+    __and__: Method[Self]
     __floordiv__: Method[Self]
+    __invert__: Method[Self]
+    __iter__: Method[Iterator[Any]]
     __mod__: Method[Self]
+    __mul__: Method[Self]
+    __or__: Method[Self]
+    __pow__: Method[Self]
+    __radd__: Method[Self]
     __rand__: Method[Self]
     __rfloordiv__: Method[Self]
     __rmod__: Method[Self]
+    __rmul__: Method[Self]
     __ror__: Method[Self]
+    __rsub__: Method[Self]
     __rtruediv__: Method[Self]
+    __sub__: Method[Self]
     __truediv__: Method[Self]
     abs: Method[Self]
     all: Method[bool]
diff --git a/narwhals/utils.py b/narwhals/utils.py
index a5985424..092c58fa 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -1511,7 +1511,7 @@ def generate_repr(header: str, native_repr: str) -> str:
         terminal_width = os.get_terminal_size().columns
     except OSError:
         terminal_width = int(os.getenv("COLUMNS", 80))  # noqa: PLW1508
-    native_lines = native_repr.splitlines()
+    native_lines = native_repr.expandtabs().splitlines()
     max_native_width = max(len(line) for line in native_lines)
 
     if max_native_width + 2 <= terminal_width:
diff --git a/pyproject.toml b/pyproject.toml
index 74681ae3..cf65128f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -284,10 +284,11 @@ module = [
   "dask_expr.*",
   "duckdb.*",
   "ibis.*",
+  "joblib.*",
   "modin.*",
   "numpy.*",
-  "sklearn.*",
   "pyspark.*",
+  "sklearn.*",
 ]
 # TODO: remove follow_imports
 follow_imports = "skip"


Test patch:
diff --git a/tests/joblib_test.py b/tests/joblib_test.py
new file mode 100644
index 00000000..c5025dd3
--- /dev/null
+++ b/tests/joblib_test.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+pytest.importorskip("joblib")
+
+from joblib import Parallel
+from joblib import delayed
+
+
+def test_parallelisability(constructor_eager: ConstructorEager) -> None:
+    # https://github.com/narwhals-dev/narwhals/issues/2450
+    def do_something(df: nw.DataFrame[Any]) -> nw.DataFrame[Any]:  # pragma: no cover
+        return df.with_columns(nw.col("col1") * 2)
+
+    dfs = [nw.from_native(constructor_eager({"col1": [0, 2], "col2": [3, 7]}))]
+    result = list(Parallel(n_jobs=-1)(delayed(do_something)(df_) for df_ in dfs))
+    assert len(result) == 1
+    expected = {"col1": [0, 4], "col2": [3, 7]}
+    assert_equal_data(result[0], expected)
+
+
+def test_parallelisability_series(
+    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
+) -> None:
+    # https://github.com/narwhals-dev/narwhals/issues/2450
+    if "modin" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    def do_something(s: nw.Series[Any]) -> nw.Series[Any]:  # pragma: no cover
+        return s * 2
+
+    columns = [
+        nw.from_native(
+            constructor_eager({"col1": [0, 2], "col2": [3, 7]}), eager_only=True
+        )["col1"]
+    ]
+    result = list(
+        Parallel(n_jobs=-1)(delayed(do_something)(column) for column in columns)
+    )
+    assert len(result) == 1
+    assert_equal_data({"col1": result[0]}, {"col1": [0, 4]})
diff --git a/tests/repr_test.py b/tests/repr_test.py
index 3fe5163d..d4a543c5 100644
--- a/tests/repr_test.py
+++ b/tests/repr_test.py
@@ -73,3 +73,25 @@ def test_repr() -> None:
         "└───────────────────────────────────────┘"
     )
     assert result == expected
+
+
+def test_polars_series_repr() -> None:
+    pytest.importorskip("polars")
+    import polars as pl
+
+    df = nw.from_native(pl.DataFrame({"col1": [None, 2], "col2": [3, 7]}))
+    s = df["col1"]
+    result = repr(s)
+    expected = (
+        "┌────────────────────┐\n"
+        "|  Narwhals Series   |\n"
+        "|--------------------|\n"
+        "|shape: (2,)         |\n"
+        "|Series: 'col1' [i64]|\n"
+        "|[                   |\n"
+        "|        null        |\n"
+        "|        2           |\n"
+        "|]                   |\n"
+        "└────────────────────┘"
+    )
+    assert result == expected