|
| 1 | +From 4d3bacc72e85d03e3dd8c9fdb6e960774192c53c Mon Sep 17 00:00:00 2001 |
| 2 | +From: AllSpark <allspark@microsoft.com> |
| 3 | +Date: Mon, 4 May 2026 03:26:56 +0000 |
| 4 | +Subject: [PATCH] Backport split pip self-version check into fetch(before) and |
| 5 | + emit(after) phases; adjust context managers and tests; compute prompt based |
| 6 | + on cached/remote and installer; skip check in install when pip is target |
| 7 | + |
| 8 | +Signed-off-by: Azure Linux Security Servicing Account <azurelinux-security@microsoft.com> |
| 9 | +Upstream-reference: AI Backport of https://github.com/pypa/pip/pull/13923.patch |
| 10 | +--- |
| 11 | + src/pip/_internal/cli/base_command.py | 11 ++-- |
| 12 | + src/pip/_internal/cli/index_command.py | 35 +++++++++--- |
| 13 | + src/pip/_internal/commands/install.py | 6 ++ |
| 14 | + src/pip/_internal/commands/list.py | 13 +++-- |
| 15 | + src/pip/_internal/self_outdated_check.py | 73 ++++++++++++------------ |
| 16 | + tests/unit/test_base_command.py | 8 +-- |
| 17 | + tests/unit/test_commands.py | 9 ++- |
| 18 | + 7 files changed, 93 insertions(+), 62 deletions(-) |
| 19 | + |
| 20 | +diff --git a/src/pip/_internal/cli/base_command.py b/src/pip/_internal/cli/base_command.py |
| 21 | +index bc1ab65..4d81124 100644 |
| 22 | +--- a/src/pip/_internal/cli/base_command.py |
| 23 | ++++ b/src/pip/_internal/cli/base_command.py |
| 24 | +@@ -6,8 +6,9 @@ import optparse |
| 25 | + import os |
| 26 | + import sys |
| 27 | + import traceback |
| 28 | ++import contextlib |
| 29 | + from optparse import Values |
| 30 | +-from typing import List, Optional, Tuple |
| 31 | ++from typing import List, Optional, Tuple, Iterator |
| 32 | + |
| 33 | + from pip._vendor.rich import reconfigure |
| 34 | + from pip._vendor.rich import traceback as rich_traceback |
| 35 | +@@ -78,7 +79,8 @@ class Command(CommandContextMixIn): |
| 36 | + def add_options(self) -> None: |
| 37 | + pass |
| 38 | + |
| 39 | +- def handle_pip_version_check(self, options: Values) -> None: |
| 40 | ++ @contextlib.contextmanager |
| 41 | ++ def pip_version_check(self, options: Values, args: List[str]) -> Iterator[None]: |
| 42 | + """ |
| 43 | + This is a no-op so that commands by default do not do the pip version |
| 44 | + check. |
| 45 | +@@ -86,16 +88,15 @@ class Command(CommandContextMixIn): |
| 46 | + # Make sure we do the pip version check if the index_group options |
| 47 | + # are present. |
| 48 | + assert not hasattr(options, "no_index") |
| 49 | ++ yield |
| 50 | + |
| 51 | + def run(self, options: Values, args: List[str]) -> int: |
| 52 | + raise NotImplementedError |
| 53 | + |
| 54 | + def _run_wrapper(self, level_number: int, options: Values, args: List[str]) -> int: |
| 55 | + def _inner_run() -> int: |
| 56 | +- try: |
| 57 | ++ with self.pip_version_check(options, args): |
| 58 | + return self.run(options, args) |
| 59 | +- finally: |
| 60 | +- self.handle_pip_version_check(options) |
| 61 | + |
| 62 | + if options.debug_mode: |
| 63 | + rich_traceback.install(show_locals=True) |
| 64 | +diff --git a/src/pip/_internal/cli/index_command.py b/src/pip/_internal/cli/index_command.py |
| 65 | +index 226f8da..930f23a 100644 |
| 66 | +--- a/src/pip/_internal/cli/index_command.py |
| 67 | ++++ b/src/pip/_internal/cli/index_command.py |
| 68 | +@@ -9,8 +9,12 @@ so commands which don't always hit the network (e.g. list w/o --outdated or |
| 69 | + import logging |
| 70 | + import os |
| 71 | + import sys |
| 72 | ++import contextlib |
| 73 | + from optparse import Values |
| 74 | +-from typing import TYPE_CHECKING, List, Optional |
| 75 | ++from typing import TYPE_CHECKING, List, Optional, Iterator |
| 76 | ++ |
| 77 | ++if TYPE_CHECKING: |
| 78 | ++ from pip._internal.self_outdated_check import UpgradePrompt |
| 79 | + |
| 80 | + from pip._vendor import certifi |
| 81 | + |
| 82 | +@@ -131,10 +135,16 @@ class SessionCommandMixin(CommandContextMixIn): |
| 83 | + return session |
| 84 | + |
| 85 | + |
| 86 | +-def _pip_self_version_check(session: "PipSession", options: Values) -> None: |
| 87 | +- from pip._internal.self_outdated_check import pip_self_version_check as check |
| 88 | ++def _pip_self_version_check_fetch(session: "PipSession", options: Values) -> Optional["UpgradePrompt"]: |
| 89 | ++ from pip._internal.self_outdated_check import pip_self_version_check_fetch |
| 90 | ++ |
| 91 | ++ return pip_self_version_check_fetch(session, options) |
| 92 | ++ |
| 93 | + |
| 94 | +- check(session, options) |
| 95 | ++def _pip_self_version_check_emit(upgrade_prompt: Optional["UpgradePrompt"]) -> None: |
| 96 | ++ from pip._internal.self_outdated_check import pip_self_version_check_emit |
| 97 | ++ |
| 98 | ++ pip_self_version_check_emit(upgrade_prompt) |
| 99 | + |
| 100 | + |
| 101 | + class IndexGroupCommand(Command, SessionCommandMixin): |
| 102 | +@@ -144,7 +154,8 @@ class IndexGroupCommand(Command, SessionCommandMixin): |
| 103 | + This also corresponds to the commands that permit the pip version check. |
| 104 | + """ |
| 105 | + |
| 106 | +- def handle_pip_version_check(self, options: Values) -> None: |
| 107 | ++ @contextlib.contextmanager |
| 108 | ++ def pip_version_check(self, options: Values, args: List[str]) -> Iterator[None]: |
| 109 | + """ |
| 110 | + Do the pip version check if not disabled. |
| 111 | + |
| 112 | +@@ -154,17 +165,27 @@ class IndexGroupCommand(Command, SessionCommandMixin): |
| 113 | + assert hasattr(options, "no_index") |
| 114 | + |
| 115 | + if options.disable_pip_version_check or options.no_index: |
| 116 | ++ yield |
| 117 | + return |
| 118 | + |
| 119 | ++ upgrade_prompt: Optional["UpgradePrompt"] = None |
| 120 | + try: |
| 121 | +- # Otherwise, check if we're using the latest version of pip available. |
| 122 | + session = self._build_session( |
| 123 | + options, |
| 124 | + retries=0, |
| 125 | + timeout=min(5, options.timeout), |
| 126 | + ) |
| 127 | + with session: |
| 128 | +- _pip_self_version_check(session, options) |
| 129 | ++ upgrade_prompt = _pip_self_version_check_fetch(session, options) |
| 130 | + except Exception: |
| 131 | + logger.warning("There was an error checking the latest version of pip.") |
| 132 | + logger.debug("See below for error", exc_info=True) |
| 133 | ++ |
| 134 | ++ try: |
| 135 | ++ yield |
| 136 | ++ finally: |
| 137 | ++ try: |
| 138 | ++ _pip_self_version_check_emit(upgrade_prompt) |
| 139 | ++ except Exception: |
| 140 | ++ logger.warning("There was an error checking the latest version of pip.") |
| 141 | ++ logger.debug("See below for error", exc_info=True) |
| 142 | +diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py |
| 143 | +index ad45a2f..298b92c 100644 |
| 144 | +--- a/src/pip/_internal/commands/install.py |
| 145 | ++++ b/src/pip/_internal/commands/install.py |
| 146 | +@@ -1,4 +1,10 @@ |
| 147 | + import errno |
| 148 | ++from __future__ import annotations |
| 149 | ++ |
| 150 | ++import contextlib |
| 151 | ++from collections.abc import Iterator |
| 152 | ++from pip._vendor.packaging.requirements import InvalidRequirement, Requirement |
| 153 | ++ |
| 154 | + import json |
| 155 | + import operator |
| 156 | + import os |
| 157 | +diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py |
| 158 | +index 82fc46a..ba00f82 100644 |
| 159 | +--- a/src/pip/_internal/commands/list.py |
| 160 | ++++ b/src/pip/_internal/commands/list.py |
| 161 | +@@ -1,7 +1,8 @@ |
| 162 | + import json |
| 163 | + import logging |
| 164 | ++import contextlib |
| 165 | + from optparse import Values |
| 166 | +-from typing import TYPE_CHECKING, Generator, List, Optional, Sequence, Tuple, cast |
| 167 | ++from typing import TYPE_CHECKING, Generator, List, Optional, Sequence, Tuple, cast, Iterator |
| 168 | + |
| 169 | + from pip._vendor.packaging.utils import canonicalize_name |
| 170 | + from pip._vendor.packaging.version import Version |
| 171 | +@@ -134,9 +135,13 @@ class ListCommand(IndexGroupCommand): |
| 172 | + self.parser.insert_option_group(0, index_opts) |
| 173 | + self.parser.insert_option_group(0, self.cmd_opts) |
| 174 | + |
| 175 | +- def handle_pip_version_check(self, options: Values) -> None: |
| 176 | +- if options.outdated or options.uptodate: |
| 177 | +- super().handle_pip_version_check(options) |
| 178 | ++ @contextlib.contextmanager |
| 179 | ++ def pip_version_check(self, options: Values, args: List[str]) -> Iterator[None]: |
| 180 | ++ if not (options.outdated or options.uptodate): |
| 181 | ++ yield |
| 182 | ++ return |
| 183 | ++ with super().pip_version_check(options, args): |
| 184 | ++ yield |
| 185 | + |
| 186 | + def _build_package_finder( |
| 187 | + self, options: Values, session: "PipSession" |
| 188 | +diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py |
| 189 | +index f9a91af..fe0e0d2 100644 |
| 190 | +--- a/src/pip/_internal/self_outdated_check.py |
| 191 | ++++ b/src/pip/_internal/self_outdated_check.py |
| 192 | +@@ -1,5 +1,4 @@ |
| 193 | + import datetime |
| 194 | +-import functools |
| 195 | + import hashlib |
| 196 | + import json |
| 197 | + import logging |
| 198 | +@@ -7,7 +6,7 @@ import optparse |
| 199 | + import os.path |
| 200 | + import sys |
| 201 | + from dataclasses import dataclass |
| 202 | +-from typing import Any, Callable, Dict, Optional |
| 203 | ++from typing import Any, Dict, Optional |
| 204 | + |
| 205 | + from pip._vendor.packaging.version import Version |
| 206 | + from pip._vendor.packaging.version import parse as parse_version |
| 207 | +@@ -26,7 +25,8 @@ from pip._internal.utils.entrypoints import ( |
| 208 | + get_best_invocation_for_this_python, |
| 209 | + ) |
| 210 | + from pip._internal.utils.filesystem import adjacent_tmp_file, check_path_owner, replace |
| 211 | +-from pip._internal.utils.misc import ensure_dir |
| 212 | ++from pip._internal.utils.misc import ensure_dir, check_externally_managed |
| 213 | ++from pip._internal.exceptions import ExternallyManagedEnvironment |
| 214 | + |
| 215 | + _WEEK = datetime.timedelta(days=7) |
| 216 | + |
| 217 | +@@ -149,14 +149,6 @@ class UpgradePrompt: |
| 218 | + ) |
| 219 | + |
| 220 | + |
| 221 | +-def was_installed_by_pip(pkg: str) -> bool: |
| 222 | +- """Checks whether pkg was installed by pip |
| 223 | +- |
| 224 | +- This is used not to display the upgrade message when pip is in fact |
| 225 | +- installed by system package manager, such as dnf on Fedora. |
| 226 | +- """ |
| 227 | +- dist = get_default_environment().get_distribution(pkg) |
| 228 | +- return dist is not None and "pip" == dist.installer |
| 229 | + |
| 230 | + |
| 231 | + def _get_current_remote_pip_version( |
| 232 | +@@ -187,28 +179,15 @@ def _get_current_remote_pip_version( |
| 233 | + return str(best_candidate.version) |
| 234 | + |
| 235 | + |
| 236 | +-def _self_version_check_logic( |
| 237 | +- *, |
| 238 | +- state: SelfCheckState, |
| 239 | +- current_time: datetime.datetime, |
| 240 | +- local_version: Version, |
| 241 | +- get_remote_version: Callable[[], Optional[str]], |
| 242 | ++def _compute_upgrade_prompt( |
| 243 | ++ local_version: Version, remote_version_str: str, installed_by_pip: bool |
| 244 | + ) -> Optional[UpgradePrompt]: |
| 245 | +- remote_version_str = state.get(current_time) |
| 246 | +- if remote_version_str is None: |
| 247 | +- remote_version_str = get_remote_version() |
| 248 | +- if remote_version_str is None: |
| 249 | +- logger.debug("No remote pip version found") |
| 250 | +- return None |
| 251 | +- state.set(remote_version_str, current_time) |
| 252 | +- |
| 253 | + remote_version = parse_version(remote_version_str) |
| 254 | + logger.debug("Remote version of pip: %s", remote_version) |
| 255 | + logger.debug("Local version of pip: %s", local_version) |
| 256 | ++ logger.debug("Was pip installed by pip? %s", installed_by_pip) |
| 257 | + |
| 258 | +- pip_installed_by_pip = was_installed_by_pip("pip") |
| 259 | +- logger.debug("Was pip installed by pip? %s", pip_installed_by_pip) |
| 260 | +- if not pip_installed_by_pip: |
| 261 | ++ if not installed_by_pip: |
| 262 | + return None # Only suggest upgrade if pip is installed by pip. |
| 263 | + |
| 264 | + local_version_is_older = ( |
| 265 | +@@ -221,24 +200,44 @@ def _self_version_check_logic( |
| 266 | + return None |
| 267 | + |
| 268 | + |
| 269 | +-def pip_self_version_check(session: PipSession, options: optparse.Values) -> None: |
| 270 | +- """Check for an update for pip. |
| 271 | ++def pip_self_version_check_fetch( |
| 272 | ++ session: PipSession, options: optparse.Values |
| 273 | ++) -> Optional[UpgradePrompt]: |
| 274 | ++ """Compute the pip upgrade prompt, if any, before the command runs. |
| 275 | + |
| 276 | + Limit the frequency of checks to once per week. State is stored either in |
| 277 | + the active virtualenv or in the user's USER_CACHE_DIR keyed off the prefix |
| 278 | + of the pip script path. |
| 279 | ++ |
| 280 | ++ Pair with :func:`pip_self_version_check_emit`, which displays the prompt |
| 281 | ++ after the command body runs. |
| 282 | + """ |
| 283 | + installed_dist = get_default_environment().get_distribution("pip") |
| 284 | + if not installed_dist: |
| 285 | +- return |
| 286 | ++ return None |
| 287 | ++ try: |
| 288 | ++ check_externally_managed() |
| 289 | ++ except ExternallyManagedEnvironment: |
| 290 | ++ return None |
| 291 | + |
| 292 | +- upgrade_prompt = _self_version_check_logic( |
| 293 | +- state=SelfCheckState(cache_dir=options.cache_dir), |
| 294 | +- current_time=datetime.datetime.now(datetime.timezone.utc), |
| 295 | ++ state = SelfCheckState(cache_dir=options.cache_dir) |
| 296 | ++ current_time = datetime.datetime.now(datetime.timezone.utc) |
| 297 | ++ remote_version_str = state.get(current_time) |
| 298 | ++ if remote_version_str is None: |
| 299 | ++ remote_version_str = _get_current_remote_pip_version(session, options) |
| 300 | ++ if remote_version_str is None: |
| 301 | ++ logger.debug("No remote pip version found") |
| 302 | ++ return None |
| 303 | ++ state.set(remote_version_str, current_time) |
| 304 | ++ |
| 305 | ++ return _compute_upgrade_prompt( |
| 306 | + local_version=installed_dist.version, |
| 307 | +- get_remote_version=functools.partial( |
| 308 | +- _get_current_remote_pip_version, session, options |
| 309 | +- ), |
| 310 | ++ remote_version_str=remote_version_str, |
| 311 | ++ installed_by_pip=installed_dist.installer == "pip", |
| 312 | + ) |
| 313 | ++ |
| 314 | ++ |
| 315 | ++def pip_self_version_check_emit(upgrade_prompt: Optional[UpgradePrompt]) -> None: |
| 316 | ++ """Emit the upgrade prompt captured by :func:`pip_self_version_check_fetch`.""" |
| 317 | + if upgrade_prompt is not None: |
| 318 | + logger.warning("%s", upgrade_prompt, extra={"rich": True}) |
| 319 | +diff --git a/tests/unit/test_base_command.py b/tests/unit/test_base_command.py |
| 320 | +index f9fae65..5fdf94a 100644 |
| 321 | +--- a/tests/unit/test_base_command.py |
| 322 | ++++ b/tests/unit/test_base_command.py |
| 323 | +@@ -97,14 +97,14 @@ class TestCommand: |
| 324 | + assert "Traceback (most recent call last):" in stderr |
| 325 | + |
| 326 | + |
| 327 | +-@patch("pip._internal.cli.index_command.Command.handle_pip_version_check") |
| 328 | +-def test_handle_pip_version_check_called(mock_handle_version_check: Mock) -> None: |
| 329 | ++@patch("pip._internal.cli.index_command.Command.pip_version_check") |
| 330 | ++def test_pip_version_check_called(mock_version_check: Mock) -> None: |
| 331 | + """ |
| 332 | +- Check that Command.handle_pip_version_check() is called. |
| 333 | ++ Check that ``Command.pip_version_check()`` wraps the command body. |
| 334 | + """ |
| 335 | + cmd = FakeCommand() |
| 336 | + cmd.main([]) |
| 337 | +- mock_handle_version_check.assert_called_once() |
| 338 | ++ mock_version_check.assert_called_once() |
| 339 | + |
| 340 | + |
| 341 | + def test_log_command_success(fixed_time: None, tmpdir: Path) -> None: |
| 342 | +diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py |
| 343 | +index 9d5aefe..7e944ce 100644 |
| 344 | +--- a/tests/unit/test_commands.py |
| 345 | ++++ b/tests/unit/test_commands.py |
| 346 | +@@ -87,8 +87,8 @@ def test_index_group_commands() -> None: |
| 347 | + (True, True, False), |
| 348 | + ], |
| 349 | + ) |
| 350 | +-@mock.patch("pip._internal.cli.index_command._pip_self_version_check") |
| 351 | +-def test_index_group_handle_pip_version_check( |
| 352 | ++@mock.patch("pip._internal.cli.index_command._pip_self_version_check_fetch") |
| 353 | ++def test_index_group_pip_version_check( |
| 354 | + mock_version_check: mock.Mock, |
| 355 | + command_name: str, |
| 356 | + disable_pip_version_check: bool, |
| 357 | +@@ -96,9 +96,8 @@ def test_index_group_handle_pip_version_check( |
| 358 | + expected_called: bool, |
| 359 | + ) -> None: |
| 360 | + """ |
| 361 | +- Test whether pip_self_version_check() is called when |
| 362 | +- handle_pip_version_check() is called, for each of the |
| 363 | +- IndexGroupCommand classes. |
| 364 | ++ Test whether self-version check is performed when ``pip_version_check()`` |
| 365 | ++ is called, for each of the IndexGroupCommand classes. |
| 366 | + """ |
| 367 | + command = create_command(command_name) |
| 368 | + options = command.parser.get_default_values() |
| 369 | +-- |
| 370 | +2.45.4 |
| 371 | + |
0 commit comments