From f583b5c8fd958ddb46136874418b0ac4289b65ba Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 31 Mar 2026 23:47:15 +0200 Subject: [PATCH 1/3] simplify _integer_fits_in_decimal function --- narwhals/dtypes/_supertyping.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/narwhals/dtypes/_supertyping.py b/narwhals/dtypes/_supertyping.py index d534f2bf93..86e20a6b34 100644 --- a/narwhals/dtypes/_supertyping.py +++ b/narwhals/dtypes/_supertyping.py @@ -95,6 +95,7 @@ def frozen_dtypes(*dtypes: type[DType]) -> FrozenDTypes: - Pairwise comparisons, but order (of classes) is not important """ +DEC128_MAX_PREC = 38 SIGNED_INTEGER: DTypeGroup = frozenset((Int8, Int16, Int32, Int64, Int128)) UNSIGNED_INTEGER: DTypeGroup = frozenset((UInt8, UInt16, UInt32, UInt64, UInt128)) @@ -273,27 +274,10 @@ def decimal_supertype(left: Decimal, right: Decimal, /) -> Decimal: return Decimal(precision=precision, scale=scale) -DEC128_MAX_PREC = 38 -# Precomputing powers of 10 up to 10^38 -POW10_LIST = tuple(10**i for i in range(DEC128_MAX_PREC + 1)) -INT_MAX_MAP: Mapping[Int, int] = { - UInt8(): (2**8) - 1, - UInt16(): (2**16) - 1, - UInt32(): (2**32) - 1, - UInt64(): (2**64) - 1, - Int8(): (2**7) - 1, - Int16(): (2**15) - 1, - Int32(): (2**31) - 1, - Int64(): (2**63) - 1, -} - - def _integer_fits_in_decimal(value: int, precision: int, scale: int) -> bool: """Scales an integer and checks if it fits the target precision.""" # !NOTE: Indexing is safe since `scale <= precision <= 38` - return (precision == DEC128_MAX_PREC) or ( - value * POW10_LIST[scale] < POW10_LIST[precision] - ) + return (precision == DEC128_MAX_PREC) or (value * (10**scale) < (10**precision)) def _decimal_integer_supertyping(decimal: Decimal, integer: Int) -> DType | None: @@ -301,11 +285,13 @@ def _decimal_integer_supertyping(decimal: Decimal, integer: Int) -> DType | None if integer in {UInt128(), Int128()}: fits_orig_prec_scale = False - elif value := INT_MAX_MAP.get(integer, None): + else: + bits = integer._bits + if isinstance(integer, UnsignedIntegerType): + bits = bits - 1 + + value = (1 << bits) - 1 fits_orig_prec_scale = _integer_fits_in_decimal(value, precision, scale) - else: # pragma: no cover - msg = "Unreachable integer type" - raise ValueError(msg) precision = precision if fits_orig_prec_scale else DEC128_MAX_PREC return Decimal(precision, scale) From 36b9832b81ed8f4b977b4030d19d4a6eae464e85 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 31 Mar 2026 23:50:32 +0200 Subject: [PATCH 2/3] do not supercast Datetime and Duration with different parameters --- narwhals/dtypes/_supertyping.py | 30 --------------------------- tests/dtypes/get_supertype_test.py | 16 +++++++-------- utils/promotion-rules.md.jinja | 33 ++++++++++++++++-------------- 3 files changed, 26 insertions(+), 53 deletions(-) diff --git a/narwhals/dtypes/_supertyping.py b/narwhals/dtypes/_supertyping.py index 86e20a6b34..10a76bb1f9 100644 --- a/narwhals/dtypes/_supertyping.py +++ b/narwhals/dtypes/_supertyping.py @@ -15,7 +15,6 @@ from operator import attrgetter from typing import TYPE_CHECKING, Any -from narwhals._constants import MS_PER_SECOND, NS_PER_SECOND, US_PER_SECOND from narwhals._dispatch import just_dispatch from narwhals._typing_compat import TypeVar from narwhals.dtypes._classes import ( @@ -58,7 +57,6 @@ from typing_extensions import TypeAlias, TypeIs from narwhals.dtypes._classes import _Bits - from narwhals.typing import TimeUnit _Fn = TypeVar("_Fn", bound=Callable[..., Any]) @@ -112,18 +110,6 @@ def frozen_dtypes(*dtypes: type[DType]) -> FrozenDTypes: } -_TIME_UNIT_PER_SECOND: Mapping[TimeUnit, int] = { - "s": 1, - "ms": MS_PER_SECOND, - "us": US_PER_SECOND, - "ns": NS_PER_SECOND, -} - - -def _key_fn_time_unit(obj: Datetime | Duration, /) -> int: - return _TIME_UNIT_PER_SECOND[obj.time_unit] - - @lru_cache(maxsize=_CACHE_SIZE // 2) def dtype_eq(left: DType, right: DType, /) -> bool: return left == right @@ -192,13 +178,6 @@ def same_supertype(left: DType, right: DType, /) -> DType | None: return left if dtype_eq(left, right) else None -@same_supertype.register(Duration, DurationV1) -@lru_cache(maxsize=_CACHE_SIZE * 2) -def downcast_time_unit(left: SameTemporalT, right: SameTemporalT, /) -> SameTemporalT: - """Return the operand with the lowest precision time unit.""" - return min(left, right, key=_key_fn_time_unit) - - def _struct_fields_union( left: Collection[Field], right: Collection[Field], / ) -> Struct | None: @@ -252,15 +231,6 @@ def list_supertype(left: List, right: List, /) -> List | None: return None -@same_supertype.register(Datetime, DatetimeV1) -def datetime_supertype( - left: SameDatetimeT, right: SameDatetimeT, / -) -> SameDatetimeT | None: - if left.time_zone != right.time_zone: - return None - return downcast_time_unit(left, right) - - @same_supertype.register(Enum) def enum_supertype(left: Enum, right: Enum, /) -> Enum | None: return left if left.categories == right.categories else None diff --git a/tests/dtypes/get_supertype_test.py b/tests/dtypes/get_supertype_test.py index cc84b811b1..31bfc65218 100644 --- a/tests/dtypes/get_supertype_test.py +++ b/tests/dtypes/get_supertype_test.py @@ -74,8 +74,8 @@ def test_identical_dtype(dtype: DType) -> None: @pytest.mark.parametrize( ("left", "right", "expected"), [ - (nw.Datetime("ns"), nw.Datetime("us"), nw.Datetime("us")), - (nw.Datetime("s"), nw.Datetime("us"), nw.Datetime("s")), + (nw.Datetime("ns"), nw.Datetime("us"), None), + (nw.Datetime("s"), nw.Datetime("us"), None), (nw.Datetime("s"), nw.Datetime("s", "Africa/Accra"), None), (nw.Datetime(time_zone="Asia/Kathmandu"), nw.Datetime(), None), ( @@ -121,7 +121,7 @@ def test_same_class(left: DType, right: DType, expected: DType | None) -> None: [ ( {"f0": nw.Duration("ms"), "f1": nw.Int64, "f2": nw.Int64}, - {"f0": nw.Duration("us"), "f1": nw.Int64()}, + {"f0": nw.Duration("ms"), "f1": nw.Int64()}, {"f0": nw.Duration("ms"), "f1": nw.Int64(), "f2": nw.Int64()}, ), ( @@ -330,7 +330,7 @@ def test_numeric_and_bool_promotion(numeric_dtype: NumericType) -> None: ("left", "right", "expected"), [ (nw_v1.Datetime(), nw_v1.Datetime(), nw_v1.Datetime()), - (nw_v1.Datetime("ns"), nw_v1.Datetime("s"), nw_v1.Datetime("s")), + (nw_v1.Datetime("ns"), nw_v1.Datetime("s"), None), ( nw_v1.Datetime(time_zone="Europe/Berlin"), nw_v1.Datetime(time_zone="Europe/Berlin"), @@ -339,13 +339,13 @@ def test_numeric_and_bool_promotion(numeric_dtype: NumericType) -> None: ( nw_v1.Datetime(time_zone="Europe/Berlin"), nw_v1.Datetime("ms", "Europe/Berlin"), - nw_v1.Datetime("ms", "Europe/Berlin"), + None, ), (nw_v1.Datetime(time_zone="Europe/Berlin"), nw_v1.Datetime(), None), (nw_v1.Datetime("s"), nw_v1.Datetime("s", "Africa/Accra"), None), - (nw_v1.Duration("ns"), nw_v1.Duration("ms"), nw_v1.Duration("ms")), + (nw_v1.Duration("ns"), nw_v1.Duration("ms"), None), (nw_v1.Duration(), nw_v1.Duration(), nw_v1.Duration()), - (nw_v1.Duration("s"), nw_v1.Duration(), nw_v1.Duration("s")), + (nw_v1.Duration("s"), nw_v1.Duration(), None), (nw_v1.Duration(), nw_v1.Datetime(), None), (nw_v1.Enum(), nw_v1.Enum(), nw_v1.Enum()), (nw_v1.Enum(), nw_v1.String(), nw_v1.String()), @@ -356,7 +356,7 @@ def test_numeric_and_bool_promotion(numeric_dtype: NumericType) -> None: ), ( nw.Struct({"f0": nw_v1.Duration("ms"), "f1": nw.Int64, "f2": nw.Int64}), - nw.Struct({"f0": nw_v1.Duration("us"), "f1": nw.Int64()}), + nw.Struct({"f0": nw_v1.Duration("ms"), "f1": nw.Int64()}), nw.Struct({"f0": nw_v1.Duration("ms"), "f1": nw.Int64(), "f2": nw.Int64()}), ), ( diff --git a/utils/promotion-rules.md.jinja b/utils/promotion-rules.md.jinja index 963e19b20a..18f9bcbbe4 100644 --- a/utils/promotion-rules.md.jinja +++ b/utils/promotion-rules.md.jinja @@ -142,39 +142,42 @@ https://github.com/narwhals-dev/narwhals/pull/3377 ### Duration -Two `Duration` types always have a supertype, namely the type with the **less precise** (coarser) time unit. -For example: +Two `Duration` types have a supertype only if share the **same time unit** (hence are the same): ```python exec="1" session="promotion-rules" result="python" +st(nw.Duration('us'), nw.Duration('us')) st(nw.Duration('us'), nw.Duration('ms')) -st(nw.Duration('s'), nw.Duration('ms')) ``` -Time unit precision order (from coarsest to finest): `s` < `ms` < `us` < `ns` +!!! warning "Difference with Polars" + + Polars promotes two `Duration` types with different time units to the **less precise** (coarser) one, + while other backends, such as pandas, promote to the **most precise** (finest) one. + + Since these two behaviors are contradictory, Narwhals does not attempt to reconcile them and instead + returns no supertype when the time units differ. ### Datetime -Two `Datetime` types have a supertype only if they share the **same time zone**: +Two `Datetime` types have a supertype only if they are the same, hence +if they share both the **same time zone** and the **same time unit**: ```python exec="1" session="promotion-rules" result="python" -st(nw.Datetime('us'), nw.Datetime('ns')) +st(nw.Datetime('us'), nw.Datetime('us')) tz = "Europe/Berlin" print(f"{tz = !r}") st(nw.Datetime(time_zone=tz), nw.Datetime(time_zone=tz)) ``` -The resulting time unit is the **less precise** (coarser) of the two as defined in the previous section on `Duration`. +!!! warning "Difference with Polars" -If they do not share the same time zone, no supertype exists: + Polars promotes two `Datetime` types with different time units to the **less precise** (coarser) one, + while other backends, such as pandas, promote to the **most precise** (finest) one. + + Since these two behaviors are contradictory, Narwhals does not attempt to reconcile them and instead + returns no supertype when the time units differ. -```python exec="1" session="promotion-rules" result="python" -tz1 = "Europe/Berlin" -tz2 = "Europe/Paris" -print(f"{tz1 = !r}") -print(f"{tz2 = !r}") -st(nw.Datetime(time_zone=tz1), nw.Datetime(time_zone=tz2)) -``` ### Datetime and Date From 23cc3e474007350026ff99f13b307678fed17ad5 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Wed, 1 Apr 2026 00:02:29 +0200 Subject: [PATCH 3/3] fix _decimal_integer_supertyping typing and logical issue --- narwhals/dtypes/_supertyping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/narwhals/dtypes/_supertyping.py b/narwhals/dtypes/_supertyping.py index 10a76bb1f9..195861ce7b 100644 --- a/narwhals/dtypes/_supertyping.py +++ b/narwhals/dtypes/_supertyping.py @@ -256,8 +256,8 @@ def _decimal_integer_supertyping(decimal: Decimal, integer: Int) -> DType | None if integer in {UInt128(), Int128()}: fits_orig_prec_scale = False else: - bits = integer._bits - if isinstance(integer, UnsignedIntegerType): + bits: int = integer._bits + if isinstance(integer, SignedIntegerType): bits = bits - 1 value = (1 << bits) - 1