Skip to content

Commit 24e364e

Browse files
committed
feat: Installation progress bar ✨
Installation can be pretty slow so it'd be nice to provide progress feedback to the user. This commit adds a new progress renderer designed for installation: - 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.
1 parent 3534989 commit 24e364e

File tree

4 files changed

+61
-7
lines changed

4 files changed

+61
-7
lines changed

news/12712.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Display a transient progress bar during package installation.

src/pip/_internal/cli/progress_bars.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import functools
22
import sys
3-
from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple
3+
from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple, TypeVar
44

55
from pip._vendor.rich.progress import (
66
BarColumn,
77
DownloadColumn,
88
FileSizeColumn,
9+
MofNCompleteColumn,
910
Progress,
1011
ProgressColumn,
1112
SpinnerColumn,
@@ -16,12 +17,14 @@
1617
)
1718

1819
from pip._internal.cli.spinners import RateLimiter
19-
from pip._internal.utils.logging import get_indentation
20+
from pip._internal.req.req_install import InstallRequirement
21+
from pip._internal.utils.logging import get_console, get_indentation
2022

21-
DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]]
23+
T = TypeVar("T")
24+
ProgressRenderer = Callable[[Iterable[T]], Iterator[T]]
2225

2326

24-
def _rich_progress_bar(
27+
def _rich_download_progress_bar(
2528
iterable: Iterable[bytes],
2629
*,
2730
bar_type: str,
@@ -57,6 +60,28 @@ def _rich_progress_bar(
5760
progress.update(task_id, advance=len(chunk))
5861

5962

63+
def _rich_install_progress_bar(
64+
iterable: Iterable[InstallRequirement], *, total: int
65+
) -> Iterator[InstallRequirement]:
66+
columns = (
67+
TextColumn("{task.fields[indent]}"),
68+
BarColumn(),
69+
MofNCompleteColumn(),
70+
TextColumn("{task.description}"),
71+
)
72+
console = get_console()
73+
74+
bar = Progress(*columns, refresh_per_second=6, console=console, transient=True)
75+
# Hiding the progress bar at initialization forces a refresh cycle to occur
76+
# until the bar appears, avoiding very short flashes.
77+
task = bar.add_task("", total=total, indent=" " * get_indentation(), visible=False)
78+
with bar:
79+
for req in iterable:
80+
bar.update(task, description=rf"\[{req.name}]", visible=True)
81+
yield req
82+
bar.advance(task)
83+
84+
6085
def _raw_progress_bar(
6186
iterable: Iterable[bytes],
6287
*,
@@ -81,14 +106,28 @@ def write_progress(current: int, total: int) -> None:
81106

82107
def get_download_progress_renderer(
83108
*, bar_type: str, size: Optional[int] = None
84-
) -> DownloadProgressRenderer:
109+
) -> ProgressRenderer[bytes]:
85110
"""Get an object that can be used to render the download progress.
86111
87112
Returns a callable, that takes an iterable to "wrap".
88113
"""
89114
if bar_type == "on":
90-
return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size)
115+
return functools.partial(
116+
_rich_download_progress_bar, bar_type=bar_type, size=size
117+
)
91118
elif bar_type == "raw":
92119
return functools.partial(_raw_progress_bar, size=size)
93120
else:
94121
return iter # no-op, when passed an iterator
122+
123+
124+
def get_install_progress_renderer(
125+
*, bar_type: str, total: int
126+
) -> ProgressRenderer[InstallRequirement]:
127+
"""Get an object that can be used to render the install progress.
128+
Returns a callable, that takes an iterable to "wrap".
129+
"""
130+
if bar_type == "on":
131+
return functools.partial(_rich_install_progress_bar, total=total)
132+
else:
133+
return iter

src/pip/_internal/commands/install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ def run(self, options: Values, args: List[str]) -> int:
464464
warn_script_location=warn_script_location,
465465
use_user_site=options.use_user_site,
466466
pycompile=options.compile,
467+
progress_bar=options.progress_bar,
467468
)
468469

469470
lib_locations = get_lib_location_guesses(

src/pip/_internal/req/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import dataclass
44
from typing import Generator, List, Optional, Sequence, Tuple
55

6+
from pip._internal.cli.progress_bars import get_install_progress_renderer
67
from pip._internal.utils.logging import indent_log
78

89
from .req_file import parse_requirements
@@ -41,6 +42,7 @@ def install_given_reqs(
4142
warn_script_location: bool,
4243
use_user_site: bool,
4344
pycompile: bool,
45+
progress_bar: str,
4446
) -> List[InstallationResult]:
4547
"""
4648
Install everything in the given list.
@@ -57,8 +59,19 @@ def install_given_reqs(
5759

5860
installed = []
5961

62+
show_progress = logger.isEnabledFor(logging.INFO) and len(to_install) > 1
63+
64+
items = iter(to_install.values())
65+
if show_progress:
66+
renderer = get_install_progress_renderer(
67+
bar_type=progress_bar, total=len(to_install)
68+
)
69+
items = renderer(items)
70+
6071
with indent_log():
61-
for req_name, requirement in to_install.items():
72+
for requirement in items:
73+
req_name = requirement.name
74+
assert req_name is not None
6275
if requirement.should_reinstall:
6376
logger.info("Attempting uninstall: %s", req_name)
6477
with indent_log():

0 commit comments

Comments
 (0)