Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/12712.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Display a transient progress bar during package installation.
51 changes: 45 additions & 6 deletions src/pip/_internal/cli/progress_bars.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,12 +17,14 @@
)

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,
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice trick!

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],
*,
Expand All @@ -81,14 +106,28 @@ def write_progress(current: int, total: int) -> None:

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
1 change: 1 addition & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ def run(self, options: Values, args: List[str]) -> int:
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(
Expand Down
15 changes: 14 additions & 1 deletion src/pip/_internal/req/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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
Expand Down Expand Up @@ -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.
Expand All @@ -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():
Expand Down
33 changes: 20 additions & 13 deletions src/pip/_internal/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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,
Expand All @@ -29,6 +29,8 @@
from pip._internal.utils.misc import ensure_dir

_log_state = threading.local()
_stdout_console = None
_stderr_console = None
subprocess_logger = getLogger("pip.subprocessor")


Expand Down Expand Up @@ -144,12 +146,21 @@ def on_broken_pipe(self) -> None:
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,
Expand Down Expand Up @@ -266,17 +277,16 @@ 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",
}
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(
{
Expand Down Expand Up @@ -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",
},
Expand All @@ -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",
},
Expand Down
10 changes: 7 additions & 3 deletions tests/unit/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pip._internal.utils.logging import (
BrokenStdoutLoggingError,
IndentingFormatter,
PipConsole,
RichPipStreamHandler,
indent_log,
)
Expand Down Expand Up @@ -142,7 +143,8 @@ def test_broken_pipe_in_stderr_flush(self) -> None:
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.
Expand All @@ -165,7 +167,8 @@ def test_broken_pipe_in_stdout_write(self) -> None:
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):
Expand All @@ -180,7 +183,8 @@ def test_broken_pipe_in_stdout_flush(self) -> None:
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):
Expand Down
Loading