Repository: pypa/pip
Pull request #13220 (2025-02-12 21:58:18)

Installation progress bar ✨
Towards #12712.

Installation can be pretty slow so it'd be nice to provide progress feedback to the user.

### Implementation notes:

- The progress bar will wait one refresh cycle (1000ms/6 = 170ms) before appearing. This avoids unsightly very short flashes.

- The progress bar is transient (i.e. it will disappear once all packages have been installed). This choice was made to avoid adding more clutter to pip install's output (despite the download progress bar being persistent).

- The progress bar won't be used at all if there's only one package to install.

### Demo

[Screencast from 2025-02-11 17-33-02.webm](https://github.com/user-attachments/assets/f6b7badf-9873-4be6-8a79-6449f88915e5)

### Where are the tests?

Turns out that aren't any progress bar tests so I had nothing to base any new tests on. I'd appreciate suggestions for testing this w/o essentially retesting rich's own functionality. 


Patch:
diff --git a/news/12712.feature.rst b/news/12712.feature.rst
new file mode 100644
index 000000000..adba2bf84
--- /dev/null
+++ b/news/12712.feature.rst
@@ -0,0 +1,1 @@
+Display a transient progress bar during package installation.
diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py
index 3d9dde8ed..a7d77cfaa 100644
--- a/src/pip/_internal/cli/progress_bars.py
+++ b/src/pip/_internal/cli/progress_bars.py
@@ -1,11 +1,12 @@
 import functools
 import sys
-from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple
+from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple, TypeVar
 
 from pip._vendor.rich.progress import (
     BarColumn,
     DownloadColumn,
     FileSizeColumn,
+    MofNCompleteColumn,
     Progress,
     ProgressColumn,
     SpinnerColumn,
@@ -16,12 +17,14 @@ from pip._vendor.rich.progress import (
 )
 
 from pip._internal.cli.spinners import RateLimiter
-from pip._internal.utils.logging import get_indentation
+from pip._internal.req.req_install import InstallRequirement
+from pip._internal.utils.logging import get_console, get_indentation
 
-DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]]
+T = TypeVar("T")
+ProgressRenderer = Callable[[Iterable[T]], Iterator[T]]
 
 
-def _rich_progress_bar(
+def _rich_download_progress_bar(
     iterable: Iterable[bytes],
     *,
     bar_type: str,
@@ -57,6 +60,28 @@ def _rich_progress_bar(
             progress.update(task_id, advance=len(chunk))
 
 
+def _rich_install_progress_bar(
+    iterable: Iterable[InstallRequirement], *, total: int
+) -> Iterator[InstallRequirement]:
+    columns = (
+        TextColumn("{task.fields[indent]}"),
+        BarColumn(),
+        MofNCompleteColumn(),
+        TextColumn("{task.description}"),
+    )
+    console = get_console()
+
+    bar = Progress(*columns, refresh_per_second=6, console=console, transient=True)
+    # Hiding the progress bar at initialization forces a refresh cycle to occur
+    # until the bar appears, avoiding very short flashes.
+    task = bar.add_task("", total=total, indent=" " * get_indentation(), visible=False)
+    with bar:
+        for req in iterable:
+            bar.update(task, description=rf"\[{req.name}]", visible=True)
+            yield req
+            bar.advance(task)
+
+
 def _raw_progress_bar(
     iterable: Iterable[bytes],
     *,
@@ -81,14 +106,28 @@ def _raw_progress_bar(
 
 def get_download_progress_renderer(
     *, bar_type: str, size: Optional[int] = None
-) -> DownloadProgressRenderer:
+) -> ProgressRenderer[bytes]:
     """Get an object that can be used to render the download progress.
 
     Returns a callable, that takes an iterable to "wrap".
     """
     if bar_type == "on":
-        return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size)
+        return functools.partial(
+            _rich_download_progress_bar, bar_type=bar_type, size=size
+        )
     elif bar_type == "raw":
         return functools.partial(_raw_progress_bar, size=size)
     else:
         return iter  # no-op, when passed an iterator
+
+
+def get_install_progress_renderer(
+    *, bar_type: str, total: int
+) -> ProgressRenderer[InstallRequirement]:
+    """Get an object that can be used to render the install progress.
+    Returns a callable, that takes an iterable to "wrap".
+    """
+    if bar_type == "on":
+        return functools.partial(_rich_install_progress_bar, total=total)
+    else:
+        return iter
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index 232a34a6d..5239d0104 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -464,6 +464,7 @@ class InstallCommand(RequirementCommand):
                 warn_script_location=warn_script_location,
                 use_user_site=options.use_user_site,
                 pycompile=options.compile,
+                progress_bar=options.progress_bar,
             )
 
             lib_locations = get_lib_location_guesses(
diff --git a/src/pip/_internal/req/__init__.py b/src/pip/_internal/req/__init__.py
index 422d851d7..bf282dab8 100644
--- a/src/pip/_internal/req/__init__.py
+++ b/src/pip/_internal/req/__init__.py
@@ -3,6 +3,7 @@ import logging
 from dataclasses import dataclass
 from typing import Generator, List, Optional, Sequence, Tuple
 
+from pip._internal.cli.progress_bars import get_install_progress_renderer
 from pip._internal.utils.logging import indent_log
 
 from .req_file import parse_requirements
@@ -41,6 +42,7 @@ def install_given_reqs(
     warn_script_location: bool,
     use_user_site: bool,
     pycompile: bool,
+    progress_bar: str,
 ) -> List[InstallationResult]:
     """
     Install everything in the given list.
@@ -57,8 +59,19 @@ def install_given_reqs(
 
     installed = []
 
+    show_progress = logger.isEnabledFor(logging.INFO) and len(to_install) > 1
+
+    items = iter(to_install.values())
+    if show_progress:
+        renderer = get_install_progress_renderer(
+            bar_type=progress_bar, total=len(to_install)
+        )
+        items = renderer(items)
+
     with indent_log():
-        for req_name, requirement in to_install.items():
+        for requirement in items:
+            req_name = requirement.name
+            assert req_name is not None
             if requirement.should_reinstall:
                 logger.info("Attempting uninstall: %s", req_name)
                 with indent_log():
diff --git a/src/pip/_internal/utils/logging.py b/src/pip/_internal/utils/logging.py
index 62035fc40..099a92c49 100644
--- a/src/pip/_internal/utils/logging.py
+++ b/src/pip/_internal/utils/logging.py
@@ -8,7 +8,7 @@ import threading
 from dataclasses import dataclass
 from io import TextIOWrapper
 from logging import Filter
-from typing import Any, ClassVar, Generator, List, Optional, TextIO, Type
+from typing import Any, ClassVar, Generator, List, Optional, Type
 
 from pip._vendor.rich.console import (
     Console,
@@ -29,6 +29,8 @@ from pip._internal.utils.deprecation import DEPRECATION_MSG_PREFIX
 from pip._internal.utils.misc import ensure_dir
 
 _log_state = threading.local()
+_stdout_console = None
+_stderr_console = None
 subprocess_logger = getLogger("pip.subprocessor")
 
 
@@ -144,12 +146,21 @@ class PipConsole(Console):
         raise BrokenPipeError() from None
 
 
+def get_console(*, stderr: bool = False) -> Console:
+    if stderr:
+        assert _stderr_console is not None, "stderr rich console is missing!"
+        return _stderr_console
+    else:
+        assert _stdout_console is not None, "stdout rich console is missing!"
+        return _stdout_console
+
+
 class RichPipStreamHandler(RichHandler):
     KEYWORDS: ClassVar[Optional[List[str]]] = []
 
-    def __init__(self, stream: Optional[TextIO], no_color: bool) -> None:
+    def __init__(self, console: Console) -> None:
         super().__init__(
-            console=PipConsole(file=stream, no_color=no_color, soft_wrap=True),
+            console=console,
             show_time=False,
             show_level=False,
             show_path=False,
@@ -266,10 +277,6 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
     vendored_log_level = "WARNING" if level in ["INFO", "ERROR"] else "DEBUG"
 
     # Shorthands for clarity
-    log_streams = {
-        "stdout": "ext://sys.stdout",
-        "stderr": "ext://sys.stderr",
-    }
     handler_classes = {
         "stream": "pip._internal.utils.logging.RichPipStreamHandler",
         "file": "pip._internal.utils.logging.BetterRotatingFileHandler",
@@ -277,6 +284,9 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
     handlers = ["console", "console_errors", "console_subprocess"] + (
         ["user_log"] if include_user_log else []
     )
+    global _stdout_console, stderr_console
+    _stdout_console = PipConsole(file=sys.stdout, no_color=no_color, soft_wrap=True)
+    _stderr_console = PipConsole(file=sys.stderr, no_color=no_color, soft_wrap=True)
 
     logging.config.dictConfig(
         {
@@ -311,16 +321,14 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
                 "console": {
                     "level": level,
                     "class": handler_classes["stream"],
-                    "no_color": no_color,
-                    "stream": log_streams["stdout"],
+                    "console": _stdout_console,
                     "filters": ["exclude_subprocess", "exclude_warnings"],
                     "formatter": "indent",
                 },
                 "console_errors": {
                     "level": "WARNING",
                     "class": handler_classes["stream"],
-                    "no_color": no_color,
-                    "stream": log_streams["stderr"],
+                    "console": _stderr_console,
                     "filters": ["exclude_subprocess"],
                     "formatter": "indent",
                 },
@@ -329,8 +337,7 @@ def setup_logging(verbosity: int, no_color: bool, user_log_file: Optional[str])
                 "console_subprocess": {
                     "level": level,
                     "class": handler_classes["stream"],
-                    "stream": log_streams["stderr"],
-                    "no_color": no_color,
+                    "console": _stderr_console,
                     "filters": ["restrict_to_subprocess"],
                     "formatter": "indent",
                 },


Test patch:
diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py
index f673ed29d..a227bd67f 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -10,6 +10,7 @@ import pytest
 from pip._internal.utils.logging import (
     BrokenStdoutLoggingError,
     IndentingFormatter,
+    PipConsole,
     RichPipStreamHandler,
     indent_log,
 )
@@ -142,7 +143,8 @@ class TestColorizedStreamHandler:
         record = self._make_log_record()
 
         with redirect_stderr(StringIO()) as stderr:
-            handler = RichPipStreamHandler(stream=stderr, no_color=True)
+            console = PipConsole(file=stderr, no_color=True, soft_wrap=True)
+            handler = RichPipStreamHandler(console)
             with patch("sys.stderr.flush") as mock_flush:
                 mock_flush.side_effect = BrokenPipeError()
                 # The emit() call raises no exception.
@@ -165,7 +167,8 @@ class TestColorizedStreamHandler:
         record = self._make_log_record()
 
         with redirect_stdout(StringIO()) as stdout:
-            handler = RichPipStreamHandler(stream=stdout, no_color=True)
+            console = PipConsole(file=stdout, no_color=True, soft_wrap=True)
+            handler = RichPipStreamHandler(console)
             with patch("sys.stdout.write") as mock_write:
                 mock_write.side_effect = BrokenPipeError()
                 with pytest.raises(BrokenStdoutLoggingError):
@@ -180,7 +183,8 @@ class TestColorizedStreamHandler:
         record = self._make_log_record()
 
         with redirect_stdout(StringIO()) as stdout:
-            handler = RichPipStreamHandler(stream=stdout, no_color=True)
+            console = PipConsole(file=stdout, no_color=True, soft_wrap=True)
+            handler = RichPipStreamHandler(console)
             with patch("sys.stdout.flush") as mock_flush:
                 mock_flush.side_effect = BrokenPipeError()
                 with pytest.raises(BrokenStdoutLoggingError):