Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3581985
enh nw.Enum to accept categories
camriddell Dec 11, 2024
69e9d88
add tests for nw.Enum(categories)
camriddell Mar 11, 2025
9e63e68
fix enum type checking for Enum dtype
camriddell Mar 11, 2025
3465fbf
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Mar 11, 2025
a570751
fix enum doctest
camriddell Mar 12, 2025
6f22771
positive check for enum instance for pyright
camriddell Mar 12, 2025
04b2ee6
Merge branch 'main' into enh-enum-creation
camriddell Mar 12, 2025
1393ac7
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Mar 27, 2025
6e85c1a
enum use implementation specific CategoricalDtype
camriddell Mar 31, 2025
70d5c67
enum preserve v1 behavior
camriddell Mar 31, 2025
e4f2d87
preserve v1 polars enum conversion
camriddell Mar 31, 2025
86eb3a2
add Enum support to dask
camriddell Apr 4, 2025
3e1db9e
modin to xfail on Enum dtype
camriddell Apr 4, 2025
5a996b1
Merge remote-tracking branch 'upstream/main' into enh-enum-creation
camriddell Apr 4, 2025
1ce56ca
Enum support outside of V1
camriddell Apr 4, 2025
a8f6e42
Fix v1 enum missing argument teset
camriddell Apr 4, 2025
342ea83
fix enum error match for py38
camriddell Apr 4, 2025
5dd7c72
add pragma: no cover to v1.Enum from aligned with DType class
camriddell Apr 4, 2025
1bf98d0
parametrize api versions for dtypes tests
camriddell Apr 4, 2025
da1a455
decouple narwhals versioned dtypes
camriddell Apr 4, 2025
a79554a
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Apr 8, 2025
0712557
enum types to pass tests
camriddell Apr 9, 2025
4fed6d1
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Apr 9, 2025
f06df25
fix: pyright warning on incorrect line for Enum
camriddell Apr 9, 2025
a0a8c39
test(typing): Fix redefinition
dangotbanned Apr 9, 2025
21d03dd
test: Add `test_enum_v1_is_enum_unstable`
dangotbanned Apr 9, 2025
36fd178
Merge branch 'main' into enh-enum-creation
dangotbanned Apr 9, 2025
105e394
fix: Get more of `__eq__` working
dangotbanned Apr 9, 2025
0d5b5cc
fix: Correct subtyping for `__eq__`
dangotbanned Apr 9, 2025
02b60e0
chore: Coverage
dangotbanned Apr 9, 2025
07d4a25
Merge remote-tracking branch 'upstream/main' into enh-enum-creation
MarcoGorelli Apr 13, 2025
5e53281
list -> tuple
MarcoGorelli Apr 13, 2025
0018a5b
use str in annotation but at runtime dont raise for hashable (pandas..)
MarcoGorelli Apr 13, 2025
99ad2b5
improve error message
MarcoGorelli Apr 13, 2025
000013c
move another test to v1test
MarcoGorelli Apr 13, 2025
6aed0db
update backcompat doc
MarcoGorelli Apr 13, 2025
3357c55
coverage
MarcoGorelli Apr 13, 2025
3b40237
test: fix doctest
dangotbanned Apr 13, 2025
15cac8e
Merge remote-tracking branch 'upstream/main' into pr/camriddell/2192
dangotbanned Apr 14, 2025
84ea789
test: Add `test_enum_from_series`
dangotbanned Apr 14, 2025
833bc3c
enum add non-string and duplicate checking
camriddell Apr 16, 2025
7aca1b8
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Apr 16, 2025
eb0b328
Merge branch 'main' into enh-enum-creation
dangotbanned Apr 16, 2025
d2504a4
Update docs/backcompat.md
camriddell Apr 16, 2025
def83a3
fix(typing): Resolve some `Enum.categories` issues
dangotbanned Apr 17, 2025
f89e331
refactor: Simplify `__init__`, raise earlier
dangotbanned Apr 17, 2025
3902a29
remove enum duplication/null checks
camriddell Apr 18, 2025
1e3326f
Merge branch 'main' of github.com:narwhals-dev/narwhals into enh-enum…
camriddell Apr 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions narwhals/_pandas_like/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ def rename(


@functools.lru_cache(maxsize=16)
def non_object_native_to_narwhals_dtype(dtype: str, version: Version) -> DType:
def non_object_native_to_narwhals_dtype(native_dtype: Any, version: Version) -> DType:
dtype = str(native_dtype)

Comment on lines +214 to +216

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change seems to have been there since the first commit (3581985), but doesn't seem to be documented?

It looks like this part is related

https://github.com/camriddell/narwhals/blob/d2504a40efc606d8e626a5b9049ff8054417d64c/narwhals/_pandas_like/utils.py#L320-L321

Which would mean we do the str(...) call twice now. Just an observation, not sure if there is a cost to that

https://github.com/camriddell/narwhals/blob/d2504a40efc606d8e626a5b9049ff8054417d64c/narwhals/_pandas_like/utils.py#L306-L309

Are all non-object pandas data types guaranteed to be immutable?
I think str was used because it is hashable, so is safe to use in functools.lru_cache

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which would mean we do the str(...) call twice now. Just an observation, not sure if there is a cost to that

I think the cost of a repeated call to str should be fairly negligible, we can always come back later to refactor if a profiler disagrees with this statement and this leads to a larger overhead.

Are all non-object pandas data types guaranteed to be immutable?
I think str was used because it is hashable, so is safe to use in functools.lru_cache

Since the tests pass, I am at least confident that all of the datatypes are hashable, however whether that hash is something meaningful or just the default id(self) / 16 then caching may not be reliable. That said, perhaps we can also leave as is for now, then if we catch wind of a slow down in the future we can revisit it? Trying to avoid the pre-mature optimization scenarios here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@camriddell agreed on the str part.

My concern on the hashability though is related to #2051 (comment)

Right now we won't get a warning like that because we have:

native_dtype: Any

However - good news!
I changed it to this locally:

@functools.lru_cache(maxsize=16)
def non_object_native_to_narwhals_dtype(
    native_dtype: pd.api.extensions.ExtensionDtype, version: Version
) -> DType:

And followed though to the docs to find:

ExtensionDtypes are required to be hashable. The base class provides

Looks like we're all good πŸ™‚

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great find on that one, thanks so much for diving in there!

dtypes = import_dtypes_module(version)
if dtype in {"int64", "Int64", "Int64[pyarrow]", "int64[pyarrow]"}:
return dtypes.Int64()
Expand Down Expand Up @@ -352,7 +354,11 @@ def non_object_native_to_narwhals_dtype(dtype: str, version: Version) -> DType:
return dtypes.String()
if dtype in {"bool", "boolean", "boolean[pyarrow]", "bool[pyarrow]"}:
return dtypes.Boolean()
if dtype == "category" or dtype.startswith("dictionary<"):
if dtype.startswith("dictionary<"):
return dtypes.Categorical()
if dtype == "category":
if native_dtype.ordered:
return dtypes.Enum(categories=native_dtype.categories)
return dtypes.Categorical()
Comment on lines +359 to 261

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would be a breaking change, so i'm not totally sure about it - could we preserve the current behaviour in v1 and only make this change in the main namespace? the version variable is available in this function, you can use that

if (match_ := PATTERN_PD_DATETIME.match(dtype)) or (
match_ := PATTERN_PA_DATETIME.match(dtype)
Expand Down Expand Up @@ -413,7 +419,7 @@ def native_to_narwhals_dtype(
return arrow_native_to_narwhals_dtype(native_dtype.to_arrow(), version)
return arrow_native_to_narwhals_dtype(native_dtype.pyarrow_dtype, version)
if str_dtype != "object":
return non_object_native_to_narwhals_dtype(str_dtype, version)
return non_object_native_to_narwhals_dtype(native_dtype, version)
elif implementation is Implementation.DASK:
# Per conversations with their maintainers, they don't support arbitrary
# objects, so we can just return String.
Expand Down Expand Up @@ -569,8 +575,17 @@ def narwhals_to_native_dtype( # noqa: PLR0915
msg = "PyArrow>=11.0.0 is required for `Date` dtype."
return "date32[pyarrow]"
if isinstance_or_issubclass(dtype, dtypes.Enum):
msg = "Converting to Enum is not (yet) supported"
raise NotImplementedError(msg)
if isinstance(dtype, dtypes.Enum):
try:
import pandas as pd # ignore-banned-import
except ImportError as exc: # pragma: no cover
msg = f"Unable to convert to {dtype} to to the following exception: {exc.msg}"
raise ImportError(msg) from exc
return pd.CategoricalDtype(categories=dtype.categories, ordered=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if we can do something pandas-specific here, as this is used by cudf and modin too - could we generalise?

@dangotbanned dangotbanned Mar 29, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by this.
pandas is already a module-level import?

import pandas as pd

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dangotbanned you're right- I pulled this code from a pretty old branch I had so that must have just been leftover. I'll delete it.

@MarcoGorelli I'll look into generalizing cudf and modin

else:
msg = "Can not cast / initialize Enum without categories present"
raise ValueError(msg)

if isinstance_or_issubclass(
dtype, (dtypes.Struct, dtypes.Array, dtypes.List, dtypes.Time, dtypes.Binary)
):
Expand Down
8 changes: 5 additions & 3 deletions narwhals/_polars/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def native_to_narwhals_dtype(
if dtype == pl.Categorical:
return dtypes.Categorical()
if dtype == pl.Enum:
return dtypes.Enum()
return dtypes.Enum(dtype.categories) # type: ignore[attr-defined]
if dtype == pl.Date:
return dtypes.Date()
if dtype == pl.Datetime:
Expand Down Expand Up @@ -185,8 +185,10 @@ def narwhals_to_native_dtype(
if dtype == dtypes.Categorical:
return pl.Categorical()
if dtype == dtypes.Enum:
msg = "Converting to Enum is not (yet) supported"
raise NotImplementedError(msg)
if dtype is dtypes.Enum:
msg = "Can not cast / initialize Enum without categories present"
raise ValueError(msg)
return pl.Enum(dtype.categories) # type: ignore[union-attr]
if dtype == dtypes.Date:
return pl.Date()
if dtype == dtypes.Time:
Expand Down
28 changes: 27 additions & 1 deletion narwhals/dtypes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import enum
from collections import OrderedDict
from datetime import timezone
from itertools import starmap
Expand All @@ -9,6 +10,8 @@
from narwhals.utils import isinstance_or_issubclass

if TYPE_CHECKING:
from collections.abc import Hashable
from typing import Iterable
from typing import Iterator
from typing import Sequence

Expand Down Expand Up @@ -443,9 +446,32 @@ class Enum(DType):
>>> data = ["beluga", "narwhal", "orca"]
>>> s_native = pl.Series(data, dtype=pl.Enum(data))
>>> nw.from_native(s_native, series_only=True).dtype
Enum
Enum(categories=['beluga', 'narwhal', 'orca'])
"""
Comment thread
dangotbanned marked this conversation as resolved.
Outdated

def __init__(self, categories: Iterable[Hashable] | type[enum.Enum]) -> None:
# TODO(Unassigned): pandas errors on NaN, NA, NaT OR duplicated value category
# Polars errors on Null, NaN OR duplicated OR any non-string category

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MarcoGorelli @dangotbanned any thoughts on whether we should perform a NaN value check at instantiation? Or should we let each backend handle their cases as to these kinds of checks.

Note that @dangotbanned and I are contemplating the duplicated/non-string categories in another thread.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd suggest leaving all of these to the backends

# should the intersection of the above be caught at the narwhals layer?
if isinstance(categories, type) and issubclass(categories, enum.Enum):
categories = (getattr(v, "value", v) for v in categories.__members__.values())
self.categories = [*categories]

def __eq__(self: Self, other: object) -> bool:
# allow comparing object instances to class
if type(other) is type and issubclass(other, self.__class__):
return True
elif isinstance(other, self.__class__):
return self.categories == other.categories
return False

def __hash__(self: Self) -> int: # pragma: no cover
return hash((self.__class__, tuple(self.categories)))

def __repr__(self: Self) -> str: # pragma: no cover
class_name = self.__class__.__name__
return f"{class_name}(categories={self.categories!r})"


class Field:
"""Definition of a single field within a `Struct` DataType.
Expand Down
11 changes: 11 additions & 0 deletions tests/dtypes_test.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from __future__ import annotations

import enum
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import TYPE_CHECKING
from typing import Any
from typing import Iterable
from typing import Literal

import numpy as np
Expand Down Expand Up @@ -393,3 +396,11 @@ def test_cast_decimal_to_native() -> None:
.with_columns(a=nw.col("a").cast(nw.Decimal()))
.to_native()
)


@pytest.mark.parametrize(
"categories", [["a", "b"], ["a", None], [1, 2], enum.Enum("test", "a b")]
)
def test_enum_valid(categories: Iterable[Any] | type[enum.Enum]) -> None:
dtype = nw.Enum(categories)
assert dtype == nw.Enum
16 changes: 8 additions & 8 deletions tests/series_only/cast_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,25 +117,25 @@ def test_cast_to_enum_polars() -> None:
pytest.importorskip("polars")
import polars as pl

# we don't yet support metadata in dtypes, so for now disallow this
# seems like a very niche use case anyway, and allowing it later wouldn't be
# backwards-incompatible
df_pl = pl.DataFrame({"a": ["a", "b"]}, schema={"a": pl.Categorical})
with pytest.raises(
NotImplementedError, match=r"Converting to Enum is not \(yet\) supported"
ValueError, match="Can not cast / initialize Enum without categories present"
):
nw.from_native(df_pl).select(nw.col("a").cast(nw.Enum))

df_nw_pl = nw.from_native(df_pl).select(nw.col("a").cast(nw.Enum(["a", "b"])))
assert df_nw_pl.schema == {"a": nw.Enum(["a", "b"])}


def test_cast_to_enum_pandas() -> None:
pytest.importorskip("pandas")
import pandas as pd

# we don't yet support metadata in dtypes, so for now disallow this
# seems like a very niche use case anyway, and allowing it later wouldn't be
# backwards-incompatible
df_pd = pd.DataFrame({"a": ["a", "b"]}, dtype="category")
with pytest.raises(
NotImplementedError, match=r"Converting to Enum is not \(yet\) supported"
ValueError, match="Can not cast / initialize Enum without categories present"
):
nw.from_native(df_pd).select(nw.col("a").cast(nw.Enum))

df_nw_pd = nw.from_native(df_pd).select(nw.col("a").cast(nw.Enum(["a", "b"])))
assert df_nw_pd.schema == {"a": nw.Enum(["a", "b"])}