From 35349891d166faa5c6929dcd5e9ad7cf7015e539 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 12 Feb 2025 15:00:02 -0500 Subject: [PATCH 1/2] Add get_console() to query global rich consoles For an install progress bar, we'd like to emit logs while the progress bar updates (for uninstallation messages, etc.). To avoid interwoven logs, we need to log to the same console that the progress bar is using. This is easiest to achieve by simply storing a global stdout and stderr console, queried via a get_console() helper. --- src/pip/_internal/utils/logging.py | 33 ++++++++++++++++++------------ tests/unit/test_logging.py | 10 ++++++--- 2 files changed, 27 insertions(+), 16 deletions(-) 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): From 24e364eb7366c263218876beeefae7897a4d08c8 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 12 Feb 2025 16:34:20 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Installation=20progress=20bar=20?= =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- news/12712.feature.rst | 1 + src/pip/_internal/cli/progress_bars.py | 51 +++++++++++++++++++++++--- src/pip/_internal/commands/install.py | 1 + src/pip/_internal/req/__init__.py | 15 +++++++- 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 news/12712.feature.rst 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():