diff --git a/tests/conftest.py b/tests/conftest.py index af1f5e529d..0b7dec95bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,13 @@ import narwhals as nw from narwhals._utils import Implementation, generate_temporary_column_name -from tests.utils import ID_PANDAS_LIKE, PANDAS_VERSION, pyspark_session, sqlframe_session +from tests.utils import ( + ID_PANDAS_LIKE, + PANDAS_VERSION, + any_integer_like_floats, + pyspark_session, + sqlframe_session, +) if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -102,14 +108,29 @@ def pandas_constructor(obj: Data) -> pd.DataFrame: def pandas_nullable_constructor(obj: Data) -> pd.DataFrame: import pandas as pd - return pd.DataFrame(obj).convert_dtypes(dtype_backend="numpy_nullable") + return pd.DataFrame( + { + k: pd.Series(v).convert_dtypes( + convert_integer=not any_integer_like_floats(v), + dtype_backend="numpy_nullable", + ) + for k, v in obj.items() + } + ) def pandas_pyarrow_constructor(obj: Data) -> pd.DataFrame: pytest.importorskip("pyarrow") import pandas as pd - return pd.DataFrame(obj).convert_dtypes(dtype_backend="pyarrow") + return pd.DataFrame( + { + k: pd.Series(v).convert_dtypes( + convert_integer=not any_integer_like_floats(v), dtype_backend="pyarrow" + ) + for k, v in obj.items() + } + ) def modin_constructor(obj: Data) -> IntoDataFrame: # pragma: no cover @@ -124,7 +145,14 @@ def modin_pyarrow_constructor(obj: Data) -> IntoDataFrame: # pragma: no cover import modin.pandas as mpd import pandas as pd - df = mpd.DataFrame(pd.DataFrame(obj)).convert_dtypes(dtype_backend="pyarrow") + df = mpd.DataFrame( + { + k: pd.Series(v).convert_dtypes( + convert_integer=not any_integer_like_floats(v), dtype_backend="pyarrow" + ) + for k, v in obj.items() + } + ) return cast("IntoDataFrame", df) diff --git a/tests/frame/to_pandas_test.py b/tests/frame/to_pandas_test.py index 473b685c19..53329b118d 100644 --- a/tests/frame/to_pandas_test.py +++ b/tests/frame/to_pandas_test.py @@ -25,7 +25,9 @@ def test_convert_pandas(constructor_eager: ConstructorEager) -> None: if constructor_eager.__name__.startswith("pandas"): expected = cast("pd.DataFrame", constructor_eager(data)) elif "modin_pyarrow" in str(constructor_eager): - expected = pd.DataFrame(data).convert_dtypes(dtype_backend="pyarrow") + from modin.pandas.io import to_pandas + + expected = to_pandas(constructor_eager(data)) else: expected = pd.DataFrame(data) diff --git a/tests/utils.py b/tests/utils.py index 4e4b4e133f..4f10b7944e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -276,3 +276,8 @@ def xfail_if_pyspark_connect( # pragma: no cover ) -> None: if is_pyspark_connect(constructor): request.applymarker(pytest.mark.xfail(reason=reason)) + + +def any_integer_like_floats(values: list[Any]) -> bool: + """Return True if any value is a float that represents an integer (e.g. 1.0, 2.0).""" + return any(isinstance(v, float) and v.is_integer() for v in values) diff --git a/tests/utils_test.py b/tests/utils_test.py index e18e3041ab..2654207f73 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -3,6 +3,7 @@ import re import string from dataclasses import dataclass +from datetime import date, datetime from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast @@ -19,7 +20,7 @@ parse_version, requires, ) -from tests.utils import get_module_version_as_tuple +from tests.utils import any_integer_like_floats, get_module_version_as_tuple pytest.importorskip("pandas") import pandas as pd @@ -626,3 +627,34 @@ def fn() -> Iterator[str]: assert next(iter(deferred_1)) == "h" assert list(deferred_1) == list("hello") assert "".join(chain(deferred_1, deferred_2)) == "helloHELLO" + + +@pytest.mark.parametrize( + ("values", "expected"), + [ + pytest.param([1, 2, 3], False, id="all_ints"), + pytest.param([1.5, 2.2, 3.1], False, id="no_integer_like_floats"), + pytest.param([1.0, 2.0, 3.5], True, id="some_integer_like_floats"), + pytest.param([1.0, 2.0, 3.0], True, id="all_integer_like_floats"), + pytest.param([1, 2.0, 3], True, id="mixed_int_and_float"), + pytest.param([], False, id="empty_list"), + pytest.param([-1.0, -2.0], True, id="negative_integer_like_floats"), + pytest.param([1.0, 2.5, 3.0, 4.1], True, id="mixed_floats"), + # mixed non-numeric types + pytest.param(["a", "b", "c"], False, id="all_strings"), + pytest.param([1.0, "a", 2.0], True, id="floats_and_strings"), + pytest.param([1, "a", 2], False, id="ints_and_strings_no_floats"), + # datetime / date objects + pytest.param([date(2024, 1, 1), date(2024, 1, 2)], False, id="all_dates"), + pytest.param( + [datetime(2024, 1, 1), 1.0, datetime(2024, 1, 2)], + True, + id="floats_and_datetimes", + ), + # None / null handling + pytest.param([None, None], False, id="all_none"), + pytest.param([1.0, None, 2.0], True, id="floats_and_none"), + ], +) +def test_any_integer_like_floats(values: list[Any], expected: bool) -> None: # noqa: FBT001 + assert any_integer_like_floats(values) is expected