diff --git a/python/cudf/cudf/core/index.py b/python/cudf/cudf/core/index.py index e6b4b42771a2..a8a7fdbd915f 100644 --- a/python/cudf/cudf/core/index.py +++ b/python/cudf/cudf/core/index.py @@ -2358,6 +2358,16 @@ def _validate_index_level(self, level) -> None: f"Requested level ({level}) does not match index name ({self.name})" ) + def _level_index_from_level(self, level) -> int: + """ + Return level index from given level name or index. + + A flat index only ever has one level, so this validates ``level`` + and returns 0. + """ + self._validate_index_level(level) + return 0 + def unique(self, level: int | None = None) -> Self: if level is not None: self._validate_index_level(level) diff --git a/python/cudf/cudf/core/indexed_frame.py b/python/cudf/cudf/core/indexed_frame.py index fc24fc877f75..65b0f89a21bb 100644 --- a/python/cudf/cudf/core/indexed_frame.py +++ b/python/cudf/cudf/core/indexed_frame.py @@ -4836,17 +4836,13 @@ def _reset_index( elif names is not None: raise NotImplementedError("names is not currently supported.") if level is not None: - if ( - isinstance(level, int) - and level > 0 - and not isinstance(self.index, MultiIndex) - ): - raise IndexError( - f"Too many levels: Index has only 1 level, not {level + 1}" - ) if not isinstance(level, (tuple, list)): level = (level,) - _check_duplicate_level_names(level, self.index.names) + # Normalize to level numbers, which also validates the labels + # (out of bounds, unknown name, ambiguous duplicate name). + level = tuple( + self.index._level_index_from_level(lv) for lv in level + ) index = self.index._new_index_for_reset_index(level, self.index.name) if index is None: @@ -4854,19 +4850,41 @@ def _reset_index( if drop: return self._data, index - new_column_data = {} + new_column_items = [] for name, col in self.index._columns_for_reset_index(level): if name == "index" and "index" in self._data: name = "level_0" - name = ( - tuple( - name if i == col_level else col_fill - for i in range(self._data.nlevels) - ) - if self._data.multiindex - else name - ) - new_column_data[name] = col + if self._data.multiindex: + nlevels = self._data.nlevels + if isinstance(name, tuple): + if len(name) > nlevels: + raise ValueError( + "Item must have length equal to number of levels." + ) + elif len(name) < nlevels: + if col_fill is None: + raise ValueError( + f"col_fill=None is incompatible with " + f"incomplete column name {name}" + ) + name = ( + (col_fill,) * col_level + + tuple(name) + + (col_fill,) * (nlevels - col_level - len(name)) + ) + # else len == nlevels: use as-is + else: + name = tuple( + name if i == col_level else col_fill + for i in range(nlevels) + ) + new_column_items.append((name, col)) + seen = set(self._data.keys()) + for name, _ in new_column_items: + if name in seen: + raise ValueError(f"cannot insert {name}, already exists") + seen.add(name) + new_column_data = dict(new_column_items) # This is to match pandas where the new data columns are always # inserted to the left of existing data columns. label_dtype = None @@ -4885,7 +4903,7 @@ def _reset_index( ColumnAccessor( {**new_column_data, **self._data}, self._data.multiindex, - self._data._level_names, + self._data.level_names, label_dtype=label_dtype, ), index, diff --git a/python/cudf/cudf/core/multiindex.py b/python/cudf/cudf/core/multiindex.py index 1004863131e8..5be6335601cd 100644 --- a/python/cudf/cudf/core/multiindex.py +++ b/python/cudf/cudf/core/multiindex.py @@ -2101,18 +2101,29 @@ def _level_index_from_level(self, level) -> int: """ Return level index from given level name or index """ + if self.names.count(level) > 1 and not is_integer(level): + raise ValueError( + f"The name {level} occurs multiple times, use a level number" + ) try: return self.names.index(level) except ValueError: if not is_integer(level): - raise KeyError(f"Level {level} not found") + raise KeyError(f"Level {level} not found") from None + # matches pandas MultiIndex._get_level_number, which words the + # underflow and overflow errors differently norm = level + self.nlevels if level < 0 else level - if not 0 <= norm < self.nlevels: - # matches pandas MultiIndex._get_level_number + if norm < 0: raise IndexError( f"Too many levels: Index has only {self.nlevels} " f"levels, {level} is not a valid level number" ) from None + elif norm >= self.nlevels: + # Note: levels are zero-based + raise IndexError( + f"Too many levels: Index has only {self.nlevels} levels, " + f"not {norm + 1}" + ) from None return norm @_performance_tracking @@ -2365,16 +2376,12 @@ def _from_columns_like_self( @_performance_tracking def _split_columns_by_levels( - self, levels: tuple, *, in_levels: bool + self, levels: tuple[int, ...], *, in_levels: bool ) -> Generator[tuple[Any, ColumnBase], None, None]: - # This function assumes that for levels with duplicate names, they are - # specified by indices, not name by ``levels``. E.g. [None, None] can - # only be specified by 0, 1, not "None". - level_names = list(self.names) - level_indices = { - lv if isinstance(lv, int) else level_names.index(lv) - for lv in levels - } + # ``levels`` are level *numbers*, already normalized by + # ``_level_index_from_level`` (so names and negative positions have + # been resolved by the caller). + level_indices = set(levels) for i, (name, col) in enumerate( zip(self.names, self._columns, strict=True) ): diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index 34be0e6f2d4c..6ef0b3c6c088 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -1259,15 +1259,7 @@ def pytest_unconfigure(config): "tests/frame/methods/test_replace.py::TestDataFrameReplaceRegex::test_regex_replace_scalar[True-True-True-\\\\s*(\\\\.)\\\\s*-\\\\1\\\\1\\\\1-data1]": "assert a b\\n0 a 0\\n1 b 1\\n2 ... 2\\n3 ... 3 is a b\\n0 a 0\\n1 b 1\\n2 ... 2\\n3 ... 3", "tests/frame/methods/test_replace.py::TestDataFrameReplaceRegex::test_regex_replace_scalar[True-True-True-\\\\s*\\\\.\\\\s*-nan-data0]": "assert a b\\n0 a e\\n1 b f\\n2 NaN g\\n3 NaN h is a b\\n0 a e\\n1 b f\\n2 NaN g\\n3 NaN h", "tests/frame/methods/test_replace.py::TestDataFrameReplaceRegex::test_regex_replace_scalar[True-True-True-\\\\s*\\\\.\\\\s*-nan-data1]": "assert a b\\n0 a 0\\n1 b 1\\n2 NaN 2\\n3 NaN 3 is a b\\n0 a 0\\n1 b 1\\n2 NaN 2\\n3 NaN 3", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_duplicate_columns_allow[False-False]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_duplicate_columns_allow[False-True]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_duplicate_columns_default[False]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_duplicate_columns_default[True]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_empty_rangeindex": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_level_missing[idx_lev0]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_level_missing[idx_lev1]": "TODO: Add a reason for failure", - "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_multiindex_columns": "TODO: Add a reason for failure", + "tests/frame/methods/test_reset_index.py::TestResetIndex::test_reset_index_empty_rangeindex": "cudf MultiIndex stores levels as int64 columns, losing RangeIndex type info; empty level cannot be reconstructed as RangeIndex", "tests/frame/methods/test_sample.py::TestSample::test_sample_random_state[DataFrame-np.array-arg0]": "TODO: Add a reason for failure", "tests/frame/methods/test_sample.py::TestSample::test_sample_random_state[Series-np.array-arg0]": "TODO: Add a reason for failure", "tests/frame/methods/test_set_axis.py::TestDataFrameSetAxis::test_set_axis_copy": "TODO: Add a reason for failure", @@ -3332,10 +3324,6 @@ def pytest_unconfigure(config): "tests/series/methods/test_replace.py::TestSeriesReplace::test_replace_with_dictlike_and_string_dtype[string[pyarrow]]": "TODO: Add a reason for failure", "tests/series/methods/test_replace.py::TestSeriesReplace::test_replace_with_dictlike_and_string_dtype[string[python]]": "TODO: Add a reason for failure", "tests/series/methods/test_replace.py::test_replace_from_index": "AssertionError: Series are different", - "tests/series/methods/test_reset_index.py::TestResetIndex::test_reset_index_drop_errors": "TODO: Add a reason for failure", - "tests/series/methods/test_reset_index.py::TestResetIndex::test_reset_index_level": "TODO: Add a reason for failure", - "tests/series/methods/test_reset_index.py::test_column_name_duplicates[False-names0-expected_names0]": "Failed: DID NOT RAISE ", - "tests/series/methods/test_reset_index.py::test_column_name_duplicates[False-names1-expected_names1]": "Failed: DID NOT RAISE ", "tests/series/methods/test_round.py::TestSeriesRound::test_round_builtin[Float32]": "TODO: Add a reason for failure", "tests/series/methods/test_round.py::TestSeriesRound::test_round_builtin[Float64]": "TODO: Add a reason for failure", "tests/series/methods/test_round.py::TestSeriesRound::test_round_numpy_with_nan[Float32]": "TODO: Add a reason for failure", diff --git a/python/cudf/cudf/tests/dataframe/methods/test_reset_index.py b/python/cudf/cudf/tests/dataframe/methods/test_reset_index.py index 318000e6ffd0..a9d778cae4f6 100644 --- a/python/cudf/cudf/tests/dataframe/methods/test_reset_index.py +++ b/python/cudf/cudf/tests/dataframe/methods/test_reset_index.py @@ -1,7 +1,8 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import numpy as np import pandas as pd import pytest @@ -143,3 +144,104 @@ def test_reset_index_invalid_level(): with pytest.raises(IndexError): pd.DataFrame([1]).reset_index(level=2) + + +@pytest.mark.parametrize( + "level", [-1, -2, -3, [-1], [-2], [0, -1], [-2, -1], ["l0", -1]] +) +def test_reset_index_negative_level_multiindex(level, drop): + # Negative levels count from the end of the MultiIndex. + midx = pd.MultiIndex.from_tuples( + [("a", 1, "x"), ("b", 2, "y")], names=["l0", "l1", "l2"] + ) + pdf = pd.DataFrame({"v": [1, 2]}, index=midx) + gdf = cudf.from_pandas(pdf) + assert_eq( + pdf.reset_index(level=level, drop=drop), + gdf.reset_index(level=level, drop=drop), + ) + + +@pytest.mark.parametrize("level", [-1, [-1]]) +def test_reset_index_negative_level_flat(level, drop): + pdf = pd.DataFrame({"a": [1, 2]}, index=pd.Index([10, 20], name="x")) + gdf = cudf.from_pandas(pdf) + assert_eq( + pdf.reset_index(level=level, drop=drop), + gdf.reset_index(level=level, drop=drop), + ) + + +@pytest.mark.parametrize("level", [np.int64(-1), np.int32(0)]) +def test_reset_index_numpy_integer_level(level, drop): + midx = pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["l0", "l1"]) + pdf = pd.DataFrame({"v": [1, 2]}, index=midx) + gdf = cudf.from_pandas(pdf) + assert_eq( + pdf.reset_index(level=level, drop=drop), + gdf.reset_index(level=level, drop=drop), + ) + + +@pytest.mark.parametrize("level", [-2, -5]) +def test_reset_index_level_underflow_flat(level): + pdf = pd.DataFrame({"a": [1, 2]}, index=pd.Index([10, 20], name="x")) + gdf = cudf.from_pandas(pdf) + assert_exceptions_equal( + lfunc=pdf.reset_index, + rfunc=gdf.reset_index, + lfunc_args_and_kwargs=([], {"level": level}), + rfunc_args_and_kwargs=([], {"level": level}), + ) + with pytest.raises( + IndexError, match=f"{level} is not a valid level number" + ): + gdf.reset_index(level=level) + + +@pytest.mark.parametrize("level", [-3, -4]) +def test_reset_index_level_underflow_multiindex(level): + midx = pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["l0", "l1"]) + pdf = pd.DataFrame({"v": [1, 2]}, index=midx) + gdf = cudf.from_pandas(pdf) + assert_exceptions_equal( + lfunc=pdf.reset_index, + rfunc=gdf.reset_index, + lfunc_args_and_kwargs=([], {"level": level}), + rfunc_args_and_kwargs=([], {"level": level}), + ) + with pytest.raises( + IndexError, match=f"{level} is not a valid level number" + ): + gdf.reset_index(level=level) + + +def test_reset_index_unknown_multiindex_level_name(): + midx = pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["l0", "l1"]) + pdf = pd.DataFrame({"v": [1, 2]}, index=midx) + gdf = cudf.from_pandas(pdf) + assert_exceptions_equal( + lfunc=pdf.reset_index, + rfunc=gdf.reset_index, + lfunc_args_and_kwargs=([], {"level": "nope"}), + rfunc_args_and_kwargs=([], {"level": "nope"}), + ) + with pytest.raises(KeyError, match="Level nope not found"): + gdf.reset_index(level="nope") + + +def test_reset_index_ambiguous_duplicate_level_name(): + midx = pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["d", "d"]) + pdf = pd.DataFrame({"v": [1, 2]}, index=midx) + gdf = cudf.from_pandas(pdf) + assert_exceptions_equal( + lfunc=pdf.reset_index, + rfunc=gdf.reset_index, + lfunc_args_and_kwargs=([], {"level": "d"}), + rfunc_args_and_kwargs=([], {"level": "d"}), + ) + with pytest.raises(ValueError, match="occurs multiple times"): + gdf.reset_index(level="d") + # Duplicate names are still addressable by level number. + assert_eq(pdf.reset_index(level=0), gdf.reset_index(level=0)) + assert_eq(pdf.reset_index(level=-1), gdf.reset_index(level=-1)) diff --git a/python/cudf/cudf/tests/indexes/multiindex/methods/test_droplevel.py b/python/cudf/cudf/tests/indexes/multiindex/methods/test_droplevel.py index c2a339271d41..83960e83012e 100644 --- a/python/cudf/cudf/tests/indexes/multiindex/methods/test_droplevel.py +++ b/python/cudf/cudf/tests/indexes/multiindex/methods/test_droplevel.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import itertools @@ -121,6 +121,17 @@ def test_multiindex_droplevel_index(level): assert_eq(pdfIndex.droplevel(level), gdfIndex.droplevel(level)) +@pytest.mark.parametrize("level", [-3, 2]) +def test_multiindex_droplevel_out_of_bounds(level): + data = [(1, 2), (3, 4)] + pidx = pd.MultiIndex.from_tuples(data, names=["l0", "l1"]) + gidx = cudf.MultiIndex.from_tuples(data, names=["l0", "l1"]) + with pytest.raises(IndexError, match="Too many levels"): + pidx.droplevel(level) + with pytest.raises(IndexError, match="Too many levels"): + gidx.droplevel(level) + + def test_multiindex_droplevel_single_level_none_names(): data = [(1, 2), (3, 4)] pidx = pd.MultiIndex.from_tuples(data, names=[None, None]) diff --git a/python/cudf/cudf/tests/series/methods/test_reset_index.py b/python/cudf/cudf/tests/series/methods/test_reset_index.py index e4ffbcb7201b..8325b0786afd 100644 --- a/python/cudf/cudf/tests/series/methods/test_reset_index.py +++ b/python/cudf/cudf/tests/series/methods/test_reset_index.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pandas as pd @@ -150,3 +150,27 @@ def test_reset_index_dup_level_name_exceptions(): {"level": [None], "drop": False, "inplace": True}, ), ) + + +@pytest.mark.parametrize("level", [-1, -2, [-1], [0, -1]]) +def test_reset_index_negative_level_multiindex(level, drop): + # Negative levels count from the end of the MultiIndex. + midx = pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["l0", "l1"]) + ps = pd.Series([1, 2], index=midx) + gs = cudf.from_pandas(ps) + assert_eq( + ps.reset_index(level=level, drop=drop), + gs.reset_index(level=level, drop=drop), + ) + + +@pytest.mark.parametrize("level", [-2, -5]) +def test_reset_index_level_underflow(level, drop): + ps = pd.Series([1, 2], index=pd.Index([10, 20], name="x")) + gs = cudf.from_pandas(ps) + assert_exceptions_equal( + lfunc=ps.reset_index, + rfunc=gs.reset_index, + lfunc_args_and_kwargs=([], {"level": level, "drop": drop}), + rfunc_args_and_kwargs=([], {"level": level, "drop": drop}), + )