From d7a76d33f72ba589d258f1b57991111816ea3ce4 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Mon, 12 Jun 2023 22:47:27 -0400 Subject: [PATCH 01/25] Add machine readable download progress option --- src/pip/_internal/cli/cmdoptions.py | 4 ++-- src/pip/_internal/cli/progress_bars.py | 27 +++++++++++++++++++++++++- src/pip/_internal/network/download.py | 4 +++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 02ba6082793..ec5bc64214f 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -226,9 +226,9 @@ class PipOption(Option): "--progress-bar", dest="progress_bar", type="choice", - choices=["on", "off"], + choices=["on", "off", "machine-readable"], default="on", - help="Specify whether the progress bar should be used [on, off] (default: on)", + help="Specify whether the progress bar should be used [on, off, machine-readable] (default: on)", ) log: Callable[..., Option] = partial( diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 0ad14031ca5..0e3793b4b3a 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -55,8 +55,31 @@ def _rich_progress_bar( progress.update(task_id, advance=len(chunk)) +class _MachineReadableProgress: + def __init__( + self, iterable: Iterable[bytes], size: int, filename: Optional[str] = None + ) -> None: + self._iterable = iterable + self._size = size + self._progress = 0 + self._filename = filename + + def __iter__(self) -> Iterator[bytes]: + return self + + def __next__(self) -> bytes: + chunk = next(self._iterable) + self._progress += len(chunk) + percent = str(round((self._progress / self._size) * 100, 1)) + print( + f"PROGRESS: {self._filename} | {self._progress}/{self._size} | {percent}%", + flush=True, + ) + return chunk + + def get_download_progress_renderer( - *, bar_type: str, size: Optional[int] = None + *, bar_type: str, size: Optional[int] = None, filename: Optional[str] = None ) -> DownloadProgressRenderer: """Get an object that can be used to render the download progress. @@ -64,5 +87,7 @@ def get_download_progress_renderer( """ if bar_type == "on": return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size) + elif bar_type == "machine-readable": + return functools.partial(_MachineReadableProgress, size=size, filename=filename) else: return iter # no-op, when passed an iterator diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py index 79b82a570e5..fa91c95434a 100644 --- a/src/pip/_internal/network/download.py +++ b/src/pip/_internal/network/download.py @@ -65,7 +65,9 @@ def _prepare_download( if not show_progress: return chunks - renderer = get_download_progress_renderer(bar_type=progress_bar, size=total_length) + renderer = get_download_progress_renderer( + bar_type=progress_bar, size=total_length, filename=link.filename + ) return renderer(chunks) From 13c04314290cf4cf667bcc02eae1c23f538b86cd Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Mon, 12 Jun 2023 22:49:23 -0400 Subject: [PATCH 02/25] Adds news file --- news/11508.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/11508.feature.rst diff --git a/news/11508.feature.rst b/news/11508.feature.rst new file mode 100644 index 00000000000..f3b4e7e8eb4 --- /dev/null +++ b/news/11508.feature.rst @@ -0,0 +1 @@ +Add a new progress_bar type that allows machine-readable download progress \ No newline at end of file From 0045fd8adee7b0f73485cefb650f9464cc9ca318 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Mon, 12 Jun 2023 23:05:35 -0400 Subject: [PATCH 03/25] Fix mypy error --- src/pip/_internal/cli/progress_bars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 0e3793b4b3a..f8beb775f0e 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -59,7 +59,7 @@ class _MachineReadableProgress: def __init__( self, iterable: Iterable[bytes], size: int, filename: Optional[str] = None ) -> None: - self._iterable = iterable + self._iterable = iter(iterable) self._size = size self._progress = 0 self._filename = filename From 5f7ab610a5e46ade385e772131e470216aa55c59 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Mon, 12 Jun 2023 23:08:43 -0400 Subject: [PATCH 04/25] lint --- src/pip/_internal/cli/cmdoptions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index ec5bc64214f..80a112fe6af 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -228,7 +228,9 @@ class PipOption(Option): type="choice", choices=["on", "off", "machine-readable"], default="on", - help="Specify whether the progress bar should be used [on, off, machine-readable] (default: on)", + help=( + "Specify whether the progress bar should be used [on, off, machine-readable] (default: on)" + ), ) log: Callable[..., Option] = partial( From 51273299717166786f425a79adeae5db1ca787ec Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 00:13:50 -0400 Subject: [PATCH 05/25] line length --- src/pip/_internal/cli/cmdoptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 80a112fe6af..43add73e991 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -229,7 +229,8 @@ class PipOption(Option): choices=["on", "off", "machine-readable"], default="on", help=( - "Specify whether the progress bar should be used [on, off, machine-readable] (default: on)" + "Specify whether the progress bar should be used" + " [on, off, machine-readable] (default: on)" ), ) From 813aac6ad62ef75c63331fba044fb5e1646b67ac Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 00:18:10 -0400 Subject: [PATCH 06/25] add newline --- news/11508.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/11508.feature.rst b/news/11508.feature.rst index f3b4e7e8eb4..8a6f9b65da3 100644 --- a/news/11508.feature.rst +++ b/news/11508.feature.rst @@ -1 +1 @@ -Add a new progress_bar type that allows machine-readable download progress \ No newline at end of file +Add a new progress_bar type that allows machine-readable download progress From 98742eaeb93cf7df391f71e034cc65a893d45be0 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 09:48:15 -0400 Subject: [PATCH 07/25] use json --- news/11508.feature.rst | 2 +- src/pip/_internal/cli/cmdoptions.py | 4 ++-- src/pip/_internal/cli/progress_bars.py | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/news/11508.feature.rst b/news/11508.feature.rst index 8a6f9b65da3..2a3310a2289 100644 --- a/news/11508.feature.rst +++ b/news/11508.feature.rst @@ -1 +1 @@ -Add a new progress_bar type that allows machine-readable download progress +Add a new progress_bar type that allows machine-readable (json) download progress diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 43add73e991..33851d77412 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -226,11 +226,11 @@ class PipOption(Option): "--progress-bar", dest="progress_bar", type="choice", - choices=["on", "off", "machine-readable"], + choices=["on", "off", "json"], default="on", help=( "Specify whether the progress bar should be used" - " [on, off, machine-readable] (default: on)" + " [on, off, json] (default: on)" ), ) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index f8beb775f0e..dd6a5db4cd6 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -16,6 +16,8 @@ from pip._internal.utils.logging import get_indentation +from json import dumps + DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] @@ -70,9 +72,13 @@ def __iter__(self) -> Iterator[bytes]: def __next__(self) -> bytes: chunk = next(self._iterable) self._progress += len(chunk) - percent = str(round((self._progress / self._size) * 100, 1)) + progress_info = { + "file": self._filename, + "current": self._progress, + "total": self._size, + } print( - f"PROGRESS: {self._filename} | {self._progress}/{self._size} | {percent}%", + f"PROGRESS:{dumps(progress_info)}", flush=True, ) return chunk @@ -87,7 +93,7 @@ def get_download_progress_renderer( """ if bar_type == "on": return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size) - elif bar_type == "machine-readable": + elif bar_type == "json": return functools.partial(_MachineReadableProgress, size=size, filename=filename) else: return iter # no-op, when passed an iterator From 9080d82d8d0743f6d9b58d410a2dd04d831708ec Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 09:51:06 -0400 Subject: [PATCH 08/25] fix import sorting --- src/pip/_internal/cli/progress_bars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index dd6a5db4cd6..bd2a19a84b0 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -1,4 +1,5 @@ import functools +from json import dumps from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple from pip._vendor.rich.progress import ( @@ -16,7 +17,6 @@ from pip._internal.utils.logging import get_indentation -from json import dumps DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] From 70ae2421c6f11ed31ebe17613e155a39c4d00d35 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 09:55:24 -0400 Subject: [PATCH 09/25] format --- src/pip/_internal/cli/progress_bars.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index bd2a19a84b0..4798881cc6b 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -17,7 +17,6 @@ from pip._internal.utils.logging import get_indentation - DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] From 13c11ee879b88f15f45db8a6ecf51ec14ed8e64b Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 17:28:48 -0400 Subject: [PATCH 10/25] PR suggestions --- src/pip/_internal/cli/progress_bars.py | 14 +++++--------- src/pip/_internal/network/download.py | 4 +--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 4798881cc6b..f58cc95306a 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -1,5 +1,5 @@ import functools -from json import dumps +import json from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple from pip._vendor.rich.progress import ( @@ -57,13 +57,10 @@ def _rich_progress_bar( class _MachineReadableProgress: - def __init__( - self, iterable: Iterable[bytes], size: int, filename: Optional[str] = None - ) -> None: + def __init__(self, iterable: Iterable[bytes], size: Optional[int]) -> None: self._iterable = iter(iterable) self._size = size self._progress = 0 - self._filename = filename def __iter__(self) -> Iterator[bytes]: return self @@ -72,19 +69,18 @@ def __next__(self) -> bytes: chunk = next(self._iterable) self._progress += len(chunk) progress_info = { - "file": self._filename, "current": self._progress, "total": self._size, } print( - f"PROGRESS:{dumps(progress_info)}", + f"PROGRESS:{json.dumps(progress_info)}", flush=True, ) return chunk def get_download_progress_renderer( - *, bar_type: str, size: Optional[int] = None, filename: Optional[str] = None + *, bar_type: str, size: Optional[int] = None ) -> DownloadProgressRenderer: """Get an object that can be used to render the download progress. @@ -93,6 +89,6 @@ def get_download_progress_renderer( if bar_type == "on": return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size) elif bar_type == "json": - return functools.partial(_MachineReadableProgress, size=size, filename=filename) + return functools.partial(_MachineReadableProgress, size=size) else: return iter # no-op, when passed an iterator diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py index fa91c95434a..79b82a570e5 100644 --- a/src/pip/_internal/network/download.py +++ b/src/pip/_internal/network/download.py @@ -65,9 +65,7 @@ def _prepare_download( if not show_progress: return chunks - renderer = get_download_progress_renderer( - bar_type=progress_bar, size=total_length, filename=link.filename - ) + renderer = get_download_progress_renderer(bar_type=progress_bar, size=total_length) return renderer(chunks) From d0d96d9e4123d38d44031d604e864756c0bf1aa6 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 17:51:37 -0400 Subject: [PATCH 11/25] use logger --- src/pip/_internal/cli/progress_bars.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index f58cc95306a..0dc5ffdd1de 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -1,5 +1,7 @@ import functools import json +import logging +import sys from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple from pip._vendor.rich.progress import ( @@ -17,6 +19,8 @@ from pip._internal.utils.logging import get_indentation +logger = logging.getLogger(__name__) + DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] @@ -72,9 +76,9 @@ def __next__(self) -> bytes: "current": self._progress, "total": self._size, } - print( - f"PROGRESS:{json.dumps(progress_info)}", - flush=True, + logger.info( + "PROGRESS:%s", + json.dumps(progress_info), ) return chunk @@ -89,6 +93,11 @@ def get_download_progress_renderer( if bar_type == "on": return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size) elif bar_type == "json": + # We don't want regular users to use this progress_bar type + # so only use if not a TTY + assert ( + not sys.stdout.isatty() + ), 'The "json" progress_bar type should only be used inside subprocesses.' return functools.partial(_MachineReadableProgress, size=size) else: return iter # no-op, when passed an iterator From f4ca35461d3a8f0dc2aa3aa2086b8298d3ff261b Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 18:19:40 -0400 Subject: [PATCH 12/25] add docs to user guide --- docs/html/user_guide.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 9a6f2901cd5..3a70b09eecb 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -855,6 +855,20 @@ We are using `freeze`_ here which outputs installed packages in requirements for reqs = subprocess.check_output([sys.executable, '-m', 'pip', 'freeze']) +Since pip's progress bar gets hidden when running in a subprocess, you can use +the ``--progress-bar=json`` option for easily parsable progress information:: + + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'my_package', '--progress-bar=json']) + +Which will give the following output after it processes each download chunk: + +``PROGRESS:{"current": ######, "total": ######}`` + +Here, ``PROGRESS:`` indicates it is download progress. The rest of the message is JSON +with the ``current`` number of bytes downloaded and ``total`` .whl size as key/value pairs. +This can be used to build your own progress bar, or report progress in other ways. +This feature cannot be used unless pip is invoked in a subprocess. + If you don't want to use pip's command line functionality, but are rather trying to implement code that works with Python packages, their metadata, or PyPI, then you should consider other, supported, packages that offer this type From dfc4d98506b99eb9dda47cd3ef2556c1ad87dd84 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 18:51:33 -0400 Subject: [PATCH 13/25] add test --- tests/functional/test_install_progress.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/functional/test_install_progress.py diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py new file mode 100644 index 00000000000..36391e4dfae --- /dev/null +++ b/tests/functional/test_install_progress.py @@ -0,0 +1,20 @@ +from tests.lib import ( + PipTestEnvironment, +) + + +def test_install_with_json_progress(script: PipTestEnvironment) -> None: + """ + Test installing a package using pip install --progress-bar=json + but not as a subprocess + """ + result = script.pip( + "install", + "pkg==0.1", + "--progress-bar=json", + expect_error=True, + ) + assert ( + 'The "json" progress_bar type should only be used inside subprocesses.' + in result.stderr + ) From d1d6a69ffffc9ccaa1f15afcd7e542ae569a3edf Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 19:15:16 -0400 Subject: [PATCH 14/25] fix test, maybe --- tests/functional/test_install_progress.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py index 36391e4dfae..c2bb2d414a9 100644 --- a/tests/functional/test_install_progress.py +++ b/tests/functional/test_install_progress.py @@ -10,7 +10,10 @@ def test_install_with_json_progress(script: PipTestEnvironment) -> None: """ result = script.pip( "install", - "pkg==0.1", + "simple==1.0", + "--no-index", + "--find-links", + script.scratch_path, "--progress-bar=json", expect_error=True, ) From 5f7782a4ae81991b9b5a0766538ebe5e0d1c2766 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 23:09:33 -0400 Subject: [PATCH 15/25] maybe this time --- tests/functional/test_install_progress.py | 35 +++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py index c2bb2d414a9..8d765a50c36 100644 --- a/tests/functional/test_install_progress.py +++ b/tests/functional/test_install_progress.py @@ -1,19 +1,22 @@ from tests.lib import ( PipTestEnvironment, + TestData, ) +import subprocess -def test_install_with_json_progress(script: PipTestEnvironment) -> None: +def test_install_with_json_progress_cli( + script: PipTestEnvironment, data: TestData +) -> None: """ Test installing a package using pip install --progress-bar=json but not as a subprocess """ result = script.pip( "install", - "simple==1.0", - "--no-index", - "--find-links", - script.scratch_path, + "dinner", + "--index-url", + data.find_links3, "--progress-bar=json", expect_error=True, ) @@ -21,3 +24,25 @@ def test_install_with_json_progress(script: PipTestEnvironment) -> None: 'The "json" progress_bar type should only be used inside subprocesses.' in result.stderr ) + + +def test_install_with_json_progress_subproc( + _script: PipTestEnvironment, data: TestData +) -> None: + """ + Test installing a package using pip install --progress-bar=json + but not as a subprocess + """ + result = subprocess.check_output( + [ + "python", + "-m", + "pip", + "install", + "dinner", + "--index-url", + data.find_links3, + "--progress-bar=json", + ] + ) + assert "PROGRESS:" in result.decode("utf-8") From 71f34443447b26e171eca6dcf31f04b2ed789802 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 13 Jun 2023 23:29:24 -0400 Subject: [PATCH 16/25] attempting just using a network package --- tests/functional/test_install_progress.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py index 8d765a50c36..e88d7a85486 100644 --- a/tests/functional/test_install_progress.py +++ b/tests/functional/test_install_progress.py @@ -1,22 +1,19 @@ +import pytest from tests.lib import ( PipTestEnvironment, - TestData, ) import subprocess -def test_install_with_json_progress_cli( - script: PipTestEnvironment, data: TestData -) -> None: +@pytest.mark.network +def test_install_with_json_progress_cli(script: PipTestEnvironment) -> None: """ Test installing a package using pip install --progress-bar=json but not as a subprocess """ result = script.pip( "install", - "dinner", - "--index-url", - data.find_links3, + "opencv-python", "--progress-bar=json", expect_error=True, ) @@ -26,9 +23,8 @@ def test_install_with_json_progress_cli( ) -def test_install_with_json_progress_subproc( - _script: PipTestEnvironment, data: TestData -) -> None: +@pytest.mark.network +def test_install_with_json_progress_subproc(_script: PipTestEnvironment) -> None: """ Test installing a package using pip install --progress-bar=json but not as a subprocess @@ -39,9 +35,7 @@ def test_install_with_json_progress_subproc( "-m", "pip", "install", - "dinner", - "--index-url", - data.find_links3, + "opencv-python", "--progress-bar=json", ] ) From 14eb09dfa77809601360c1b3d8e27eebaed757f3 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 14 Jun 2023 00:10:38 -0400 Subject: [PATCH 17/25] lint --- tests/functional/test_install_progress.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py index e88d7a85486..76ee6983bd5 100644 --- a/tests/functional/test_install_progress.py +++ b/tests/functional/test_install_progress.py @@ -1,8 +1,8 @@ import pytest +import subprocess from tests.lib import ( PipTestEnvironment, ) -import subprocess @pytest.mark.network From 7567e2a30987ac63d07f201c27021a2032fd37ca Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Thu, 15 Jun 2023 01:12:07 -0400 Subject: [PATCH 18/25] PR suggestions --- docs/html/user_guide.rst | 10 ++++-- src/pip/_internal/cli/progress_bars.py | 27 +++++++++++---- tests/functional/test_install_progress.py | 42 ----------------------- 3 files changed, 29 insertions(+), 50 deletions(-) delete mode 100644 tests/functional/test_install_progress.py diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 3a70b09eecb..4890bcd0a9d 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -862,13 +862,19 @@ the ``--progress-bar=json`` option for easily parsable progress information:: Which will give the following output after it processes each download chunk: -``PROGRESS:{"current": ######, "total": ######}`` +``Progress: {"current": ######, "total": ######}`` -Here, ``PROGRESS:`` indicates it is download progress. The rest of the message is JSON +Here, ``Progress:`` indicates it is download progress. The rest of the message is JSON with the ``current`` number of bytes downloaded and ``total`` .whl size as key/value pairs. +Note: ``total`` is optional and may be null. + This can be used to build your own progress bar, or report progress in other ways. This feature cannot be used unless pip is invoked in a subprocess. +NOTE: Relying on the exact form of pip's output is unsupported, and so should not be used in +production applications unless you are willing to adapt when pip's output changes. + + If you don't want to use pip's command line functionality, but are rather trying to implement code that works with Python packages, their metadata, or PyPI, then you should consider other, supported, packages that offer this type diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 0dc5ffdd1de..6b046ecf205 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -19,6 +19,8 @@ from pip._internal.utils.logging import get_indentation +from pip._internal.cli.spinners import RateLimiter + logger = logging.getLogger(__name__) DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] @@ -61,10 +63,17 @@ def _rich_progress_bar( class _MachineReadableProgress: - def __init__(self, iterable: Iterable[bytes], size: Optional[int]) -> None: + def __init__( + self, + iterable: Iterable[bytes], + size: Optional[int], + # Copying the default from spinners.py + min_update_interval_seconds: float = 0.125, + ) -> None: self._iterable = iter(iterable) self._size = size self._progress = 0 + self._rate_limiter = RateLimiter(min_update_interval_seconds) def __iter__(self) -> Iterator[bytes]: return self @@ -76,10 +85,12 @@ def __next__(self) -> bytes: "current": self._progress, "total": self._size, } - logger.info( - "PROGRESS:%s", - json.dumps(progress_info), - ) + if not self._rate_limiter.ready(): + return chunk + # Writing to stdout directly blocks printing out progress in subprocesses + # So we have to use print here + print(f"Progress: {json.dumps(progress_info)}", flush=True) + self._rate_limiter.reset() return chunk @@ -98,6 +109,10 @@ def get_download_progress_renderer( assert ( not sys.stdout.isatty() ), 'The "json" progress_bar type should only be used inside subprocesses.' - return functools.partial(_MachineReadableProgress, size=size) + # Mimic log level + if logger.getEffectiveLevel() <= logging.INFO: + return functools.partial(_MachineReadableProgress, size=size) + else: + return iter else: return iter # no-op, when passed an iterator diff --git a/tests/functional/test_install_progress.py b/tests/functional/test_install_progress.py deleted file mode 100644 index 76ee6983bd5..00000000000 --- a/tests/functional/test_install_progress.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -import subprocess -from tests.lib import ( - PipTestEnvironment, -) - - -@pytest.mark.network -def test_install_with_json_progress_cli(script: PipTestEnvironment) -> None: - """ - Test installing a package using pip install --progress-bar=json - but not as a subprocess - """ - result = script.pip( - "install", - "opencv-python", - "--progress-bar=json", - expect_error=True, - ) - assert ( - 'The "json" progress_bar type should only be used inside subprocesses.' - in result.stderr - ) - - -@pytest.mark.network -def test_install_with_json_progress_subproc(_script: PipTestEnvironment) -> None: - """ - Test installing a package using pip install --progress-bar=json - but not as a subprocess - """ - result = subprocess.check_output( - [ - "python", - "-m", - "pip", - "install", - "opencv-python", - "--progress-bar=json", - ] - ) - assert "PROGRESS:" in result.decode("utf-8") From c0fbf132fcbd4ef7a259eb4d2016677740e236ce Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Thu, 15 Jun 2023 01:20:26 -0400 Subject: [PATCH 19/25] I didn't see the comment about using -u --- src/pip/_internal/cli/progress_bars.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 6b046ecf205..f24b675d5a8 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -87,9 +87,8 @@ def __next__(self) -> bytes: } if not self._rate_limiter.ready(): return chunk - # Writing to stdout directly blocks printing out progress in subprocesses - # So we have to use print here - print(f"Progress: {json.dumps(progress_info)}", flush=True) + sys.stdout.write(f"Progress: {json.dumps(progress_info)}\n") + sys.stdout.flush() self._rate_limiter.reset() return chunk From 31dee38c87e35819196739d99d0d64a3b66bbfa3 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Thu, 15 Jun 2023 01:47:47 -0400 Subject: [PATCH 20/25] lint + add a test (that may or may not work) --- src/pip/_internal/cli/progress_bars.py | 3 +-- tests/unit/test_network_download.py | 28 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index f24b675d5a8..73138d89f98 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -17,9 +17,8 @@ TransferSpeedColumn, ) -from pip._internal.utils.logging import get_indentation - from pip._internal.cli.spinners import RateLimiter +from pip._internal.utils.logging import get_indentation logger = logging.getLogger(__name__) diff --git a/tests/unit/test_network_download.py b/tests/unit/test_network_download.py index 53200f2e511..ae63077e914 100644 --- a/tests/unit/test_network_download.py +++ b/tests/unit/test_network_download.py @@ -71,6 +71,34 @@ def test_prepare_download__log( assert expected in record.message +@pytest.mark.parametrize( + "url, expected", + [ + ( + "http://example.com/foo.tgz", + {}, + False, + 'The "json" progress_bar type should only be used inside subprocesses.', + ), + ], +) +def test_prepare_download__json( + caplog: pytest.LogCaptureFixture, + url: str, + expected: str, +) -> None: + caplog.set_level(logging.INFO) + resp = MockResponse(b"") + resp.url = url + link = Link(url) + _prepare_download(resp, link, progress_bar="json") + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelname == "INFO" + assert expected in record.message + + @pytest.mark.parametrize( "filename, expected", [ From d73336b4ff80ac070721b2481697f71fbf11b15c Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Thu, 15 Jun 2023 19:39:52 -0400 Subject: [PATCH 21/25] remove bad test --- tests/unit/test_network_download.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/unit/test_network_download.py b/tests/unit/test_network_download.py index ae63077e914..53200f2e511 100644 --- a/tests/unit/test_network_download.py +++ b/tests/unit/test_network_download.py @@ -71,34 +71,6 @@ def test_prepare_download__log( assert expected in record.message -@pytest.mark.parametrize( - "url, expected", - [ - ( - "http://example.com/foo.tgz", - {}, - False, - 'The "json" progress_bar type should only be used inside subprocesses.', - ), - ], -) -def test_prepare_download__json( - caplog: pytest.LogCaptureFixture, - url: str, - expected: str, -) -> None: - caplog.set_level(logging.INFO) - resp = MockResponse(b"") - resp.url = url - link = Link(url) - _prepare_download(resp, link, progress_bar="json") - - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.levelname == "INFO" - assert expected in record.message - - @pytest.mark.parametrize( "filename, expected", [ From 15f0c8acb42de32ca7d2d6549da52e0493c404be Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Fri, 23 Jun 2023 21:39:17 -0400 Subject: [PATCH 22/25] Update with more realistic example --- docs/html/user_guide.rst | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 4890bcd0a9d..d41ff917b3f 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -858,7 +858,40 @@ We are using `freeze`_ here which outputs installed packages in requirements for Since pip's progress bar gets hidden when running in a subprocess, you can use the ``--progress-bar=json`` option for easily parsable progress information:: - subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'my_package', '--progress-bar=json']) + import subprocess + import sys + import json + + python_path = sys.executable + + process = subprocess.Popen( + [ + python_path, + "-m", + "pip", + "install", + "numpy", + "opencv-python", + "scipy", + "--progress-bar=json", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + while True: + nextline = process.stdout.readline() + if nextline == b"" and process.poll() is not None: + break + line = nextline.decode("utf-8").strip() + + if "Progress:" in line: + json_line = line.replace("Progress:", "").strip() + parsed = json.loads(json_line) + current, total = parsed["current"], parsed["total"] + if total is not None and total > 0: + percent = current / total * 100 + print(f"Download at: {percent}%") + Which will give the following output after it processes each download chunk: @@ -869,8 +902,12 @@ with the ``current`` number of bytes downloaded and ``total`` .whl size as key/v Note: ``total`` is optional and may be null. This can be used to build your own progress bar, or report progress in other ways. +In the code example above, we just print the current parsed status of the download. This feature cannot be used unless pip is invoked in a subprocess. +NOTE: For this to work properly, you may need to run python with the ``-u`` flag +to ensure that the output is unbuffered. + NOTE: Relying on the exact form of pip's output is unsupported, and so should not be used in production applications unless you are willing to adapt when pip's output changes. From 23f95d2dff04291c0f66f3159a8fcbe5b0473b8f Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Fri, 23 Jun 2023 21:40:35 -0400 Subject: [PATCH 23/25] update null verbiage --- docs/html/user_guide.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index d41ff917b3f..c2762ebe6c4 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -899,7 +899,8 @@ Which will give the following output after it processes each download chunk: Here, ``Progress:`` indicates it is download progress. The rest of the message is JSON with the ``current`` number of bytes downloaded and ``total`` .whl size as key/value pairs. -Note: ``total`` is optional and may be null. +Note: ``total`` is may be null if the size of the package cannot be determined. This means +that it will either be null during the duration of the download, or not be null at all. This can be used to build your own progress bar, or report progress in other ways. In the code example above, we just print the current parsed status of the download. From fd16cbc0766ecdb2efc69b0117179bbc9dad5559 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Fri, 23 Jun 2023 21:46:10 -0400 Subject: [PATCH 24/25] error suggestion --- src/pip/_internal/cli/progress_bars.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index 73138d89f98..ca9a2bc4450 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -104,13 +104,17 @@ def get_download_progress_renderer( elif bar_type == "json": # We don't want regular users to use this progress_bar type # so only use if not a TTY - assert ( - not sys.stdout.isatty() - ), 'The "json" progress_bar type should only be used inside subprocesses.' + if sys.stdout.isatty(): + logger.warning( + """Using json progress bar type outside a subprocess is not recommended. +Using normal progress bar instead.""" + ) + return functools.partial(_rich_progress_bar, bar_type="on", size=size) + # Mimic log level if logger.getEffectiveLevel() <= logging.INFO: return functools.partial(_MachineReadableProgress, size=size) - else: - return iter + + return iter else: return iter # no-op, when passed an iterator From d81de4984d0ff2da81f843713b61ab09d31092cb Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Fri, 23 Jun 2023 21:49:04 -0400 Subject: [PATCH 25/25] ruff --- src/pip/_internal/cli/progress_bars.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/cli/progress_bars.py b/src/pip/_internal/cli/progress_bars.py index ca9a2bc4450..6f7267104f5 100644 --- a/src/pip/_internal/cli/progress_bars.py +++ b/src/pip/_internal/cli/progress_bars.py @@ -106,8 +106,8 @@ def get_download_progress_renderer( # so only use if not a TTY if sys.stdout.isatty(): logger.warning( - """Using json progress bar type outside a subprocess is not recommended. -Using normal progress bar instead.""" + """Using json progress bar type outside a subprocess is not recommended. + Using normal progress bar instead.""" ) return functools.partial(_rich_progress_bar, bar_type="on", size=size)