diff --git a/news/12712.feature.rst b/news/12712.feature.rst new file mode 100644 index 00000000000..adba2bf8451 --- /dev/null +++ b/news/12712.feature.rst @@ -0,0 +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 3d9dde8ed88..a7d77cfaa8f 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._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 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 diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 232a34a6d3e..5239d010421 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -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( diff --git a/src/pip/_internal/req/__init__.py b/src/pip/_internal/req/__init__.py index 422d851d729..bf282dab8bc 100644 --- a/src/pip/_internal/req/__init__.py +++ b/src/pip/_internal/req/__init__.py @@ -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 @@ -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 62035fc40ec..099a92c496d 100644 --- a/src/pip/_internal/utils/logging.py +++ b/src/pip/_internal/utils/logging.py @@ -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, @@ -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") @@ -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, @@ -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", }, diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index f673ed29def..a227bd67fa2 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -10,6 +10,7 @@ from pip._internal.utils.logging import ( BrokenStdoutLoggingError, IndentingFormatter, + PipConsole, RichPipStreamHandler, indent_log, ) @@ -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. @@ -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): @@ -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):