Repository: pypa/pip
Pull request #13228 (2025-02-21 18:51:29)

make `--no-input` flag work with install `git+http` and `git+ssh` schemes
<!---
Thank you for your soon to be pull request. Before you submit this, please
double check to make sure that you've added a news file fragment. In pip we
generate our NEWS.rst from multiple news fragment files, and all pull requests
require either a news file fragment or a marker to indicate they don't require
one.

To read more about adding a news file fragment for your PR, please check out
our documentation at: https://pip.pypa.io/en/latest/development/contributing/#news-entries
-->

Fix https://github.com/pypa/pip/issues/12718



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 769373ae8..30b55ef5e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
   - id: black
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.9.5
+  rev: v0.9.9
   hooks:
     - id: ruff
       args: [--fix, --exit-non-zero-on-fix]
diff --git a/news/12718.bugfix.rst b/news/12718.bugfix.rst
new file mode 100644
index 000000000..fccfde4ad
--- /dev/null
+++ b/news/12718.bugfix.rst
@@ -0,0 +1,1 @@
+Disable Git and SSH prompts when ``--no-input`` is passed.
diff --git a/news/13194.feature.rst b/news/13194.feature.rst
new file mode 100644
index 000000000..0ed40e3ab
--- /dev/null
+++ b/news/13194.feature.rst
@@ -0,0 +1,1 @@
+Add a structured ``--json`` output to ``pip index versions``
diff --git a/news/13252.bugfix.rst b/news/13252.bugfix.rst
new file mode 100644
index 000000000..477374589
--- /dev/null
+++ b/news/13252.bugfix.rst
@@ -0,0 +1,3 @@
+When choosing a preferred requirement for resolving dependencies
+do not consider a specifier with a * in it, e.g. "==1.*", to be a
+pinned specifier.
diff --git a/news/5210.feature.rst b/news/5210.feature.rst
new file mode 100644
index 000000000..c7e9e513f
--- /dev/null
+++ b/news/5210.feature.rst
@@ -0,0 +1,1 @@
+Display wheel build tag in ``pip list`` columns output if set.
diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py
index 81fed6e94..93969be93 100644
--- a/src/pip/_internal/cli/cmdoptions.py
+++ b/src/pip/_internal/cli/cmdoptions.py
@@ -928,6 +928,14 @@ pre: Callable[..., Option] = partial(
     "pip only finds stable versions.",
 )
 
