Repository: pypa/pip
Pull request #13255 (2025-03-03 17:02:32)

Bump setuptools from 75.8.0 to 75.8.2
Bumps [setuptools](https://github.com/pypa/setuptools) from 75.8.0 to 75.8.2.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/pypa/setuptools/blob/main/NEWS.rst">setuptools's changelog</a>.</em></p>
<blockquote>
<h1>v75.8.2</h1>
<h2>Bugfixes</h2>
<ul>
<li>Fixed <code>pkg_resources.require(...)</code> to also consider standardised
<code>dist-info</code> directories. (<a href="https://redirect.github.com/pypa/setuptools/issues/4856">#4856</a>)</li>
</ul>
<h1>v75.8.1</h1>
<h2>Bugfixes</h2>
<ul>
<li>Fix wheel file naming to follow binary distribution specification -- by :user:<code>di</code> (<a href="https://redirect.github.com/pypa/setuptools/issues/4766">#4766</a>)</li>
<li>Fixed crash generating error message printed when building wheels for the
free-threaded build using the limited API. -- by :user:<code>ngoldbaum</code> (<a href="https://redirect.github.com/pypa/setuptools/issues/4809">#4809</a>)</li>
<li>Fix documentation for recent CFLAGS distutils change. -- by :user:<code>thesamesam</code> (<a href="https://redirect.github.com/pypa/setuptools/issues/4836">#4836</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/pypa/setuptools/commit/9aa3771f57f070bd13b9a3e83bd469413afc98c7"><code>9aa3771</code></a> Bump version: 75.8.1 → 75.8.2</li>
<li><a href="https://github.com/pypa/setuptools/commit/c0f39c04c5e7632b3f1f4800c8776f5d4709fabf"><code>c0f39c0</code></a> Update WorkingSet.find to consider standardised .dist-info directory names (#...</li>
<li><a href="https://github.com/pypa/setuptools/commit/edca1811df4daa15d18eb06d0dd5da11eda8b3af"><code>edca181</code></a> Add news fragment</li>
<li><a href="https://github.com/pypa/setuptools/commit/22355fcb3337317d4f6ca675aa60947692c9af3a"><code>22355fc</code></a> Also consider '-' separator in tests</li>
<li><a href="https://github.com/pypa/setuptools/commit/8280e2c4fc2f32a5da1ec3ba322c534e2f5369a3"><code>8280e2c</code></a> Attempt to solve path normalisation issue in windows tests</li>
<li><a href="https://github.com/pypa/setuptools/commit/a3718c8099235fb3b40d013f97530d5aeb5ba0ce"><code>a3718c8</code></a> Slightly change test, so that we are sure about the correct distribution bein...</li>
<li><a href="https://github.com/pypa/setuptools/commit/23b73aaef2cb95650a997f80ea74c8d51bc5f01c"><code>23b73aa</code></a> Fix mypy errors</li>
<li><a href="https://github.com/pypa/setuptools/commit/2c242238f536c4b942812632ba9dd0b1c48b4b85"><code>2c24223</code></a> Update WorkingSet.find to consider standardised dist-info names</li>
<li><a href="https://github.com/pypa/setuptools/commit/79d6e46d4949a77238ca8884ed8137d5d6175d31"><code>79d6e46</code></a> Add regression test for issue 4853</li>
<li><a href="https://github.com/pypa/setuptools/commit/56c055b653f080544f490e198605974c71b6fe35"><code>56c055b</code></a> Bump version: 75.8.0 → 75.8.1</li>
<li>Additional commits viewable in <a href="https://github.com/pypa/setuptools/compare/v75.8.0...v75.8.2">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=setuptools&package-manager=pip&previous-version=75.8.0&new-version=75.8.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a3995a32c..15e5ee3a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,7 +36,7 @@ jobs:
 
     steps:
       - name: Download all the dists
-        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
+        uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4
         with:
           name: python-package-distributions
           path: dist/
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/build-requirements.txt b/build-requirements.txt
index ad876c4ad..d45cae769 100644
--- a/build-requirements.txt
+++ b/build-requirements.txt
@@ -18,7 +18,7 @@ pyproject-hooks==1.2.0 \
     # via build
 
 # The following packages are considered to be unsafe in a requirements file:
-setuptools==75.8.0 \
-    --hash=sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6 \
-    --hash=sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3
+setuptools==75.8.2 \
+    --hash=sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2 \
+    --hash=sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f
     # via -r build-requirements.in
diff --git a/docs/html/topics/more-dependency-resolution.md b/docs/html/topics/more-dependency-resolution.md
index 215205287..c0afd3a4e 100644
--- a/docs/html/topics/more-dependency-resolution.md
+++ b/docs/html/topics/more-dependency-resolution.md
@@ -132,6 +132,8 @@ operations:
 * `get_preference` - this provides information to the resolver to help it choose
   which requirement to look at "next" when working through the resolution
   process.
+* `narrow_requirement_selection` - this provides a way to limit the number of
+  identifiers passed to `get_preference`.
 * `find_matches` - given a set of constraints, determine what candidates exist
   that satisfy them. This is essentially where the finder interacts with the
   resolver.
@@ -140,12 +142,12 @@ operations:
 * `get_dependencies` - get the dependency metadata for a candidate. This is
   the implementation of the process of getting and reading package metadata.
 
-Of these methods, the only non-trivial one is the `get_preference` method. This
-implements the heuristics used to guide the resolution, telling it which
-requirement to try to satisfy next. It's this method that is responsible for
-trying to guess which route through the dependency tree will be most productive.
-As noted above, it's doing this with limited information. See the following
-diagram
+Of these methods, the only non-trivial ones are the `get_preference` and
+`narrow_requirement_selection` methods. These implement heuristics used
+to guide the resolution, telling it which requirement to try to satisfy next.
+It's these methods that are responsible for trying to guess which route through
+the dependency tree will be most productive. As noted above, it's doing this
+with limited information. See the following diagram:
 
 ![](deps.png)
 
@@ -153,6 +155,13 @@ When the provider is asked to choose between the red requirements (A->B and
 A->C) it doesn't know anything about the dependencies of B or C (i.e., the
 grey parts of the graph).
 
+Pip's current implementation of the provider implements
+`narrow_requirement_selection` as follows:
+
+* If Requires-Python is present only consider that
+* If there are causes of resolution conflict (backtrack causes) then
+  only consider them until there are no longer any resolution conflicts
+
 Pip's current implementation of the provider implements `get_preference` as
 follows:
 
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/13135.feature.rst b/news/13135.feature.rst
new file mode 100644
index 000000000..02deaeda5
--- /dev/null
+++ b/news/13135.feature.rst
@@ -0,0 +1,3 @@
+Use :pep:`753` "Well-known Project URLs in Metadata" normalization rules when
+identifying an equivalent project URL to replace a missing ``Home-Page`` field
+in ``pip show``.
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/13229.bugfix.rst b/news/13229.bugfix.rst
new file mode 100644
index 000000000..fa9d60095
--- /dev/null
+++ b/news/13229.bugfix.rst
@@ -0,0 +1,4 @@
+Parse wheel filenames according to `binary distribution format specification
+<https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-format>`_.
+When a filename doesn't match the spec a deprecation warning is emitted and the
+filename is parsed using the old method.
diff --git a/news/13253.feature.rst b/news/13253.feature.rst
new file mode 100644
index 000000000..712e1639e
--- /dev/null
+++ b/news/13253.feature.rst
@@ -0,0 +1,2 @@
+Speed up resolution by first only considering the preference of
+candidates that must be required to complete the resolution.
diff --git a/news/13270.bugfix.rst b/news/13270.bugfix.rst
new file mode 100644
index 000000000..9828b666d
--- /dev/null
+++ b/news/13270.bugfix.rst
@@ -0,0 +1,3 @@
+Fix a regression that causes dependencies to be checked *before* ``Requires-Python``
+project metadata is checked, leading to wasted cycles when the Python version is
+unsupported.
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/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/show.py b/src/pip/_internal/commands/show.py
index b47500cf8..7aaf6f4b6 100644
--- a/src/pip/_internal/commands/show.py
+++ b/src/pip/_internal/commands/show.py
@@ -1,4 +1,5 @@
 import logging
+import string
 from optparse import Values
 from typing import Generator, Iterable, Iterator, List, NamedTuple, Optional
 
@@ -13,6 +14,13 @@ from pip._internal.utils.misc import write_output
 logger = logging.getLogger(__name__)
 
 
+def normalize_project_url_label(label: str) -> str:
+    # This logic is from PEP 753 (Well-known Project URLs in Metadata).
+    chars_to_remove = string.punctuation + string.whitespace
+    removal_map = str.maketrans("", "", chars_to_remove)
+    return label.translate(removal_map).lower()
+
+
 class ShowCommand(Command):
     """
     Show information about one or more installed packages.
@@ -135,13 +143,9 @@ def search_packages_info(query: List[str]) -> Generator[_PackageInfo, None, None
         if not homepage:
             # It's common that there is a "homepage" Project-URL, but Home-page
             # remains unset (especially as PEP 621 doesn't surface the field).
-            #
-            # This logic was taken from PyPI's codebase.
             for url in project_urls:
                 url_label, url = url.split(",", maxsplit=1)
-                normalized_label = (
-                    url_label.casefold().replace("-", "").replace("_", "").strip()
-                )
+                normalized_label = normalize_project_url_label(url_label)
                 if normalized_label == "homepage":
                     homepage = url.strip()
                     break
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index 85628ee5d..ce0842b07 100644
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -514,11 +514,7 @@ class CandidateEvaluator:
                 )
             if self._prefer_binary:
                 binary_preference = 1
-            if wheel.build_tag is not None:
-                match = re.match(r"^(\d+)(.*)$", wheel.build_tag)
-                assert match is not None, "guaranteed by filename validation"
-                build_tag_groups = match.groups()
-                build_tag = (int(build_tag_groups[0]), build_tag_groups[1])
+            build_tag = wheel.build_tag
         else:  # sdist
             pri = -(support_num)
         has_allowed_hash = int(link.is_hash_allowed(self._hashes))
diff --git a/src/pip/_internal/metadata/importlib/_envs.py b/src/pip/_internal/metadata/importlib/_envs.py
index 4d906fd31..681fd241f 100644
--- a/src/pip/_internal/metadata/importlib/_envs.py
+++ b/src/pip/_internal/metadata/importlib/_envs.py
@@ -8,10 +8,14 @@ import zipfile
 import zipimport
 from typing import Iterator, List, Optional, Sequence, Set, Tuple
 
-from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
+from pip._vendor.packaging.utils import (
+    InvalidWheelFilename,
+    NormalizedName,
+    canonicalize_name,
+    parse_wheel_filename,
+)
 
 from pip._internal.metadata.base import BaseDistribution, BaseEnvironment
-from pip._internal.models.wheel import Wheel
 from pip._internal.utils.deprecation import deprecated
 from pip._internal.utils.filetypes import WHEEL_EXTENSION
 
@@ -26,7 +30,9 @@ def _looks_like_wheel(location: str) -> bool:
         return False
     if not os.path.isfile(location):
         return False
-    if not Wheel.wheel_file_re.match(os.path.basename(location)):
+    try:
+        parse_wheel_filename(os.path.basename(location))
+    except InvalidWheelFilename:
         return False
     return zipfile.is_zipfile(location)
 
diff --git a/src/pip/_internal/models/wheel.py b/src/pip/_internal/models/wheel.py
index ea8560089..d905d652e 100644
--- a/src/pip/_internal/models/wheel.py
+++ b/src/pip/_internal/models/wheel.py
@@ -3,13 +3,13 @@ name that have meaning.
 """
 
 import re
-from typing import Dict, Iterable, List
+from typing import Dict, Iterable, List, Optional
 
 from pip._vendor.packaging.tags import Tag
+from pip._vendor.packaging.utils import BuildTag, parse_wheel_filename
 from pip._vendor.packaging.utils import (
-    InvalidWheelFilename as PackagingInvalidWheelName,
+    InvalidWheelFilename as _PackagingInvalidWheelFilename,
 )
-from pip._vendor.packaging.utils import parse_wheel_filename
 
 from pip._internal.exceptions import InvalidWheelFilename
 from pip._internal.utils.deprecation import deprecated
@@ -18,7 +18,7 @@ from pip._internal.utils.deprecation import deprecated
 class Wheel:
     """A wheel file"""
 
-    wheel_file_re = re.compile(
+    legacy_wheel_file_re = re.compile(
         r"""^(?P<namever>(?P<name>[^\s-]+?)-(?P<ver>[^\s-]*?))
         ((-(?P<build>\d[^-]*?))?-(?P<pyver>[^\s-]+?)-(?P<abi>[^\s-]+?)-(?P<plat>[^\s-]+?)
         \.whl|\.dist-info)$""",
@@ -26,46 +26,67 @@ class Wheel:
     )
 
     def __init__(self, filename: str) -> None:
-        """
-        :raises InvalidWheelFilename: when the filename is invalid for a wheel
-        """
-        wheel_info = self.wheel_file_re.match(filename)
-        if not wheel_info:
-            raise InvalidWheelFilename(f"{filename} is not a valid wheel filename.")
         self.filename = filename
-        self.name = wheel_info.group("name").replace("_", "-")
-        _version = wheel_info.group("ver")
-        if "_" in _version:
-            try:
-                parse_wheel_filename(filename)
-            except PackagingInvalidWheelName as e:
-                deprecated(
-                    reason=(
-                        f"Wheel filename {filename!r} is not correctly normalised. "
-                        "Future versions of pip will raise the following error:\n"
-                        f"{e.args[0]}\n\n"
-                    ),
-                    replacement=(
-                        "to rename the wheel to use a correctly normalised "
-                        "name (this may require updating the version in "
-                        "the project metadata)"
-                    ),
-                    gone_in="25.1",
-                    issue=12938,
-                )
-
-            _version = _version.replace("_", "-")
-
-        self.version = _version
-        self.build_tag = wheel_info.group("build")
-        self.pyversions = wheel_info.group("pyver").split(".")
-        self.abis = wheel_info.group("abi").split(".")
-        self.plats = wheel_info.group("plat").split(".")
-
-        # All the tag combinations from this file
-        self.file_tags = {
-            Tag(x, y, z) for x in self.pyversions for y in self.abis for z in self.plats
-        }
+
+        # To make mypy happy specify type hints that can come from either
+        # parse_wheel_filename or the legacy_wheel_file_re match.
+        self.name: str
+        self._build_tag: Optional[BuildTag] = None
+
+        try:
+            wheel_info = parse_wheel_filename(filename)
+            self.name, _version, self._build_tag, self.file_tags = wheel_info
+            self.version = str(_version)
+        except _PackagingInvalidWheelFilename as e:
+            # Check if the wheel filename is in the legacy format
+            legacy_wheel_info = self.legacy_wheel_file_re.match(filename)
+            if not legacy_wheel_info:
+                raise InvalidWheelFilename(e.args[0]) from None
+
+            deprecated(
+                reason=(
+                    f"Wheel filename {filename!r} is not correctly normalised. "
+                    "Future versions of pip will raise the following error:\n"
+                    f"{e.args[0]}\n\n"
+                ),
+                replacement=(
+                    "to rename the wheel to use a correctly normalised "
+                    "name (this may require updating the version in "
+                    "the project metadata)"
+                ),
+                gone_in="25.3",
+                issue=12938,
+            )
+
+            self.name = legacy_wheel_info.group("name").replace("_", "-")
+            self.version = legacy_wheel_info.group("ver").replace("_", "-")
+
+            # Generate the file tags from the legacy wheel filename
+            pyversions = legacy_wheel_info.group("pyver").split(".")
+            abis = legacy_wheel_info.group("abi").split(".")
+            plats = legacy_wheel_info.group("plat").split(".")
+            self.file_tags = frozenset(
+                Tag(interpreter=py, abi=abi, platform=plat)
+                for py in pyversions
+                for abi in abis
+                for plat in plats
+            )
+
+    @property
+    def build_tag(self) -> BuildTag:
+        if self._build_tag is not None:
+            return self._build_tag
+
+        # Parse the build tag from the legacy wheel filename
+        legacy_wheel_info = self.legacy_wheel_file_re.match(self.filename)
+        assert legacy_wheel_info is not None, "guaranteed by filename validation"
+        build_tag = legacy_wheel_info.group("build")
+        match = re.match(r"^(\d+)(.*)$", build_tag)
+        assert match is not None, "guaranteed by filename validation"
+        build_tag_groups = match.groups()
+        self._build_tag = (int(build_tag_groups[0]), build_tag_groups[1])
+
+        return self._build_tag
 
     def get_formatted_file_tags(self) -> List[str]:
         """Return the wheel's tags as a sorted list of strings."""
diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py
index 1d21ede72..d976026ac 100644
--- a/src/pip/_internal/resolution/resolvelib/candidates.py
+++ b/src/pip/_internal/resolution/resolvelib/candidates.py
@@ -249,10 +249,12 @@ class _InstallRequirementBackedCandidate(Candidate):
         return dist
 
     def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requirement]]:
+        # Emit the Requires-Python requirement first to fail fast on
+        # unsupported candidates and avoid pointless downloads/preparation.
+        yield self._factory.make_requires_python_requirement(self.dist.requires_python)
         requires = self.dist.iter_dependencies() if with_requires else ()
         for r in requires:
             yield from self._factory.make_requirements_from_spec(str(r), self._ireq)
-        yield self._factory.make_requires_python_requirement(self.dist.requires_python)
 
     def get_install_requirement(self) -> Optional[InstallRequirement]:
         return self._ireq
diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py
index 40326001e..afdffe819 100644
--- a/src/pip/_internal/resolution/resolvelib/provider.py
+++ b/src/pip/_internal/resolution/resolvelib/provider.py
@@ -103,6 +103,49 @@ class PipProvider(_ProviderBase):
     def identify(self, requirement_or_candidate: Union[Requirement, Candidate]) -> str:
         return requirement_or_candidate.name
 
+    def narrow_requirement_selection(
+        self,
+        identifiers: Iterable[str],
+        resolutions: Mapping[str, Candidate],
+        candidates: Mapping[str, Iterator[Candidate]],
+        information: Mapping[str, Iterator["PreferenceInformation"]],
+        backtrack_causes: Sequence["PreferenceInformation"],
+    ) -> Iterable[str]:
+        """Produce a subset of identifiers that should be considered before others.
+
+        Currently pip narrows the following selection:
+            * Requires-Python, if present is always returned by itself
+            * Backtrack causes are considered next because they can be identified
+              in linear time here, whereas because get_preference() is called
+              for each identifier, it would be quadratic to check for them there.
+              Further, the current backtrack causes likely need to be resolved
+              before other requirements as a resolution can't be found while
+              there is a conflict.
+        """
+        backtrack_identifiers = set()
+        for info in backtrack_causes:
+            backtrack_identifiers.add(info.requirement.name)
+            if info.parent is not None:
+                backtrack_identifiers.add(info.parent.name)
+
+        current_backtrack_causes = []
+        for identifier in identifiers:
+            # Requires-Python has only one candidate and the check is basically
+            # free, so we always do it first to avoid needless work if it fails.
+            # This skips calling get_preference() for all other identifiers.
+            if identifier == REQUIRES_PYTHON_IDENTIFIER:
+                return [identifier]
+
+            # Check if this identifier is a backtrack cause
+            if identifier in backtrack_identifiers:
+                current_backtrack_causes.append(identifier)
+                continue
+
+        if current_backtrack_causes:
+            return current_backtrack_causes
+
+        return identifiers
+
     def get_preference(
         self,
         identifier: str,
@@ -153,20 +196,9 @@ class PipProvider(_ProviderBase):
         unfree = bool(operators)
         requested_order = self._user_requested.get(identifier, math.inf)
 
-        # Requires-Python has only one candidate and the check is basically
-        # free, so we always do it first to avoid needless work if it fails.
-        requires_python = identifier == REQUIRES_PYTHON_IDENTIFIER
-
-        # Prefer the causes of backtracking on the assumption that the problem
-        # resolving the dependency tree is related to the failures that caused
-        # the backtracking
-        backtrack_cause = self.is_backtrack_cause(identifier, backtrack_causes)
-
         return (
-            not requires_python,
             not direct,
             not pinned,
-            not backtrack_cause,
             requested_order,
             not unfree,
             identifier,
@@ -218,17 +250,7 @@ class PipProvider(_ProviderBase):
     def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> bool:
         return requirement.is_satisfied_by(candidate)
 
-    def get_dependencies(self, candidate: Candidate) -> Sequence[Requirement]:
+    def get_dependencies(self, candidate: Candidate) -> Iterable[Requirement]:
         with_requires = not self._ignore_dependencies
-        return [r for r in candidate.iter_dependencies(with_requires) if r is not None]
-
-    @staticmethod
-    def is_backtrack_cause(
-        identifier: str, backtrack_causes: Sequence["PreferenceInformation"]
-    ) -> bool:
-        for backtrack_cause in backtrack_causes:
-            if identifier == backtrack_cause.requirement.name:
-                return True
-            if backtrack_cause.parent and identifier == backtrack_cause.parent.name:
-                return True
-        return False
+        # iter_dependencies() can perform nontrivial work so delay until needed.
+        return (r for r in candidate.iter_dependencies(with_requires) if r is not None)
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:


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_install_wheel.py b/tests/functional/test_install_wheel.py
index 7e7aeaf7a..e01ecfb57 100644
--- a/tests/functional/test_install_wheel.py
+++ b/tests/functional/test_install_wheel.py
@@ -190,7 +190,7 @@ def test_install_from_wheel_with_headers(script: PipTestEnvironment) -> None:
     dist_info_folder = script.site_packages / "headers.dist-0.1.dist-info"
     result.did_create(dist_info_folder)
 
-    header_scheme_path = get_header_scheme_path_for_script(script, "headers.dist")
+    header_scheme_path = get_header_scheme_path_for_script(script, "headers-dist")
     header_path = header_scheme_path / "header.h"
     assert header_path.read_text() == header_text
 
diff --git a/tests/functional/test_new_resolver_errors.py b/tests/functional/test_new_resolver_errors.py
index 5976de52e..ebca917d0 100644
--- a/tests/functional/test_new_resolver_errors.py
+++ b/tests/functional/test_new_resolver_errors.py
@@ -126,7 +126,9 @@ def test_new_resolver_checks_requires_python_before_dependencies(
         expect_error=True,
     )
 
-    # Resolution should fail because of pkg-a's Requires-Python.
-    # This check should be done before pkg-b, so pkg-b should never be pulled.
+    # Resolution should fail because of pkg-root's Requires-Python.
+    # This is done before dependencies so pkg-dep should never be pulled.
     assert incompatible_python in result.stderr, str(result)
-    assert "pkg-b" not in result.stderr, str(result)
+    # Setuptools produces wheels with normalized names.
+    assert "pkg_dep" not in result.stderr, str(result)
+    assert "pkg_dep" not in result.stdout, str(result)
diff --git a/tests/functional/test_show.py b/tests/functional/test_show.py
index b7caea46f..ea8319353 100644
--- a/tests/functional/test_show.py
+++ b/tests/functional/test_show.py
@@ -408,7 +408,8 @@ def test_show_deduplicate_requirements(script: PipTestEnvironment) -> None:
 
 
 @pytest.mark.parametrize(
-    "project_url", ["Home-page", "home-page", "Homepage", "homepage"]
+    "project_url",
+    ["Home-page", "home-page", "Homepage", "homepage", "home_PAGE", "home page"],
 )
 def test_show_populate_homepage_from_project_urls(
     script: PipTestEnvironment, project_url: str
diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py
index d37076a93..690217e85 100644
--- a/tests/unit/resolution_resolvelib/test_provider.py
+++ b/tests/unit/resolution_resolvelib/test_provider.py
@@ -1,5 +1,5 @@
 import math
-from typing import TYPE_CHECKING, Dict, Iterable, Optional, Sequence
+from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence
 
 import pytest
 
@@ -36,21 +36,13 @@ def build_req_info(
 @pytest.mark.parametrize(
     "identifier, information, backtrack_causes, user_requested, expected",
     [
-        # Test case for REQUIRES_PYTHON_IDENTIFIER
-        (
-            REQUIRES_PYTHON_IDENTIFIER,
-            {REQUIRES_PYTHON_IDENTIFIER: [build_req_info("python")]},
-            [],
-            {},
-            (False, False, True, True, math.inf, True, REQUIRES_PYTHON_IDENTIFIER),
-        ),
         # Pinned package with "=="
         (
             "pinned-package",
             {"pinned-package": [build_req_info("pinned-package==1.0")]},
             [],
             {},
-            (True, False, False, True, math.inf, False, "pinned-package"),
+            (False, False, math.inf, False, "pinned-package"),
         ),
         # Star-specified package, i.e. with "*"
         (
@@ -58,7 +50,7 @@ def build_req_info(
             {"star-specified-package": [build_req_info("star-specified-package==1.*")]},
             [],
             {},
-            (True, False, True, True, math.inf, False, "star-specified-package"),
+            (False, True, math.inf, False, "star-specified-package"),
         ),
         # Package that caused backtracking
         (
@@ -66,7 +58,7 @@ def build_req_info(
             {"backtrack-package": [build_req_info("backtrack-package")]},
             [build_req_info("backtrack-package")],
             {},
-            (True, False, True, False, math.inf, True, "backtrack-package"),
+            (False, True, math.inf, True, "backtrack-package"),
         ),
         # Root package requested by user
         (
@@ -74,7 +66,7 @@ def build_req_info(
             {"root-package": [build_req_info("root-package")]},
             [],
             {"root-package": 1},
-            (True, False, True, True, 1, True, "root-package"),
+            (False, True, 1, True, "root-package"),
         ),
         # Unfree package (with specifier operator)
         (
@@ -82,7 +74,7 @@ def build_req_info(
             {"unfree-package": [build_req_info("unfree-package<1")]},
             [],
             {},
-            (True, False, True, True, math.inf, False, "unfree-package"),
+            (False, True, math.inf, False, "unfree-package"),
         ),
         # Free package (no operator)
         (
@@ -90,7 +82,7 @@ def build_req_info(
             {"free-package": [build_req_info("free-package")]},
             [],
             {},
-            (True, False, True, True, math.inf, True, "free-package"),
+            (False, True, math.inf, True, "free-package"),
         ),
     ],
 )
@@ -115,3 +107,70 @@ def test_get_preference(
     )
 
     assert preference == expected, f"Expected {expected}, got {preference}"
+
+
+@pytest.mark.parametrize(
+    "identifiers, backtrack_causes, expected",
+    [
+        # REQUIRES_PYTHON_IDENTIFIER is present
+        (
+            [REQUIRES_PYTHON_IDENTIFIER, "package1", "package2", "backtrack-package"],
+            [build_req_info("backtrack-package")],
+            [REQUIRES_PYTHON_IDENTIFIER],
+        ),
+        # REQUIRES_PYTHON_IDENTIFIER is present after backtrack causes
+        (
+            ["package1", "package2", "backtrack-package", REQUIRES_PYTHON_IDENTIFIER],
+            [build_req_info("backtrack-package")],
+            [REQUIRES_PYTHON_IDENTIFIER],
+        ),
+        # Backtrack causes present (direct requirement)
+        (
+            ["package1", "package2", "backtrack-package"],
+            [build_req_info("backtrack-package")],
+            ["backtrack-package"],
+        ),
+        # Multiple backtrack causes
+        (
+            ["package1", "backtrack1", "backtrack2", "package2"],
+            [build_req_info("backtrack1"), build_req_info("backtrack2")],
+            ["backtrack1", "backtrack2"],
+        ),
+        # No special identifiers - return all
+        (
+            ["package1", "package2"],
+            [],
+            ["package1", "package2"],
+        ),
+        # Empty list of identifiers
+        (
+            [],
+            [],
+            [],
+        ),
+    ],
+)
+def test_narrow_requirement_selection(
+    identifiers: List[str],
+    backtrack_causes: Sequence["PreferenceInformation"],
+    expected: List[str],
+    factory: Factory,
+) -> None:
+    """Test that narrow_requirement_selection correctly prioritizes identifiers:
+    1. REQUIRES_PYTHON_IDENTIFIER (if present)
+    2. Backtrack causes (if present)
+    3. All other identifiers (as-is)
+    """
+    provider = PipProvider(
+        factory=factory,
+        constraints={},
+        ignore_dependencies=False,
+        upgrade_strategy="to-satisfy-only",
+        user_requested={},
+    )
+
+    result = provider.narrow_requirement_selection(
+        identifiers, {}, {}, {}, backtrack_causes
+    )
+
+    assert list(result) == expected, f"Expected {expected}, got {list(result)}"
diff --git a/tests/unit/test_models_wheel.py b/tests/unit/test_models_wheel.py
index e87b2c107..46991e348 100644
--- a/tests/unit/test_models_wheel.py
+++ b/tests/unit/test_models_wheel.py
@@ -12,17 +12,24 @@ class TestWheelFile:
         w = Wheel("simple-1.1.1-py2-none-any.whl")
         assert w.name == "simple"
         assert w.version == "1.1.1"
-        assert w.pyversions == ["py2"]
-        assert w.abis == ["none"]
-        assert w.plats == ["any"]
+        assert w.build_tag == ()
+        assert w.file_tags == frozenset(
+            [Tag(interpreter="py2", abi="none", platform="any")]
+        )
 
     def test_wheel_pattern_multi_values(self) -> None:
         w = Wheel("simple-1.1-py2.py3-abi1.abi2-any.whl")
         assert w.name == "simple"
         assert w.version == "1.1"
-        assert w.pyversions == ["py2", "py3"]
-        assert w.abis == ["abi1", "abi2"]
-        assert w.plats == ["any"]
+        assert w.build_tag == ()
+        assert w.file_tags == frozenset(
+            [
+                Tag(interpreter="py2", abi="abi1", platform="any"),
+                Tag(interpreter="py2", abi="abi2", platform="any"),
+                Tag(interpreter="py3", abi="abi1", platform="any"),
+                Tag(interpreter="py3", abi="abi2", platform="any"),
+            ]
+        )
 
     def test_wheel_with_build_tag(self) -> None:
         # pip doesn't do anything with build tags, but theoretically, we might
@@ -30,16 +37,18 @@ class TestWheelFile:
         w = Wheel("simple-1.1-4-py2-none-any.whl")
         assert w.name == "simple"
         assert w.version == "1.1"
-        assert w.pyversions == ["py2"]
-        assert w.abis == ["none"]
-        assert w.plats == ["any"]
+        assert w.build_tag == (4, "")
+        assert w.file_tags == frozenset(
+            [Tag(interpreter="py2", abi="none", platform="any")]
+        )
 
     def test_single_digit_version(self) -> None:
         w = Wheel("simple-1-py2-none-any.whl")
         assert w.version == "1"
 
     def test_non_pep440_version(self) -> None:
-        w = Wheel("simple-_invalid_-py2-none-any.whl")
+        with pytest.warns(deprecation.PipDeprecationWarning):
+            w = Wheel("simple-_invalid_-py2-none-any.whl")
         assert w.version == "-invalid-"
 
     def test_missing_version_raises(self) -> None: