From ca2824ba618a4ac944d39d1ef02743c3fc46e6c6 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 14 Oct 2025 17:24:40 +0200 Subject: [PATCH 1/9] chore: DTypeClass metaclass --- narwhals/dtypes.py | 97 +++++++++++------------------------ narwhals/stable/v1/_dtypes.py | 5 +- tests/dtypes_test.py | 8 +-- 3 files changed, 35 insertions(+), 75 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index e73e6ee93a..96e36f44f5 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -59,7 +59,26 @@ def _validate_into_dtype(dtype: Any) -> None: raise TypeError(msg) -class DType: +class DTypeClass(type): + """Metaclass for DType classes. + + * Nicely printing classes. + * Guarantee `__slots__` is defined. + """ + + def __repr__(cls) -> str: + return cls.__name__ + + def __new__( + cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any + ) -> type: + # Only add empty slots if __slots__ isn't already defined + if "__slots__" not in namespace: + namespace["__slots__"] = () + return super().__new__(cls, name, bases, namespace, **kwargs) + + +class DType(metaclass=DTypeClass): __slots__ = () def __repr__(self) -> str: # pragma: no cover @@ -72,13 +91,11 @@ def base_type(cls) -> type[Self]: Examples: >>> import narwhals as nw >>> nw.Datetime("us").base_type() - - + Datetime >>> nw.String.base_type() - - + String >>> nw.List(nw.Int64).base_type() - + List """ return cls @@ -154,44 +171,30 @@ def __hash__(self) -> int: class NumericType(DType): """Base class for numeric data types.""" - __slots__ = () - class IntegerType(NumericType): """Base class for integer data types.""" - __slots__ = () - class SignedIntegerType(IntegerType): """Base class for signed integer data types.""" - __slots__ = () - class UnsignedIntegerType(IntegerType): """Base class for unsigned integer data types.""" - __slots__ = () - class FloatType(NumericType): """Base class for float data types.""" - __slots__ = () - class TemporalType(DType): """Base class for temporal data types.""" - __slots__ = () - class NestedType(DType): """Base class for nested data types.""" - __slots__ = () - class Decimal(NumericType): """Decimal type. @@ -204,8 +207,6 @@ class Decimal(NumericType): Decimal """ - __slots__ = () - class Int128(SignedIntegerType): """128-bit signed integer type. @@ -226,8 +227,6 @@ class Int128(SignedIntegerType): Int128 """ - __slots__ = () - class Int64(SignedIntegerType): """64-bit signed integer type. @@ -241,8 +240,6 @@ class Int64(SignedIntegerType): Int64 """ - __slots__ = () - class Int32(SignedIntegerType): """32-bit signed integer type. @@ -256,8 +253,6 @@ class Int32(SignedIntegerType): Int32 """ - __slots__ = () - class Int16(SignedIntegerType): """16-bit signed integer type. @@ -271,8 +266,6 @@ class Int16(SignedIntegerType): Int16 """ - __slots__ = () - class Int8(SignedIntegerType): """8-bit signed integer type. @@ -286,8 +279,6 @@ class Int8(SignedIntegerType): Int8 """ - __slots__ = () - class UInt128(UnsignedIntegerType): """128-bit unsigned integer type. @@ -302,8 +293,6 @@ class UInt128(UnsignedIntegerType): UInt128 """ - __slots__ = () - class UInt64(UnsignedIntegerType): """64-bit unsigned integer type. @@ -317,8 +306,6 @@ class UInt64(UnsignedIntegerType): UInt64 """ - __slots__ = () - class UInt32(UnsignedIntegerType): """32-bit unsigned integer type. @@ -332,8 +319,6 @@ class UInt32(UnsignedIntegerType): UInt32 """ - __slots__ = () - class UInt16(UnsignedIntegerType): """16-bit unsigned integer type. @@ -347,8 +332,6 @@ class UInt16(UnsignedIntegerType): UInt16 """ - __slots__ = () - class UInt8(UnsignedIntegerType): """8-bit unsigned integer type. @@ -362,8 +345,6 @@ class UInt8(UnsignedIntegerType): UInt8 """ - __slots__ = () - class Float64(FloatType): """64-bit floating point type. @@ -377,8 +358,6 @@ class Float64(FloatType): Float64 """ - __slots__ = () - class Float32(FloatType): """32-bit floating point type. @@ -392,8 +371,6 @@ class Float32(FloatType): Float32 """ - __slots__ = () - class String(DType): """UTF-8 encoded string type. @@ -406,8 +383,6 @@ class String(DType): String """ - __slots__ = () - class Boolean(DType): """Boolean type. @@ -420,8 +395,6 @@ class Boolean(DType): Boolean """ - __slots__ = () - class Object(DType): """Data type for wrapping arbitrary Python objects. @@ -435,8 +408,6 @@ class Object(DType): Object """ - __slots__ = () - class Unknown(DType): """Type representing DataType values that could not be determined statically. @@ -449,10 +420,8 @@ class Unknown(DType): Unknown """ - __slots__ = () - -class _DatetimeMeta(type): +class _DatetimeMeta(DTypeClass): @property def time_unit(cls) -> TimeUnit: """Unit of time. Defaults to `'us'` (microseconds).""" @@ -546,7 +515,7 @@ def __repr__(self) -> str: # pragma: no cover return f"{class_name}(time_unit={self.time_unit!r}, time_zone={self.time_zone!r})" -class _DurationMeta(type): +class _DurationMeta(DTypeClass): @property def time_unit(cls) -> TimeUnit: """Unit of time. Defaults to `'us'` (microseconds).""" @@ -627,8 +596,6 @@ class Categorical(DType): Categorical """ - __slots__ = () - class Enum(DType): """A fixed categorical encoding of a unique set of strings. @@ -686,7 +653,7 @@ def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] >>> nw.Enum(["a", "b", "c"]) == nw.Enum True """ - if type(other) is type: + if type(other) is DTypeClass: return other is Enum return isinstance(other, type(self)) and self.categories == other.categories @@ -801,7 +768,7 @@ def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] >>> nw.Struct({"a": nw.Int64}) == nw.Struct True """ - if type(other) is type and issubclass(other, self.__class__): + if type(other) is DTypeClass and issubclass(other, self.__class__): return True if isinstance(other, self.__class__): return self.fields == other.fields @@ -864,7 +831,7 @@ def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] >>> nw.List(nw.Int64) == nw.List True """ - if type(other) is type and issubclass(other, self.__class__): + if type(other) is DTypeClass and issubclass(other, self.__class__): return True if isinstance(other, self.__class__): return self.inner == other.inner @@ -937,7 +904,7 @@ def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] >>> nw.Array(nw.Int64, 2) == nw.Array True """ - if type(other) is type and issubclass(other, self.__class__): + if type(other) is DTypeClass and issubclass(other, self.__class__): return True if isinstance(other, self.__class__): if self.shape != other.shape: @@ -972,8 +939,6 @@ class Date(TemporalType): Date """ - __slots__ = () - class Time(TemporalType): """Data type representing the time of day. @@ -999,8 +964,6 @@ class Time(TemporalType): Time """ - __slots__ = () - class Binary(DType): """Binary type. @@ -1024,5 +987,3 @@ class Binary(DType): >>> nw.from_native(rel).collect_schema()["t"] Binary """ - - __slots__ = () diff --git a/narwhals/stable/v1/_dtypes.py b/narwhals/stable/v1/_dtypes.py index 98b490b56f..21e25efa7d 100644 --- a/narwhals/stable/v1/_dtypes.py +++ b/narwhals/stable/v1/_dtypes.py @@ -12,6 +12,7 @@ Datetime as NwDatetime, Decimal, DType, + DTypeClass, Duration as NwDuration, Enum as NwEnum, Field, @@ -85,13 +86,11 @@ class Enum(NwEnum): Enum """ - __slots__ = () - def __init__(self) -> None: super(NwEnum, self).__init__() def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] - if type(other) is type: + if type(other) is DTypeClass: return other in {type(self), NwEnum} return isinstance(other, type(self)) diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py index fe2dec98e7..31583230bb 100644 --- a/tests/dtypes_test.py +++ b/tests/dtypes_test.py @@ -116,7 +116,7 @@ def test_list_valid() -> None: assert dtype == nw.List assert dtype != nw.List(nw.Float32) assert dtype != nw.Duration - assert repr(dtype) == "List()" + assert repr(dtype) == "List(Int64)" dtype = nw.List(nw.List(nw.Int64)) assert dtype == nw.List(nw.List(nw.Int64)) assert dtype == nw.List @@ -131,7 +131,7 @@ def test_array_valid() -> None: assert dtype != nw.Array(nw.Int64, 3) assert dtype != nw.Array(nw.Float32, 2) assert dtype != nw.Duration - assert repr(dtype) == "Array(, shape=(2,))" + assert repr(dtype) == "Array(Int64, shape=(2,))" dtype = nw.Array(nw.Array(nw.Int64, 2), 2) assert dtype == nw.Array(nw.Array(nw.Int64, 2), 2) assert dtype == nw.Array @@ -151,7 +151,7 @@ def test_struct_valid() -> None: assert dtype == nw.Struct assert dtype != nw.Struct([nw.Field("a", nw.Float32)]) assert dtype != nw.Duration - assert repr(dtype) == "Struct({'a': })" + assert repr(dtype) == "Struct({'a': Int64})" dtype = nw.Struct({"a": nw.Int64, "b": nw.String}) assert dtype == nw.Struct({"a": nw.Int64, "b": nw.String}) @@ -170,7 +170,7 @@ def test_struct_reverse() -> None: def test_field_repr() -> None: dtype = nw.Field("a", nw.Int32) - assert repr(dtype) == "Field('a', )" + assert repr(dtype) == "Field('a', Int32)" def test_field_eq() -> None: From cd26196e5e90008093632d91309c8f4ea239eec4 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 14 Oct 2025 17:42:35 +0200 Subject: [PATCH 2/9] simplify --- narwhals/dtypes.py | 10 ++++++---- narwhals/stable/v1/_dtypes.py | 4 ---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index 96e36f44f5..7d6b16c261 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -62,8 +62,8 @@ def _validate_into_dtype(dtype: Any) -> None: class DTypeClass(type): """Metaclass for DType classes. - * Nicely printing classes. - * Guarantee `__slots__` is defined. + * Nicely print classes. + * Guarantee `__slots__` is defined (empty by default). """ def __repr__(cls) -> str: @@ -79,9 +79,11 @@ def __new__( class DType(metaclass=DTypeClass): - __slots__ = () + """Base class for all Narwhals data types.""" - def __repr__(self) -> str: # pragma: no cover + __slots__ = () # NOTE: Keep this one defined manually for the type checker + + def __repr__(self) -> str: return self.__class__.__qualname__ @classmethod diff --git a/narwhals/stable/v1/_dtypes.py b/narwhals/stable/v1/_dtypes.py index 21e25efa7d..5b4ea54958 100644 --- a/narwhals/stable/v1/_dtypes.py +++ b/narwhals/stable/v1/_dtypes.py @@ -49,8 +49,6 @@ class Datetime(NwDatetime): - __slots__ = NwDatetime.__slots__ - @inherit_doc(NwDatetime) def __init__( self, time_unit: TimeUnit = "us", time_zone: str | timezone | None = None @@ -62,8 +60,6 @@ def __hash__(self) -> int: class Duration(NwDuration): - __slots__ = NwDuration.__slots__ - @inherit_doc(NwDuration) def __init__(self, time_unit: TimeUnit = "us") -> None: super().__init__(time_unit) From e4bddda38769961500c81ca9db8be162db610483 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 14 Oct 2025 23:19:38 +0200 Subject: [PATCH 3/9] Match Dan's impl in _plan/_meta.py --- narwhals/dtypes.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index 7d6b16c261..ee3be9f807 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -17,6 +17,7 @@ from collections.abc import Iterator, Sequence from typing import Any + import _typeshed from typing_extensions import Self, TypeIs from narwhals.typing import IntoDType, TimeUnit @@ -63,19 +64,28 @@ class DTypeClass(type): """Metaclass for DType classes. * Nicely print classes. - * Guarantee `__slots__` is defined (empty by default). + * Ensure [`__slots__`] are always defined to prevent `__dict__` creation (empty by default). + + [`__slots__`]: https://docs.python.org/3/reference/datamodel.html#object.__slots__ """ def __repr__(cls) -> str: return cls.__name__ + # https://github.com/python/typeshed/blob/776508741d76b58f9dcb2aaf42f7d4596a48d580/stdlib/abc.pyi#L13-L19 + # https://github.com/python/typeshed/blob/776508741d76b58f9dcb2aaf42f7d4596a48d580/stdlib/_typeshed/__init__.pyi#L36-L40 + # https://github.com/astral-sh/ruff/issues/8353#issuecomment-1786238311 + # https://docs.python.org/3/reference/datamodel.html#creating-the-class-object def __new__( - cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any - ) -> type: - # Only add empty slots if __slots__ isn't already defined - if "__slots__" not in namespace: - namespace["__slots__"] = () - return super().__new__(cls, name, bases, namespace, **kwargs) + metacls: type[_typeshed.Self], + cls_name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + /, + **kwds: Any, + ) -> _typeshed.Self: + namespace.setdefault("__slots__", ()) + return super().__new__(metacls, cls_name, bases, namespace, **kwds) # type: ignore[no-any-return, misc] class DType(metaclass=DTypeClass): From 59dc3d909fd3168d5a3c4dd30d345c6f6b17c2b3 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Thu, 16 Oct 2025 20:07:22 +0200 Subject: [PATCH 4/9] Thanks Dan https://github.com/narwhals-dev/narwhals/pull/3194\#discussion_r2423150714 --- tests/expr_and_series/cast_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py index 09a19073b5..4dc0079e77 100644 --- a/tests/expr_and_series/cast_test.py +++ b/tests/expr_and_series/cast_test.py @@ -18,7 +18,9 @@ ) if TYPE_CHECKING: - from narwhals.typing import NativeLazyFrame + from collections.abc import Mapping + + from narwhals.typing import NativeLazyFrame, NonNestedDType DATA = { "a": [1], @@ -38,7 +40,7 @@ "o": ["a"], "p": [1], } -SCHEMA = { +SCHEMA: Mapping[str, type[NonNestedDType]] = { "a": nw.Int64, "b": nw.Int32, "c": nw.Int16, @@ -86,7 +88,7 @@ def test_cast(constructor: Constructor) -> None: nw.col(col_).cast(dtype) for col_, dtype in schema.items() ) - cast_map = { + cast_map: Mapping[str, type[NonNestedDType]] = { "a": nw.Int32, "b": nw.Int16, "c": nw.Int8, @@ -134,7 +136,7 @@ def test_cast_series( .lazy() .collect() ) - cast_map = { + cast_map: Mapping[str, type[NonNestedDType]] = { "a": nw.Int32, "b": nw.Int16, "c": nw.Int8, From cdf38b39bd02d7b0d19049edd4f399e3c1cf5bd9 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:41:32 +0000 Subject: [PATCH 5/9] test: Demonstrate repr quirk --- tests/dtypes_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py index 645721c358..50578c6e09 100644 --- a/tests/dtypes_test.py +++ b/tests/dtypes_test.py @@ -493,6 +493,17 @@ def test_enum_hash() -> None: assert nw.Enum(["a", "b"]) not in {nw.Enum(["a", "b", "c"])} +@pytest.mark.parametrize("dtype_name", ["Datetime", "Duration", "Enum"]) +def test_dtype_repr_versioned(dtype_name: str) -> None: + from narwhals.stable import v1 as nw_v1 + + dtype_class_main = getattr(nw, dtype_name) + dtype_class_v1 = getattr(nw_v1, dtype_name) + + assert dtype_class_main is not dtype_class_v1 + assert repr(dtype_class_main) != repr(dtype_class_v1) + + def test_datetime_w_tz_duckdb() -> None: pytest.importorskip("duckdb") import duckdb From fb1a4a0574dd1eecff2f85d61d2b628dc945beea Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:54:28 +0000 Subject: [PATCH 6/9] test: xfail for now --- tests/dtypes_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py index 50578c6e09..517ba2c883 100644 --- a/tests/dtypes_test.py +++ b/tests/dtypes_test.py @@ -493,6 +493,9 @@ def test_enum_hash() -> None: assert nw.Enum(["a", "b"]) not in {nw.Enum(["a", "b", "c"])} +@pytest.mark.xfail( + reason="https://github.com/narwhals-dev/narwhals/pull/3213#discussion_r2437271987" +) @pytest.mark.parametrize("dtype_name", ["Datetime", "Duration", "Enum"]) def test_dtype_repr_versioned(dtype_name: str) -> None: from narwhals.stable import v1 as nw_v1 From 1bb2dd45bf0f4856f4e8fa50e5fe9d1f23ff5c07 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:02:49 +0000 Subject: [PATCH 7/9] refactor: Use `DTypeClass` instead of `type` --- narwhals/dtypes.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index ee3be9f807..2eb0231ba7 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -34,14 +34,12 @@ def _validate_dtype(dtype: DType | type[DType]) -> None: def _is_into_dtype(obj: Any) -> TypeIs[IntoDType]: return isinstance(obj, DType) or ( - isinstance(obj, type) - and issubclass(obj, DType) - and not issubclass(obj, NestedType) + isinstance(obj, DTypeClass) and not issubclass(obj, NestedType) ) def _is_nested_type(obj: Any) -> TypeIs[type[NestedType]]: - return isinstance(obj, type) and issubclass(obj, NestedType) + return isinstance(obj, DTypeClass) and issubclass(obj, NestedType) def _validate_into_dtype(dtype: Any) -> None: From 0144e6cf70c1275737d4010847df941a6f5bbf66 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:07:54 +0000 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20don't=20import=20twice=20?= =?UTF-8?q?=F0=9F=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- narwhals/dtypes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index 2eb0231ba7..b9b4abea18 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -170,8 +170,6 @@ def __eq__(self, other: DType | type[DType]) -> bool: # type: ignore[override] >>> nw.Date() == nw.Datetime False """ - from narwhals._utils import isinstance_or_issubclass - return isinstance_or_issubclass(other, type(self)) def __hash__(self) -> int: From 85210deb57fcde3855a316b48dd16acb7546d092 Mon Sep 17 00:00:00 2001 From: Francesco Bruzzesi <42817048+FBruzzesi@users.noreply.github.com> Date: Fri, 17 Oct 2025 22:15:57 +0200 Subject: [PATCH 9/9] Change from `*` to `-` for markdown list Co-authored-by: Dan Redding <125183946+dangotbanned@users.noreply.github.com> --- narwhals/dtypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py index b9b4abea18..8ef5d30183 100644 --- a/narwhals/dtypes.py +++ b/narwhals/dtypes.py @@ -61,8 +61,8 @@ def _validate_into_dtype(dtype: Any) -> None: class DTypeClass(type): """Metaclass for DType classes. - * Nicely print classes. - * Ensure [`__slots__`] are always defined to prevent `__dict__` creation (empty by default). + - Nicely print classes. + - Ensure [`__slots__`] are always defined to prevent `__dict__` creation (empty by default). [`__slots__`]: https://docs.python.org/3/reference/datamodel.html#object.__slots__ """