Repository: pypa/pip
Pull request #13242 (2025-02-24 23:53:56)

Implement PEP 753 normalization for Home-Page fallback
We treat "homepage" as a well-known project URL, using it to populate the Home-Page core metadata field if it's undefined. This patch updates the normalization logic to follow PEP 753.

**Feedback wanted**: The logic emitting the raw project URLs was left unchanged as we don't really care for well-known vs. boring URLs here. I believe this is the right call, but I can be convinced to also apply normalization here (although that could break anyone who is using `pip show` to inspect metadata which is actually a valid use-case as the show output format is documented.)

```
$ pip show pip --verbose
Name: pip
Version: 25.1.dev0
Summary: The PyPA recommended tool for installing Python packages.
Home-page: https://pip.pypa.io/
Author: 
Author-email: The pip developers <distutils-sig@python.org>
License: MIT
Location: /home/ichard26/dev/oss/pip/venv/lib/python3.12/site-packages
Editable project location: /home/ichard26/dev/oss/pip
Requires: 
Required-by: flit
Metadata-Version: 2.2
Installer: pip
Classifiers:
  [... a bunch of classifiers]
Entry-points:
  [console_scripts]
  pip = pip._internal.cli.main:main
  pip3 = pip._internal.cli.main:main
Project-URLs:
  Homepage, https://pip.pypa.io/
  Documentation, https://pip.pypa.io
  Source, https://github.com/pypa/pip
  Changelog, https://pip.pypa.io/en/stable/news/
```


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa6dea0f3..8b582e104 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -114,7 +114,6 @@ jobs:
       matrix:
         os: [ubuntu-22.04, macos-13, macos-latest]
         python:
-          - "3.8"
           - "3.9"
           - "3.10"
           - "3.11"
@@ -172,10 +171,9 @@ jobs:
       matrix:
         os: [Windows]
         python:
-          - "3.8"
+          - "3.9"
           # Commented out, since Windows tests are expensively slow,
           # only test the oldest and newest Python supported by pip
-          # - "3.9"
           # - "3.10"
           # - "3.11"
           # - "3.12"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 60b32a5c9..a3995a32c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,7 +17,7 @@ jobs:
       - name: Build a binary wheel and a source tarball
         run: ./build-project.py
       - name: Store the distribution packages
-        uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
+        uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 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/docs/html/development/ci.rst b/docs/html/development/ci.rst
index ef1f7125d..f2db084ad 100644
--- a/docs/html/development/ci.rst
+++ b/docs/html/development/ci.rst
@@ -18,7 +18,6 @@ Supported interpreters
 
 pip support a variety of Python interpreters:
 
-- CPython 3.8
 - CPython 3.9
 - CPython 3.10
 - CPython 3.11
@@ -91,9 +90,7 @@ Actual testing
 +------------------------------+---------------+-----------------+
 |       **interpreter**        |   **unit**    | **integration** |
 +-----------+----------+-------+---------------+-----------------+
-|           |   x86    | CP3.8 |               |                 |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |               |                 |
+|           |   x86    | CP3.9 |               |                 |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|               |                 |
 |           |          +-------+---------------+-----------------+
@@ -105,9 +102,7 @@ Actual testing
 |           |          +-------+---------------+-----------------+
 |           |          | PyPy3 |               |                 |
 |  Windows  +----------+-------+---------------+-----------------+
-|           |   x64    | CP3.8 |   GitHub      |   GitHub        |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |               |                 |
+|           |   x64    | CP3.9 |   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|               |                 |
 |           |          +-------+---------------+-----------------+
@@ -119,9 +114,7 @@ Actual testing
 |           |          +-------+---------------+-----------------+
 |           |          | PyPy3 |               |                 |
 +-----------+----------+-------+---------------+-----------------+
-|           |   x86    | CP3.8 |               |                 |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |               |                 |
+|           |   x86    | CP3.9 |               |                 |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|               |                 |
 |           |          +-------+---------------+-----------------+
@@ -133,9 +126,7 @@ Actual testing
 |           |          +-------+---------------+-----------------+
 |           |          | PyPy3 |               |                 |
 |   Linux   +----------+-------+---------------+-----------------+
