Repository: modin-project/modin (python)

Issue:
FEAT: Add progress bar for engine switch
**Is your feature request related to a problem? Please describe.**
When `DataFrame.set_backend` is called, a progress bar should display switch progress.

Pull request:
FEAT-#7468: Add progress bar for engine switch
<!--
Thank you for your contribution!
Please review the contributing docs: https://modin.readthedocs.io/en/latest/development/contributing.html
if you have questions about contributing.
-->

## What do these changes do?

Adds a progress bar when materializing data in `BasePandasDataset.set_backend`. It looks like `tqdm` relies on timing individual iterations to display an actual estimate + extending bar, so right now the bar only moves between the to_pandas and FactoryDispatcher calls and isn't really meaningful aside from signaling that a transfer is in progress.

Notebook example:
![Screenshot 2025-03-18 at 15 41 28](https://github.com/user-attachments/assets/3adbe416-968d-4711-a544-d006402ce741)

Standard Python REPL example:
```
>>> import modin.pandas as pd
>>> pd.DataFrame([[1] * 5] * 5).set_backend("dask")
2025-03-18 15:40:43,301	INFO worker.py:1781 -- Started a local Ray instance.
Transferring data from Ray to Dask ...: 100%|█████████████████████████████████| 2/2 [00:01<00:00,  1.24it/s]
   0  1  2  3  4
0  1  1  1  1  1
1  1  1  1  1  1
2  1  1  1  1  1
3  1  1  1  1  1
4  1  1  1  1  1
```

IPython REPL example:
```
In [1]: import modin.pandas as pd
   ...: pd.DataFrame([[1] * 5] * 5).set_backend("dask")
2025-03-18 15:39:38,406	INFO worker.py:1781 -- Started a local Ray instance.
Transferring data from Ray to Dask ...: 100%|█████████████████████████████████| 2/2 [00:01<00:00,  1.20it/s]
Out[1]: 
   0  1  2  3  4
0  1  1  1  1  1
1  1  1  1  1  1
2  1  1  1  1  1
3  1  1  1  1  1
4  1  1  1  1  1
```

- [x] first commit message and PR title follow format outlined [here](https://modin.readthedocs.io/en/latest/development/contributing.html#commit-message-formatting)
  > **_NOTE:_**  If you edit the PR title to match this format, you need to add another commit (even if it's empty) or amend your last commit for the CI job that checks the PR title to pick up the new PR title.
- [x] passes `flake8 modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] passes `black --check modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] signed commit with `git commit -s` <!-- you can amend your commit with a signature via `git commit -amend -s` -->
- [x] Resolves #7468 <!-- issue must be created for each patch -->
- [x] tests added and passing
- [x] module layout described at `docs/development/architecture.rst` is up-to-date <!-- if you have added, renamed or removed files or directories please update the documentation accordingly -->


Patch:
diff --git a/modin/pandas/base.py b/modin/pandas/base.py
index 18a64cd7..b0b6cee4 100644
--- a/modin/pandas/base.py
+++ b/modin/pandas/base.py
@@ -4400,8 +4400,31 @@ class BasePandasDataset(ClassLogger):
             FactoryDispatcher,
         )
 
+        progress_split_count = 2
+        progress_iter = iter(range(progress_split_count))
+        if Backend.normalize(backend) != self.get_backend():
+            try:
+                from tqdm.auto import trange
+
+                progress_iter = iter(
+                    trange(
+                        progress_split_count,
+                        desc=f"Transferring data from {self.get_backend()} to {Backend.normalize(backend)} ...",
+                    )
+                )
+            except ImportError:
+                # Iterate over blank range(2) if tqdm is not installed
+                pass
+        # If tqdm is imported and a conversion is necessary, then display a progress bar.
+        next(progress_iter)
         pandas_self = self._query_compiler.to_pandas()
+        next(progress_iter)
         query_compiler = FactoryDispatcher.from_pandas(df=pandas_self, backend=backend)
+        try:
+            next(progress_iter)
+        except StopIteration:
+            # Last call to next informs tqdm that the operation is done
+            pass
         if inplace:
             self._update_inplace(query_compiler)
             return None
diff --git a/modin/pandas/series.py b/modin/pandas/series.py
index b03395e2..ebf190cd 100644
--- a/modin/pandas/series.py
+++ b/modin/pandas/series.py
@@ -754,7 +754,9 @@ class Series(BasePandasDataset):
         """
         Return int position of the largest value in the Series.
         """
-        result = self.idxmax(axis=axis, skipna=skipna, *args, **kwargs)
+        result = self.reset_index(drop=True).idxmax(
+            axis=axis, skipna=skipna, *args, **kwargs
+        )
         if np.isnan(result) or result is pandas.NA:
             result = -1
         return result
@@ -765,7 +767,9 @@ class Series(BasePandasDataset):
         """
         Return int position of the smallest value in the Series.
         """
-        result = self.idxmin(axis=axis, skipna=skipna, *args, **kwargs)
+        result = self.reset_index(drop=True).idxmin(
+            axis=axis, skipna=skipna, *args, **kwargs
+        )
         if np.isnan(result) or result is pandas.NA:
             result = -1
         return result
@@ -1710,6 +1714,29 @@ class Series(BasePandasDataset):
             mapper=mapper, index=index, axis=axis, copy=copy, inplace=inplace
         )
 