+json: Callable[..., Option] = partial(
+    Option,
+    "--json",
+    action="store_true",
+    default=False,
+    help="Output data in a machine-readable JSON format.",
+)
+
 disable_pip_version_check: Callable[..., Option] = partial(
     Option,
     "--disable-pip-version-check",
diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py
index 2e2661bba..14f0feb61 100644
--- a/src/pip/_internal/commands/index.py
+++ b/src/pip/_internal/commands/index.py
@@ -1,3 +1,4 @@
+import json
 import logging
 from optparse import Values
 from typing import Any, Iterable, List, Optional
@@ -7,7 +8,10 @@ from pip._vendor.packaging.version import Version
 from pip._internal.cli import cmdoptions
 from pip._internal.cli.req_command import IndexGroupCommand
 from pip._internal.cli.status_codes import ERROR, SUCCESS
-from pip._internal.commands.search import print_dist_installation_info
+from pip._internal.commands.search import (
+    get_installed_distribution,
+    print_dist_installation_info,
+)
 from pip._internal.exceptions import CommandError, DistributionNotFound, PipError
 from pip._internal.index.collector import LinkCollector
 from pip._internal.index.package_finder import PackageFinder
@@ -34,6 +38,7 @@ class IndexCommand(IndexGroupCommand):
 
         self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
         self.cmd_opts.add_option(cmdoptions.pre())
+        self.cmd_opts.add_option(cmdoptions.json())
         self.cmd_opts.add_option(cmdoptions.no_binary())
         self.cmd_opts.add_option(cmdoptions.only_binary())
 
@@ -134,6 +139,21 @@ class IndexCommand(IndexGroupCommand):
             formatted_versions = [str(ver) for ver in sorted(versions, reverse=True)]
             latest = formatted_versions[0]
 
-        write_output(f"{query} ({latest})")
-        write_output("Available versions: {}".format(", ".join(formatted_versions)))
-        print_dist_installation_info(query, latest)
+        dist = get_installed_distribution(query)
+
+        if options.json:
+            structured_output = {
+                "name": query,
+                "versions": formatted_versions,
+                "latest": latest,
+            }
+
+            if dist is not None:
+                structured_output["installed_version"] = str(dist.version)
+
+            write_output(json.dumps(structured_output))
+
+        else:
+            write_output(f"{query} ({latest})")
+            write_output("Available versions: {}".format(", ".join(formatted_versions)))
+            print_dist_installation_info(latest, dist)
diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py
index a8b4f7af9..b03085070 100644
--- a/src/pip/_internal/commands/list.py
+++ b/src/pip/_internal/commands/list.py
@@ -1,5 +1,6 @@
 import json
 import logging
+from email.parser import Parser
 from optparse import Values
 from typing import TYPE_CHECKING, Generator, List, Optional, Sequence, Tuple, cast
 
@@ -323,17 +324,29 @@ def format_for_columns(
     if running_outdated:
         header.extend(["Latest", "Type"])
 
-    has_editables = any(x.editable for x in pkgs)
-    if has_editables:
-        header.append("Editable project location")
+    def wheel_build_tag(dist: BaseDistribution) -> Optional[str]:
+        try:
+            wheel_file = dist.read_text("WHEEL")
+        except FileNotFoundError:
+            return None
+        return Parser().parsestr(wheel_file).get("Build")
+
+    build_tags = [wheel_build_tag(p) for p in pkgs]
+    has_build_tags = any(build_tags)
+    if has_build_tags:
+        header.append("Build")
 
     if options.verbose >= 1:
         header.append("Location")
     if options.verbose >= 1:
         header.append("Installer")
 
+    has_editables = any(x.editable for x in pkgs)
+    if has_editables:
+        header.append("Editable project location")
+
     data = []
-    for proj in pkgs:
+    for i, proj in enumerate(pkgs):
         # if we're working on the 'outdated' list, separate out the
         # latest_version and type
         row = [proj.raw_name, proj.raw_version]
@@ -342,6 +355,9 @@ def format_for_columns(
             row.append(str(proj.latest_version))
             row.append(proj.latest_filetype)
 
+        if has_build_tags:
+            row.append(build_tags[i] or "")
+
         if has_editables:
             row.append(proj.editable_project_location or "")
 
diff --git a/src/pip/_internal/commands/search.py b/src/pip/_internal/commands/search.py
index 89bba0941..c58c2b3b1 100644
--- a/src/pip/_internal/commands/search.py
+++ b/src/pip/_internal/commands/search.py
@@ -14,6 +14,7 @@ from pip._internal.cli.req_command import SessionCommandMixin
 from pip._internal.cli.status_codes import NO_MATCHES_FOUND, SUCCESS
 from pip._internal.exceptions import CommandError
 from pip._internal.metadata import get_default_environment
+from pip._internal.metadata.base import BaseDistribution
 from pip._internal.models.index import PyPI
 from pip._internal.network.xmlrpc import PipXmlrpcTransport
 from pip._internal.utils.logging import indent_log
@@ -110,9 +111,7 @@ def transform_hits(hits: List[Dict[str, str]]) -> List["TransformedHit"]:
     return list(packages.values())
 
 
-def print_dist_installation_info(name: str, latest: str) -> None:
-    env = get_default_environment()
-    dist = env.get_distribution(name)
+def print_dist_installation_info(latest: str, dist: Optional[BaseDistribution]) -> None:
     if dist is not None:
         with indent_log():
             if dist.version == latest:
@@ -129,6 +128,11 @@ def print_dist_installation_info(name: str, latest: str) -> None:
                     write_output("LATEST:    %s", latest)
 
 
+def get_installed_distribution(name: str) -> Optional[BaseDistribution]:
+    env = get_default_environment()
+    return env.get_distribution(name)
+
+
 def print_results(
     hits: List["TransformedHit"],
     name_column_width: Optional[int] = None,
@@ -162,7 +166,8 @@ def print_results(
         line = f"{name_latest:{name_column_width}} - {summary}"
         try:
             write_output(line)
-            print_dist_installation_info(name, latest)
+            dist = get_installed_distribution(name)
+            print_dist_installation_info(latest, dist)
         except UnicodeEncodeError:
             pass
 
diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py
index 278719f4e..b380754bc 100644
--- a/src/pip/_internal/commands/wheel.py
+++ b/src/pip/_internal/commands/wheel.py
@@ -16,7 +16,7 @@ from pip._internal.req.req_install import (
 )
 from pip._internal.utils.misc import ensure_dir, normalize_path
 from pip._internal.utils.temp_dir import TempDirectory
-from pip._internal.wheel_builder import build, should_build_for_wheel_command
+from pip._internal.wheel_builder import build
 
 logger = logging.getLogger(__name__)
 
@@ -150,7 +150,7 @@ class WheelCommand(RequirementCommand):
         for req in requirement_set.requirements.values():
             if req.is_wheel:
                 preparer.save_linked_requirement(req)
-            elif should_build_for_wheel_command(req):
+            else:
                 reqs_to_build.append(req)
 
         preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py
index 74cdb9d80..40326001e 100644
--- a/src/pip/_internal/resolution/resolvelib/provider.py
+++ b/src/pip/_internal/resolution/resolvelib/provider.py
@@ -142,14 +142,14 @@ class PipProvider(_ProviderBase):
         else:
             candidate, ireqs = None, ()
 
-        operators = [
-            specifier.operator
+        operators: list[tuple[str, str]] = [
+            (specifier.operator, specifier.version)
             for specifier_set in (ireq.specifier for ireq in ireqs if ireq)
             for specifier in specifier_set
         ]
 
         direct = candidate is not None
-        pinned = any(op[:2] == "==" for op in operators)
+        pinned = any(((op[:2] == "==") and ("*" not in ver)) for op, ver in operators)
         unfree = bool(operators)
         requested_order = self._user_requested.get(identifier, math.inf)
 
diff --git a/src/pip/_internal/vcs/git.py b/src/pip/_internal/vcs/git.py
index 0425debb3..9c926e969 100644
--- a/src/pip/_internal/vcs/git.py
+++ b/src/pip/_internal/vcs/git.py
@@ -5,7 +5,7 @@ import re
 import urllib.parse
 import urllib.request
 from dataclasses import replace
-from typing import List, Optional, Tuple
+from typing import Any, List, Optional, Tuple
 
 from pip._internal.exceptions import BadCommand, InstallationError
 from pip._internal.utils.misc import HiddenText, display_path, hide_url
@@ -77,6 +77,15 @@ class Git(VersionControl):
     def get_base_rev_args(rev: str) -> List[str]:
         return [rev]
 
+    @classmethod
+    def run_command(cls, *args: Any, **kwargs: Any) -> str:
+        if os.environ.get("PIP_NO_INPUT"):
+            extra_environ = kwargs.get("extra_environ", {})
+            extra_environ["GIT_TERMINAL_PROMPT"] = "0"
+            extra_environ["GIT_SSH_COMMAND"] = "ssh -oBatchMode=yes"
+            kwargs["extra_environ"] = extra_environ
+        return super().run_command(*args, **kwargs)
+
     def is_immutable_rev_checkout(self, url: str, dest: str) -> bool:
         _, rev_options = self.get_url_rev_options(hide_url(url))
         if not rev_options.rev:
diff --git a/src/pip/_internal/wheel_builder.py b/src/pip/_internal/wheel_builder.py
index 00319cb56..3cf02e01d 100644
--- a/src/pip/_internal/wheel_builder.py
+++ b/src/pip/_internal/wheel_builder.py
@@ -43,29 +43,14 @@ def _contains_egg_info(s: str) -> bool:
 
 def _should_build(
     req: InstallRequirement,
-    need_wheel: bool,
 ) -> bool:
     """Return whether an InstallRequirement should be built into a wheel."""
-    if req.constraint:
-        # never build requirements that are merely constraints
-        return False
+    assert not req.constraint
+
     if req.is_wheel:
-        if need_wheel:
-            logger.info(
-                "Skipping %s, due to already being wheel.",
-                req.name,
-            )
         return False
 
-    if need_wheel:
-        # i.e. pip wheel, not pip install
-        return True
-
-    # From this point, this concerns the pip install command only
-    # (need_wheel=False).
-
-    if not req.source_dir:
-        return False
+    assert req.source_dir
 
     if req.editable:
         # we only build PEP 660 editable requirements
@@ -74,16 +59,10 @@ def _should_build(
     return True
 
 
-def should_build_for_wheel_command(
-    req: InstallRequirement,
-) -> bool:
-    return _should_build(req, need_wheel=True)
-
-
 def should_build_for_install_command(
     req: InstallRequirement,
 ) -> bool:
-    return _should_build(req, need_wheel=False)
+    return _should_build(req)
 
 
 def _should_cache(


Test patch:
diff --git a/tests/functional/test_index.py b/tests/functional/test_index.py
index 5a3c27bac..b58ad4a50 100644
--- a/tests/functional/test_index.py
+++ b/tests/functional/test_index.py
@@ -1,3 +1,5 @@
+import json
+
 import pytest
 
 from pip._internal.cli.status_codes import ERROR, SUCCESS
@@ -6,6 +8,36 @@ from pip._internal.commands import create_command
 from tests.lib import PipTestEnvironment
 
 
+@pytest.mark.network
+def test_json_structured_output(script: PipTestEnvironment) -> None:
+    """
+    Test that --json flag returns structured output
+    """
+    output = script.pip("index", "versions", "pip", "--json", allow_stderr_warning=True)
+    structured_output = json.loads(output.stdout)
+
+    assert isinstance(structured_output, dict)
+    assert "name" in structured_output
+    assert structured_output["name"] == "pip"
+    assert "latest" in structured_output
+    assert isinstance(structured_output["latest"], str)
+    assert "versions" in structured_output
+    assert isinstance(structured_output["versions"], list)
+    assert (
+        "20.2.3, 20.2.2, 20.2.1, 20.2, 20.1.1, 20.1, 20.0.2"
+        ", 20.0.1, 19.3.1, 19.3, 19.2.3, 19.2.2, 19.2.1, 19.2, 19.1.1"
+        ", 19.1, 19.0.3, 19.0.2, 19.0.1, 19.0, 18.1, 18.0, 10.0.1, 10.0.0, "
+        "9.0.3, 9.0.2, 9.0.1, 9.0.0, 8.1.2, 8.1.1, "
+        "8.1.0, 8.0.3, 8.0.2, 8.0.1, 8.0.0, 7.1.2, 7.1.1, 7.1.0, 7.0.3, "
+        "7.0.2, 7.0.1, 7.0.0, 6.1.1, 6.1.0, 6.0.8, 6.0.7, 6.0.6, 6.0.5, "
+        "6.0.4, 6.0.3, 6.0.2, 6.0.1, 6.0, 1.5.6, 1.5.5, 1.5.4, 1.5.3, "
+        "1.5.2, 1.5.1, 1.5, 1.4.1, 1.4, 1.3.1, 1.3, 1.2.1, 1.2, 1.1, 1.0.2,"
+        " 1.0.1, 1.0, 0.8.3, 0.8.2, 0.8.1, 0.8, 0.7.2, 0.7.1, 0.7, 0.6.3, "
+        "0.6.2, 0.6.1, 0.6, 0.5.1, 0.5, 0.4, 0.3.1, "
+        "0.3, 0.2.1, 0.2" in ", ".join(structured_output["versions"])
+    )
+
+
 @pytest.mark.network
 def test_list_all_versions_basic_search(script: PipTestEnvironment) -> None:
     """
diff --git a/tests/functional/test_install_config.py b/tests/functional/test_install_config.py
index d111bc5f7..b8fa56bff 100644
--- a/tests/functional/test_install_config.py
+++ b/tests/functional/test_install_config.py
@@ -341,6 +341,34 @@ def test_do_not_prompt_for_authentication(
     assert "ERROR: HTTP error 401" in result.stderr
 
 
+def test_do_not_prompt_for_authentication_git(
+    script: PipTestEnvironment, data: TestData, cert_factory: CertFactory
+) -> None:
+    """Test behaviour if --no-input option is given while installing
+    from a git http url requiring authentication
+    """
+    server = make_mock_server()
+    # Disable vscode user/password prompt, will make tests fail inside vscode
+    script.environ["GIT_ASKPASS"] = ""
+
+    # Return 401 on all URLs
+    server.mock.side_effect = lambda _, __: authorization_response(
+        data.packages / "simple-3.0.tar.gz"
+    )
+
+    url = f"git+http://{server.host}:{server.port}/simple"
+
+    with server_running(server):
+        result = script.pip(
+            "install",
+            url,
+            "--no-input",
+            expect_error=True,
+        )
+
+    assert "terminal prompts disabled" in result.stderr
+
+
 @pytest.fixture(params=(True, False), ids=("interactive", "noninteractive"))
 def interactive(request: pytest.FixtureRequest) -> bool:
     return request.param
diff --git a/tests/functional/test_list.py b/tests/functional/test_list.py
index e611fe7cb..468e6acd6 100644
--- a/tests/functional/test_list.py
+++ b/tests/functional/test_list.py
@@ -12,6 +12,7 @@ from tests.lib import (
     TestData,
     _create_test_package,
     create_test_package_with_setup,
+    make_wheel,
     wheel,
 )
 from tests.lib.direct_url import get_created_direct_url_path
@@ -751,3 +752,16 @@ def test_list_pep610_editable(script: PipTestEnvironment) -> None:
             break
     else:
         pytest.fail("package 'testpkg' not found in pip list result")
+
+
+def test_list_wheel_build(script: PipTestEnvironment) -> None:
+    package = make_wheel(
+        name="package",
+        version="3.0",
+        wheel_metadata_updates={"Build": "123"},
+    ).save_to_dir(script.scratch_path)
+    script.pip("install", package, "--no-index")
+
+    result = script.pip("list")
+    assert "Build" in result.stdout, str(result)
+    assert "123" in result.stdout, str(result)
diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py
index 45536a9ea..d37076a93 100644
--- a/tests/unit/resolution_resolvelib/test_provider.py
+++ b/tests/unit/resolution_resolvelib/test_provider.py
@@ -52,6 +52,14 @@ def build_req_info(
             {},
             (True, False, False, True, math.inf, False, "pinned-package"),
         ),
+        # Star-specified package, i.e. with "*"
+        (
+            "star-specified-package",
+            {"star-specified-package": [build_req_info("star-specified-package==1.*")]},
+            [],
+            {},
+            (True, False, True, True, math.inf, False, "star-specified-package"),
+        ),
         # Package that caused backtracking
         (
             "backtrack-package",
diff --git a/tests/unit/test_wheel_builder.py b/tests/unit/test_wheel_builder.py
index a657e900b..d3d6801ed 100644
--- a/tests/unit/test_wheel_builder.py
+++ b/tests/unit/test_wheel_builder.py
@@ -51,8 +51,6 @@ class ReqMock:
         # We build, whether pep 517 is enabled or not.
         (ReqMock(use_pep517=True), True),
         (ReqMock(use_pep517=False), True),
-        # We don't build constraints.
-        (ReqMock(constraint=True), False),
         # We don't build reqs that are already wheels.
         (ReqMock(is_wheel=True), False),
         # We build editables if the backend supports PEP 660.
@@ -65,8 +63,6 @@ class ReqMock:
             ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=False),
             False,
         ),
-        # We don't build if there is no source dir (whatever that means!).
-        (ReqMock(source_dir=None), False),
         # By default (i.e. when binaries are allowed), VCS requirements
         # should be built in install mode.
         (
@@ -86,25 +82,6 @@ def test_should_build_for_install_command(req: ReqMock, expected: bool) -> None:
     assert should_build is expected
 
 
-@pytest.mark.parametrize(
-    "req, expected",
-    [
-        (ReqMock(), True),
-        (ReqMock(constraint=True), False),
-        (ReqMock(is_wheel=True), False),
-        (ReqMock(editable=True, use_pep517=False), True),
-        (ReqMock(editable=True, use_pep517=True), True),
-        (ReqMock(source_dir=None), True),
-        (ReqMock(link=Link("git+https://g.c/org/repo")), True),
-    ],
-)
-def test_should_build_for_wheel_command(req: ReqMock, expected: bool) -> None:
-    should_build = wheel_builder.should_build_for_wheel_command(
-        cast(InstallRequirement, req)
-    )
-    assert should_build is expected
-
-
 @pytest.mark.parametrize(
     "req, expected",
     [