-|           |   x64    | CP3.8 |   GitHub      |   GitHub        |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |   GitHub      |   GitHub        |
+|           |   x64    | CP3.9 |   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
@@ -147,9 +138,7 @@ Actual testing
 |           |          +-------+---------------+-----------------+
 |           |          | PyPy3 |               |                 |
 +-----------+----------+-------+---------------+-----------------+
-|           |  arm64   | CP3.8 |   GitHub      |   GitHub        |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |   GitHub      |   GitHub        |
+|           |  arm64   | CP3.9 |   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
@@ -161,9 +150,7 @@ Actual testing
 |           |          +-------+---------------+-----------------+
 |           |          | PyPy3 |               |                 |
 |   macOS   +----------+-------+---------------+-----------------+
-|           |   x64    | CP3.8 |   GitHub      |   GitHub        |
-|           |          +-------+---------------+-----------------+
-|           |          | CP3.9 |   GitHub      |   GitHub        |
+|           |   x64    | CP3.9 |   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
 |           |          | CP3.10|   GitHub      |   GitHub        |
 |           |          +-------+---------------+-----------------+
diff --git a/docs/html/installation.md b/docs/html/installation.md
index ffc8199dd..11c48dfc7 100644
--- a/docs/html/installation.md
+++ b/docs/html/installation.md
@@ -126,7 +126,7 @@ $ pip install --upgrade pip
 The current version of pip works on:
 
 - Windows, Linux and macOS.
-- CPython 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, and latest PyPy3.
+- CPython 3.9, 3.10, 3.11, 3.12, 3.13, and latest PyPy3.
 
 pip is tested to work on the latest patch version of the Python interpreter,
 for each of the minor versions listed above. Previous patch versions are
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/12989.removal.rst b/news/12989.removal.rst
new file mode 100644
index 000000000..50f0008c6
--- /dev/null
+++ b/news/12989.removal.rst
@@ -0,0 +1,1 @@
+Drop support for Python 3.8.
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/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/noxfile.py b/noxfile.py
index e3f902b42..57f5e897d 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -69,7 +69,7 @@ def should_update_common_wheels() -> bool:
 # -----------------------------------------------------------------------------
 # Development Commands
 # -----------------------------------------------------------------------------
-@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3"])
+@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3"])
 def test(session: nox.Session) -> None:
     # Get the common wheels.
     if should_update_common_wheels():