+    def _set_axis_name(self, name, axis=0, inplace=False) -> Union[Series, None]:
+        """
+        Alter the name of the axis.
+
+        Parameters
+        ----------
+        name : str
+            Name for the Series.
+        axis : str or int, default: 0
+            The axis to set the label.
+            Only 0 is valid for Series.
+        inplace : bool, default: False
+            Whether to modify `self` directly or return a copy.
+
+        Returns
+        -------
+        Series or None
+        """
+        self._get_axis_number(axis)  # raises ValueError if not 0
+        renamed = self if inplace else self.copy()
+        renamed.index = renamed.index.set_names(name)
+        return None if inplace else renamed
+
     def rename(
         self,
         index=None,


Test patch:
diff --git a/modin/tests/pandas/test_backend.py b/modin/tests/pandas/test_backend.py
index 37a2978e..e0b3aef1 100644
--- a/modin/tests/pandas/test_backend.py
+++ b/modin/tests/pandas/test_backend.py
@@ -13,8 +13,10 @@
 
 import platform
 import re
+from unittest.mock import patch
 
 import pytest
+import tqdm.auto
 
 import modin.pandas as pd
 from modin.config import Backend
@@ -117,7 +119,10 @@ def test_set_valid_backend(
     data_class,
     expected_result_backend,
 ):
-    with config_context(Backend=starting_backend):
+    progress_iter_count = 2
+    with patch.object(
+        tqdm.auto, "trange", return_value=range(progress_iter_count)
+    ) as mock_trange, config_context(Backend=starting_backend):
         original_df = data_class([1])
         # convert to pandas for comparison while still on the `starting_backend`.
         original_df_as_pandas = original_df.modin.to_pandas()
@@ -135,6 +140,14 @@ def test_set_valid_backend(
         # The global Backend should remain the same even if we change the
         # backend for a single dataframe.
         assert Backend.get() == Backend.normalize(starting_backend)
+        if Backend.normalize(starting_backend) == Backend.normalize(
+            expected_result_backend
+        ):
+            mock_trange.assert_not_called()
+        else:
+            # trange constructor is only called once and the iterator is consumed
+            # progress_iter_count times, but we can't easily assert on the number of iterations
+            mock_trange.assert_called_once()
 
 
 def test_set_nonexistent_backend():
diff --git a/modin/tests/pandas/test_series.py b/modin/tests/pandas/test_series.py
index 706afeaf..828cc158 100644
--- a/modin/tests/pandas/test_series.py
+++ b/modin/tests/pandas/test_series.py
@@ -5188,3 +5188,30 @@ def test_logical_binary_with_list(op):
     result_md = getattr(series_md, op)(rhs)
     result_pd = getattr(series_pd, op)(rhs)
     df_equals(result_md, result_pd)
+
+
+@pytest.mark.parametrize("op", ["argmax", "argmin"])
+def test_argmax_argmin_7413(op):
+    # Ensures that argmin/argmax use positional index, not the actual index value
+    series_md, series_pd = create_test_series([1, 2, 3], index=["b", "a", "c"])
+    result_md = getattr(series_md, op)()
+    result_pd = getattr(series_pd, op)()
+    assert result_md == result_pd
+
+
+def test_rename_axis():
+    series_md, series_pd = create_test_series([0, 1, 2])
+    eval_general(series_md, series_pd, lambda ser: ser.rename_axis("name"))
+    eval_general(
+        series_md,
+        series_pd,
+        lambda ser: ser.rename_axis("new_name", inplace=True),
+        __inplace__=True,
+    )
+    # axis=1 is invalid for series
+    eval_general(
+        series_md,
+        series_pd,
+        lambda ser: ser.rename_axis("newer_name", axis=1),
+        expected_exception=ValueError("No axis named 1 for object type Series"),
+    )