diff --git a/pyproject.toml b/pyproject.toml
index ed79101bf..23830a248 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,6 @@ classifiers = [
   "Programming Language :: Python",
   "Programming Language :: Python :: 3",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
@@ -28,7 +27,7 @@ authors = [
 
 # NOTE: requires-python is duplicated in __pip-runner__.py.
 # When changing this value, please change the other copy as well.
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 
 [project.scripts]
 pip = "pip._internal.cli.main:main"
@@ -152,6 +151,8 @@ distlib = "https://bitbucket.org/pypa/distlib/raw/master/LICENSE.txt"
 #
 
 [tool.ruff]
+# Pinned to delay pyupgrade changes (https://github.com/pypa/pip/issues/13236)
+target-version = "py38"  # Pin Ruff to Python 3.8
 src = ["src"]
 line-length = 88
 extend-exclude = [
diff --git a/src/pip/__pip-runner__.py b/src/pip/__pip-runner__.py
index c633787fc..d6be15783 100644
--- a/src/pip/__pip-runner__.py
+++ b/src/pip/__pip-runner__.py
@@ -9,7 +9,7 @@ an import statement.
 import sys
 
 # Copied from pyproject.toml
-PYTHON_REQUIRES = (3, 8)
+PYTHON_REQUIRES = (3, 9)
 
 
 def version_str(version):  # type: ignore
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 74b8d656b..c58c2b3b1 100644
--- a/src/pip/_internal/commands/search.py
+++ b/src/pip/_internal/commands/search.py
@@ -5,7 +5,7 @@ import textwrap
 import xmlrpc.client
 from collections import OrderedDict
 from optparse import Values
-from typing import TYPE_CHECKING, Dict, List, Optional, TypedDict
+from typing import Dict, List, Optional, TypedDict
 
 from pip._vendor.packaging.version import parse as parse_version
 
@@ -14,17 +14,17 @@ 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
 from pip._internal.utils.misc import write_output
 
-if TYPE_CHECKING:
 
-    class TransformedHit(TypedDict):
-        name: str
-        summary: str
-        versions: List[str]
+class TransformedHit(TypedDict):
+    name: str
+    summary: str
+    versions: List[str]
 
 
 logger = logging.getLogger(__name__)
@@ -111,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:
@@ -130,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,
@@ -163,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/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/locations/__init__.py b/src/pip/_internal/locations/__init__.py
index 32382be7f..e5b4b9eec 100644
--- a/src/pip/_internal/locations/__init__.py
+++ b/src/pip/_internal/locations/__init__.py
@@ -304,7 +304,6 @@ def get_scheme(
             user
             and k == "platlib"
             and not WINDOWS
-            and sys.version_info >= (3, 9)
             and _PLATLIBDIR != "lib"
             and _looks_like_bpo_44860()
         )
diff --git a/src/pip/_internal/metadata/__init__.py b/src/pip/_internal/metadata/__init__.py
index 1ea1e7fd2..3535e5699 100644
--- a/src/pip/_internal/metadata/__init__.py
+++ b/src/pip/_internal/metadata/__init__.py
@@ -2,17 +2,12 @@ import contextlib
 import functools
 import os
 import sys
-from typing import TYPE_CHECKING, List, Optional, Type, cast
+from typing import List, Literal, Optional, Protocol, Type, cast
 
 from pip._internal.utils.misc import strtobool
 
 from .base import BaseDistribution, BaseEnvironment, FilesystemWheel, MemoryWheel, Wheel
 
-if TYPE_CHECKING:
-    from typing import Literal, Protocol
-else:
-    Protocol = object
-
 __all__ = [
     "BaseDistribution",
     "BaseEnvironment",
diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py
index 769c433d0..73e4bfc7c 100644
--- a/src/pip/_internal/operations/install/wheel.py
+++ b/src/pip/_internal/operations/install/wheel.py
@@ -13,10 +13,10 @@ import sys
 import warnings
 from base64 import urlsafe_b64encode
 from email.message import Message
+from io import StringIO
 from itertools import chain, filterfalse, starmap
 from typing import (
     IO,
-    TYPE_CHECKING,
     Any,
     BinaryIO,
     Callable,
@@ -50,7 +50,7 @@ from pip._internal.metadata import (
 from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl
 from pip._internal.models.scheme import SCHEME_KEYS, Scheme
 from pip._internal.utils.filesystem import adjacent_tmp_file, replace
-from pip._internal.utils.misc import StreamWrapper, ensure_dir, hash_file, partition
+from pip._internal.utils.misc import ensure_dir, hash_file, partition
 from pip._internal.utils.unpacking import (
     current_umask,
     is_within_directory,
@@ -59,15 +59,14 @@ from pip._internal.utils.unpacking import (
 )
 from pip._internal.utils.wheel import parse_wheel
 
-if TYPE_CHECKING:
 
-    class File(Protocol):
-        src_record_path: "RecordPath"
-        dest_path: str
-        changed: bool
+class File(Protocol):
+    src_record_path: "RecordPath"
+    dest_path: str
+    changed: bool
 
-        def save(self) -> None:
-            pass
+    def save(self) -> None:
+        pass
 
 
 logger = logging.getLogger(__name__)
@@ -608,9 +607,7 @@ def _install_wheel(  # noqa: C901, PLR0915 function is too long
 
     # Compile all of the pyc files for the installed files
     if pycompile:
-        with contextlib.redirect_stdout(
-            StreamWrapper.from_stream(sys.stdout)
-        ) as stdout:
+        with contextlib.redirect_stdout(StringIO()) as stdout:
             with warnings.catch_warnings():
                 warnings.filterwarnings("ignore")
                 for path in pyc_source_file_paths():
diff --git a/src/pip/_internal/resolution/resolvelib/found_candidates.py b/src/pip/_internal/resolution/resolvelib/found_candidates.py
index a1d57e0f4..dd07a4072 100644
--- a/src/pip/_internal/resolution/resolvelib/found_candidates.py
+++ b/src/pip/_internal/resolution/resolvelib/found_candidates.py
@@ -11,7 +11,7 @@ something.
 import functools
 import logging
 from collections.abc import Sequence
-from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Set, Tuple
+from typing import Any, Callable, Iterator, Optional, Set, Tuple
 
 from pip._vendor.packaging.version import _BaseVersion
 
@@ -23,21 +23,6 @@ logger = logging.getLogger(__name__)
 
 IndexCandidateInfo = Tuple[_BaseVersion, Callable[[], Optional[Candidate]]]
 
-if TYPE_CHECKING:
-    SequenceCandidate = Sequence[Candidate]
-else:
-    # For compatibility: Python before 3.9 does not support using [] on the
-    # Sequence class.
-    #
-    # >>> from collections.abc import Sequence
-    # >>> Sequence[str]
-    # Traceback (most recent call last):
-    #   File "<stdin>", line 1, in <module>
-    # TypeError: 'ABCMeta' object is not subscriptable
-    #
-    # TODO: Remove this block after dropping Python 3.8 support.
-    SequenceCandidate = Sequence
-
 
 def _iter_built(infos: Iterator[IndexCandidateInfo]) -> Iterator[Candidate]:
     """Iterator for ``FoundCandidates``.
@@ -124,7 +109,7 @@ def _iter_built_with_inserted(
         yield installed
 
 
-class FoundCandidates(SequenceCandidate):
+class FoundCandidates(Sequence[Candidate]):
     """A lazy sequence to provide candidates to the resolver.
 
     The intended usage is to return this from `find_matches()` so the resolver
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/utils/misc.py b/src/pip/_internal/utils/misc.py
index 44f6a05fb..156accda1 100644
--- a/src/pip/_internal/utils/misc.py
+++ b/src/pip/_internal/utils/misc.py
@@ -11,7 +11,6 @@ import sysconfig
 import urllib.parse
 from dataclasses import dataclass
 from functools import partial
-from io import StringIO
 from itertools import filterfalse, tee, zip_longest
 from pathlib import Path
 from types import FunctionType, TracebackType
@@ -26,7 +25,6 @@ from typing import (
     Mapping,
     Optional,
     Sequence,
-    TextIO,
     Tuple,
     Type,
     TypeVar,
@@ -375,22 +373,6 @@ def write_output(msg: Any, *args: Any) -> None:
     logger.info(msg, *args)
 
 
-class StreamWrapper(StringIO):
-    orig_stream: TextIO
-
-    @classmethod
-    def from_stream(cls, orig_stream: TextIO) -> "StreamWrapper":
-        ret = cls()
-        ret.orig_stream = orig_stream
-        return ret
-
-    # compileall.compile_dir() needs stdout.encoding to print to stdout
-    # type ignore is because TextIOBase.encoding is writeable
-    @property
-    def encoding(self) -> str:  # type: ignore
-        return self.orig_stream.encoding
-
-
 # Simulates an enum
 def enum(*sequential: Any, **named: Any) -> Type[Any]:
     enums = dict(zip(sequential, range(len(sequential))), **named)
diff --git a/src/pip/_internal/utils/unpacking.py b/src/pip/_internal/utils/unpacking.py
index 9a4ad8a33..feb40f828 100644
--- a/src/pip/_internal/utils/unpacking.py
+++ b/src/pip/_internal/utils/unpacking.py
@@ -208,7 +208,6 @@ def untar_file(filename: str, location: str) -> None:
                         member = data_filter(member, location)
                     except tarfile.LinkOutsideDestinationError:
                         if sys.version_info[:3] in {
-                            (3, 8, 17),
                             (3, 9, 17),
                             (3, 10, 12),
                             (3, 11, 4),
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.py b/tests/functional/test_install.py
index 8ada80ed1..1dc46cf86 100644
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -291,7 +291,7 @@ def test_pip_second_command_line_interface_works(
     args.extend(["install", "INITools==0.2"])
     args.extend(["-f", os.fspath(data.packages)])
     result = script.run(*args)
-    dist_info_folder = script.site_packages / "INITools-0.2.dist-info"
+    dist_info_folder = script.site_packages / "initools-0.2.dist-info"
     initools_folder = script.site_packages / "initools"
     result.did_create(dist_info_folder)
     result.did_create(initools_folder)
@@ -363,7 +363,7 @@ def test_basic_install_from_pypi(script: PipTestEnvironment) -> None:
     Test installing a package from PyPI.
     """
     result = script.pip("install", "INITools==0.2")
-    dist_info_folder = script.site_packages / "INITools-0.2.dist-info"
+    dist_info_folder = script.site_packages / "initools-0.2.dist-info"
     initools_folder = script.site_packages / "initools"
     result.did_create(dist_info_folder)
     result.did_create(initools_folder)
@@ -519,26 +519,15 @@ def test_install_editable_from_bazaar(script: PipTestEnvironment) -> None:
     result.assert_installed("testpackage", with_files=[".bzr"])
 
 
-@pytest.mark.network
-@need_bzr
-def test_vcs_url_urlquote_normalization(
-    script: PipTestEnvironment, tmpdir: Path
-) -> None:
+def test_vcs_url_urlquote_normalization(script: PipTestEnvironment) -> None:
     """
     Test that urlquoted characters are normalized for repo URL comparison.
     """
-    script.pip(
-        "install",
-        "-e",
-        "{url}/#egg=django-wikiapp".format(
-            url=local_checkout(
-                "bzr+http://bazaar.launchpad.net/"
-                "%7Edjango-wikiapp/django-wikiapp"
-                "/release-0.1",
-                tmpdir,
-            )
-        ),
+    pkg_path = _create_test_package(
+        script.scratch_path, name="django_wikiapp", vcs="git"
     )
+    url = f"git+{pkg_path.as_uri().replace('django_', 'django%5F')}/#egg=django_wikiapp"
+    script.pip("install", "-e", url)
 
 
 @pytest.mark.parametrize("resolver", ["", "--use-deprecated=legacy-resolver"])
@@ -555,7 +544,7 @@ def test_basic_install_from_local_directory(
     args.append(os.fspath(to_install))
     result = script.pip(*args)
     fspkg_folder = script.site_packages / "fspkg"
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     result.did_create(fspkg_folder)
     result.did_create(dist_info_folder)
 
@@ -577,7 +566,7 @@ def test_basic_install_relative_directory(
     """
     Test installing a requirement using a relative path.
     """
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     egg_link_file = script.site_packages / "FSPkg.egg-link"
     package_folder = script.site_packages / "fspkg"
 
@@ -871,7 +860,7 @@ def test_install_from_local_directory_with_in_tree_build(
     assert not in_tree_build_dir.exists()
     result = script.pip("install", to_install)
     fspkg_folder = script.site_packages / "fspkg"
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     result.did_create(fspkg_folder)
     result.did_create(dist_info_folder)
     assert in_tree_build_dir.exists()
@@ -1025,7 +1014,7 @@ def test_install_curdir(script: PipTestEnvironment, data: TestData) -> None:
         rmtree(egg_info)
     result = script.pip("install", curdir, cwd=run_from)
     fspkg_folder = script.site_packages / "fspkg"
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     result.did_create(fspkg_folder)
     result.did_create(dist_info_folder)
 
@@ -1037,7 +1026,7 @@ def test_install_pardir(script: PipTestEnvironment, data: TestData) -> None:
     run_from = data.packages.joinpath("FSPkg", "fspkg")
     result = script.pip("install", pardir, cwd=run_from)
     fspkg_folder = script.site_packages / "fspkg"
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     result.did_create(fspkg_folder)
     result.did_create(dist_info_folder)
 
@@ -1550,9 +1539,9 @@ def test_url_req_case_mismatch_no_index(
     )
 
     # only Upper-1.0.tar.gz should get installed.
-    dist_info_folder = script.site_packages / "Upper-1.0.dist-info"
+    dist_info_folder = script.site_packages / "upper-1.0.dist-info"
     result.did_create(dist_info_folder)
-    dist_info_folder = script.site_packages / "Upper-2.0.dist-info"
+    dist_info_folder = script.site_packages / "upper-2.0.dist-info"
     result.did_not_create(dist_info_folder)
 
 
@@ -1578,10 +1567,10 @@ def test_url_req_case_mismatch_file_index(
         "install", "--index-url", data.find_links3, Dinner, "requiredinner"
     )
 
-    # only Upper-1.0.tar.gz should get installed.
-    dist_info_folder = script.site_packages / "Dinner-1.0.dist-info"
+    # only Dinner-1.0.tar.gz should get installed.
+    dist_info_folder = script.site_packages / "dinner-1.0.dist-info"
     result.did_create(dist_info_folder)
-    dist_info_folder = script.site_packages / "Dinner-2.0.dist-info"
+    dist_info_folder = script.site_packages / "dinner-2.0.dist-info"
     result.did_not_create(dist_info_folder)
 
 
@@ -1602,9 +1591,9 @@ def test_url_incorrect_case_no_index(
     )
 
     # only Upper-2.0.tar.gz should get installed.
-    dist_info_folder = script.site_packages / "Upper-1.0.dist-info"
+    dist_info_folder = script.site_packages / "upper-1.0.dist-info"
     result.did_not_create(dist_info_folder)
-    dist_info_folder = script.site_packages / "Upper-2.0.dist-info"
+    dist_info_folder = script.site_packages / "upper-2.0.dist-info"
     result.did_create(dist_info_folder)
 
 
@@ -1624,10 +1613,10 @@ def test_url_incorrect_case_file_index(
         expect_stderr=True,
     )
 
-    # only Upper-2.0.tar.gz should get installed.
-    dist_info_folder = script.site_packages / "Dinner-1.0.dist-info"
+    # only Dinner-2.0.tar.gz should get installed.
+    dist_info_folder = script.site_packages / "dinner-1.0.dist-info"
     result.did_not_create(dist_info_folder)
-    dist_info_folder = script.site_packages / "Dinner-2.0.dist-info"
+    dist_info_folder = script.site_packages / "dinner-2.0.dist-info"
     result.did_create(dist_info_folder)
 
     # Should show index-url location in output
@@ -1798,7 +1787,7 @@ def test_install_builds_wheels(script: PipTestEnvironment, data: TestData) -> No
     # into the cache
     assert wheels != [], str(res)
     assert wheels == [
-        f"Upper-2.0-py{sys.version_info[0]}-none-any.whl",
+        f"upper-2.0-py{sys.version_info[0]}-none-any.whl",
     ]
 
 
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_reqs.py b/tests/functional/test_install_reqs.py
index 45103dc7c..7a553ae91 100644
--- a/tests/functional/test_install_reqs.py
+++ b/tests/functional/test_install_reqs.py
@@ -86,7 +86,7 @@ def test_requirements_file(script: PipTestEnvironment) -> None:
         )
     )
     result = script.pip("install", "-r", script.scratch_path / "initools-req.txt")
-    result.did_create(script.site_packages / "INITools-0.2.dist-info")
+    result.did_create(script.site_packages / "initools-0.2.dist-info")
     result.did_create(script.site_packages / "initools")
     assert result.files_created[script.site_packages / other_lib_name].dir
     fn = f"{other_lib_name}-{other_lib_version}.dist-info"
@@ -130,7 +130,7 @@ def test_dependency_group(
             path = path(pyproject)
         arg = f"{path}:{groupname}"
     result = script.pip("install", "--group", arg)
-    result.did_create(script.site_packages / "INITools-0.2.dist-info")
+    result.did_create(script.site_packages / "initools-0.2.dist-info")
     result.did_create(script.site_packages / "initools")
     assert result.files_created[script.site_packages / "peppercorn"].dir
     assert result.files_created[script.site_packages / "peppercorn-0.6.dist-info"].dir
@@ -153,7 +153,7 @@ def test_multiple_dependency_groups(script: PipTestEnvironment) -> None:
         )
     )
     result = script.pip("install", "--group", "initools", "--group", "peppercorn")
-    result.did_create(script.site_packages / "INITools-0.2.dist-info")
+    result.did_create(script.site_packages / "initools-0.2.dist-info")
     result.did_create(script.site_packages / "initools")
     assert result.files_created[script.site_packages / "peppercorn"].dir
     assert result.files_created[script.site_packages / "peppercorn-0.6.dist-info"].dir
@@ -176,7 +176,7 @@ def test_dependency_group_with_non_normalized_name(script: PipTestEnvironment) -
         )
     )
     result = script.pip("install", "--group", "IniTools")
-    result.did_create(script.site_packages / "INITools-0.2.dist-info")
+    result.did_create(script.site_packages / "initools-0.2.dist-info")
     result.did_create(script.site_packages / "initools")
 
 
@@ -215,7 +215,7 @@ def test_relative_requirements_file(
     URLs, use an egg= definition.
 
     """
-    dist_info_folder = script.site_packages / "FSPkg-0.1.dev0.dist-info"
+    dist_info_folder = script.site_packages / "fspkg-0.1.dev0.dist-info"
     egg_link_file = script.site_packages / "FSPkg.egg-link"
     package_folder = script.site_packages / "fspkg"
 
diff --git a/tests/functional/test_install_upgrade.py b/tests/functional/test_install_upgrade.py
index 00036c1ef..43b43d64f 100644
--- a/tests/functional/test_install_upgrade.py
+++ b/tests/functional/test_install_upgrade.py
@@ -144,8 +144,8 @@ def test_upgrade_to_specific_version(script: PipTestEnvironment) -> None:
     script.pip("install", "INITools==0.1")
     result = script.pip("install", "INITools==0.2")
     assert result.files_created, "pip install with specific version did not upgrade"
-    assert script.site_packages / "INITools-0.1.dist-info" in result.files_deleted
-    result.did_create(script.site_packages / "INITools-0.2.dist-info")
+    assert script.site_packages / "initools-0.1.dist-info" in result.files_deleted
+    result.did_create(script.site_packages / "initools-0.2.dist-info")
 
 
 @pytest.mark.network
@@ -157,7 +157,7 @@ def test_upgrade_if_requested(script: PipTestEnvironment) -> None:
     script.pip("install", "INITools==0.1")
     result = script.pip("install", "--upgrade", "INITools")
     assert result.files_created, "pip install --upgrade did not upgrade"
-    result.did_not_create(script.site_packages / "INITools-0.1.dist-info")
+    result.did_not_create(script.site_packages / "initools-0.1.dist-info")
 
 
 def test_upgrade_with_newest_already_installed(
@@ -319,8 +319,8 @@ def test_should_not_install_always_from_cache(script: PipTestEnvironment) -> Non
     script.pip("install", "INITools==0.2")
     script.pip("uninstall", "-y", "INITools")
     result = script.pip("install", "INITools==0.1")
-    result.did_not_create(script.site_packages / "INITools-0.2.dist-info")
-    result.did_create(script.site_packages / "INITools-0.1.dist-info")
+    result.did_not_create(script.site_packages / "initools-0.2.dist-info")
+    result.did_create(script.site_packages / "initools-0.1.dist-info")
 
 
 @pytest.mark.network
@@ -332,8 +332,8 @@ def test_install_with_ignoreinstalled_requested(script: PipTestEnvironment) -> N
     result = script.pip("install", "-I", "INITools==0.3")
     assert result.files_created, "pip install -I did not install"
     # both the old and new metadata should be present.
-    assert os.path.exists(script.site_packages_path / "INITools-0.1.dist-info")
-    assert os.path.exists(script.site_packages_path / "INITools-0.3.dist-info")
+    assert os.path.exists(script.site_packages_path / "initools-0.1.dist-info")
+    assert os.path.exists(script.site_packages_path / "initools-0.3.dist-info")
 
 
 @pytest.mark.network
diff --git a/tests/functional/test_install_user.py b/tests/functional/test_install_user.py
index 604072d76..0b1e025cb 100644
--- a/tests/functional/test_install_user.py
+++ b/tests/functional/test_install_user.py
@@ -53,7 +53,7 @@ class Tests_UserSite:
             "('initools').project_name)",
         )
         project_name = result.stdout.strip()
-        assert "INITools" == project_name, project_name
+        assert "initools" == project_name, project_name
 
     @pytest.mark.xfail
     @pytest.mark.network
@@ -95,7 +95,7 @@ class Tests_UserSite:
         fspkg_folder = script.user_site / "fspkg"
         result.did_create(fspkg_folder)
 
-        dist_info_folder = script.user_site / "FSPkg-0.1.dev0.dist-info"
+        dist_info_folder = script.user_site / "fspkg-0.1.dev0.dist-info"
         result.did_create(dist_info_folder)
 
     def test_install_user_venv_nositepkgs_fails(
@@ -133,7 +133,7 @@ class Tests_UserSite:
         result2 = script.pip("install", "--user", "INITools==0.1", "--no-binary=:all:")
 
         # usersite has 0.1
-        dist_info_folder = script.user_site / "INITools-0.1.dist-info"
+        dist_info_folder = script.user_site / "initools-0.1.dist-info"
         initools_v3_file = (
             # file only in 0.3
             script.base_path
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/functional/test_proxy.py b/tests/functional/test_proxy.py
index d4946675b..d2570ab4f 100644
--- a/tests/functional/test_proxy.py
+++ b/tests/functional/test_proxy.py
@@ -25,9 +25,10 @@ class AccessLogPlugin(HttpProxyBasePlugin):
 def test_proxy_overrides_env(
     script: PipTestEnvironment, capfd: pytest.CaptureFixture[str]
 ) -> None:
-    with proxy.Proxy(port=0, num_acceptors=1) as proxy1, proxy.Proxy(
-        plugins=[AccessLogPlugin], port=0, num_acceptors=1
-    ) as proxy2:
+    with (
+        proxy.Proxy(port=0, num_acceptors=1) as proxy1,
+        proxy.Proxy(plugins=[AccessLogPlugin], port=0, num_acceptors=1) as proxy2,
+    ):
         script.environ["http_proxy"] = f"127.0.0.1:{proxy2.flags.port}"
         script.environ["https_proxy"] = f"127.0.0.1:{proxy2.flags.port}"
         result = script.pip(
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/functional/test_uninstall_user.py b/tests/functional/test_uninstall_user.py
index c49120ec2..63ba5e581 100644
--- a/tests/functional/test_uninstall_user.py
+++ b/tests/functional/test_uninstall_user.py
@@ -79,7 +79,7 @@ class Tests_UninstallUserSite:
     @pytest.mark.xfail(
         sys.platform == "darwin"
         and platform.machine() == "arm64"
-        and sys.version_info[:2] in {(3, 8), (3, 9)},
+        and sys.version_info[:2] < (3, 10),
         reason="Unexpected egg-link install path",
     )
     def test_uninstall_editable_from_usersite(
diff --git a/tests/lib/compat.py b/tests/lib/compat.py
index 866ac7a77..88dadab2e 100644
--- a/tests/lib/compat.py
+++ b/tests/lib/compat.py
@@ -2,7 +2,7 @@
 
 import contextlib
 import signal
-from typing import Callable, ContextManager, Iterable, Iterator
+from typing import Callable, ContextManager, Iterator
 
 # Applies on Windows.
 if not hasattr(signal, "pthread_sigmask"):
@@ -14,13 +14,7 @@ else:
     @contextlib.contextmanager
     def blocked_signals() -> Iterator[None]:
         """Block all signals for e.g. starting a worker thread."""
-        # valid_signals() was added in Python 3.8 (and not using it results
-        # in a warning on pthread_sigmask() call)
-        mask: Iterable[int]
-        try:
-            mask = signal.valid_signals()
-        except AttributeError:
-            mask = set(range(1, signal.NSIG))
+        mask = signal.valid_signals()
 
         old_mask = signal.pthread_sigmask(  # type: ignore[attr-defined]
             signal.SIG_SETMASK,  # type: ignore[attr-defined]
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_req_file.py b/tests/unit/test_req_file.py
index 60b14940d..593eeba35 100644
--- a/tests/unit/test_req_file.py
+++ b/tests/unit/test_req_file.py
@@ -1033,8 +1033,9 @@ class TestParseRequirements:
 
         # it's hard to rely on a locale definitely existing for testing
         # so patch things out for simplicity
-        with caplog.at_level(logging.WARNING), mock.patch(
-            "locale.getpreferredencoding", return_value=locale_encoding
+        with (
+            caplog.at_level(logging.WARNING),
+            mock.patch("locale.getpreferredencoding", return_value=locale_encoding),
         ):
             reqs = tuple(parse_reqfile(req_file.resolve(), session=session))
 
@@ -1065,7 +1066,8 @@ class TestParseRequirements:
         req_file = tmpdir / "requirements.txt"
         req_file.write_bytes(data)
 
-        with pytest.raises(UnicodeDecodeError), mock.patch(
-            "locale.getpreferredencoding", return_value=encoding
+        with (
+            pytest.raises(UnicodeDecodeError),
+            mock.patch("locale.getpreferredencoding", return_value=encoding),
         ):
             next(parse_reqfile(req_file.resolve(), session=session))
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",
     [