Repository: pypa/pip
Pull request #12622 (2024-04-14 17:50:20)

Bump pre-commit hooks
There was one new mypy error:

```
src/pip/_internal/metadata/_json.py:82: error: Incompatible types in assignment (expression has type "Message | Any | str | list[Message | str]", target has type "str | list[str]")  [assignment]
            result["description"] = payload
                                    ^~~~~~~
Found 1 error in 1 file (checked 294 source files)
```

Closes https://github.com/pypa/pip/pull/12605.


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index 20e0a5dd9..e28e54082 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -61,13 +61,11 @@ body:
       description: >-
         Provide the output of the steps above, including the commands
         themselves and pip's output/traceback etc.
-        (The output will auto-rendered as code, no need for backticks.)
 
         If you want to present output from multiple commands, please prefix
         the line containing the command with `$ `. Please also ensure that
         the "How to reproduce" section contains matching instructions for
         reproducing this.
-      render: shell
 
   - type: checkboxes
     attributes:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 52650efd5..bb46b772b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ exclude: 'src/pip/_vendor/'
 
 repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
-  rev: v4.5.0
+  rev: v4.6.0
   hooks:
   - id: check-builtin-literals
   - id: check-added-large-files
@@ -22,13 +22,13 @@ repos:
   - id: black
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.3.6
+  rev: v0.4.1
   hooks:
     - id: ruff
       args: [--fix, --exit-non-zero-on-fix]
 
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v1.8.0
+  rev: v1.9.0
   hooks:
   - id: mypy
     exclude: tests/data
diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst
index aa2e3ad8e..2fa5552b1 100644
--- a/docs/html/user_guide.rst
+++ b/docs/html/user_guide.rst
@@ -1140,13 +1140,6 @@ Since this work will not change user-visible behavior described in the
 pip documentation, this change is not covered by the :ref:`Deprecation
 Policy`.
 
-.. attention::
-
-    The legacy resolver is deprecated and unsupported. New features, such
-    as :doc:`reference/installation-report`, will not work with the
-    legacy resolver and this resolver will be removed in a future
-    release.
-
 Context and followup
 --------------------
 
diff --git a/docs/html/ux-research-design/contribute.md b/docs/html/ux-research-design/contribute.md
index 48aab68e6..60b982c85 100644
--- a/docs/html/ux-research-design/contribute.md
+++ b/docs/html/ux-research-design/contribute.md
@@ -20,5 +20,5 @@ You can help the team by testing new features as they are released to the commun
 
 If you believe that you have found a user experience bug in pip, or you have ideas for how pip could be made better for all users, please file an issue on the [pip issue tracker](https://github.com/pypa/pip/issues/new).
 
-You can also help improve pip’s user experience by [working on UX issues](https://github.com/pypa/pip/issues?q=is%3Aissue+label%3AUX+is%3Aopen). Issues that are ideal for new contributors are marked with “[good first issue](https://github.com/pypa/pip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)”. Explore the
+You can also help improve pip’s user experience by [working on UX issues](https://github.com/pypa/pip/issues?q=is%3Aissue+is%3Aopen+label%3A%22K%3A+UX%22). Issues that are ideal for new contributors are marked with “[good first issue](https://github.com/pypa/pip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)”. Explore the
 [UX Guidance](guidance) if you have questions.
diff --git a/docs/html/ux-research-design/guidance.md b/docs/html/ux-research-design/guidance.md
index 035df4c73..188930a9e 100644
--- a/docs/html/ux-research-design/guidance.md
+++ b/docs/html/ux-research-design/guidance.md
@@ -383,7 +383,7 @@ An in-depth write up on how the pip personas were created, and how they can be a
 
 In any UX project, it is important to prototype and test interfaces with real users. This provides the team with a feedback loop, and ensures that the solution shipped to the end user meets their needs.
 
-Prototyping CLIs can be a challenge. See [Creating rapid CLI prototypes with cli-output](https://www.ei8fdb.org/prototyping-command-line-interfaces-with-cli-output/) for recommendations.
+Prototyping CLIs can be a challenge. See [Creating rapid CLI prototypes with cli-output](https://www.ei8fdb.org/thoughts/2020/10/prototyping-command-line-interfaces-with-cli-output/) for recommendations.
 
 #### Copywriting Style Guides
 
diff --git a/docs/html/ux-research-design/research-results/index.md b/docs/html/ux-research-design/research-results/index.md
index 3cbd73570..588aef397 100644
--- a/docs/html/ux-research-design/research-results/index.md
+++ b/docs/html/ux-research-design/research-results/index.md
@@ -192,12 +192,12 @@ improving-pips-documentation
 ## Read More
 
 - [Pip team midyear report (blog, July 2020)](https://pyfound.blogspot.com/2020/07/pip-team-midyear-report.html)
-- [Creating rapid CLI prototypes with cli-output (blog, Oct 2020)](https://www.ei8fdb.org/prototyping-command-line-interfaces-with-cli-output/)
+- [Creating rapid CLI prototypes with cli-output (blog, Oct 2020)](https://www.ei8fdb.org/thoughts/2020/10/prototyping-command-line-interfaces-with-cli-output/)
 - [Changes are coming to pip (video)](https://www.youtube.com/watch?v=B4GQCBBsuNU)
-- [How should pip handle dependency conflicts when updating already installed packages? (blog, July 2020)](https://www.ei8fdb.org/how-should-pip-handle-conflicts-when-updating-already-installed-packages/)
-- [Test pip's alpha resolver and help us document dependency conflicts (blog, May 2020)](https://www.ei8fdb.org/test-pips-alpha-resolver-and-help-us-document-dependency-conflicts/)
-- [How do you deal with conflicting dependencies caused by pip installs? (blog, April 2020)](https://www.ei8fdb.org/how-do-you-deal-with-conflicting-dependencies-caused-by-pip-installs/)
-- [pip UX studies: response data (blog, March 2020)](https://www.ei8fdb.org/pip-ux-studies-response-data/)
+- [How should pip handle dependency conflicts when updating already installed packages? (blog, July 2020)](https://www.ei8fdb.org/thoughts/2020/07/how-should-pip-handle-conflicts-when-updating-already-installed-packages/)
+- [Test pip's alpha resolver and help us document dependency conflicts (blog, May 2020)](https://www.ei8fdb.org/thoughts/2020/05/test-pips-alpha-resolver-and-help-us-document-dependency-conflicts/)
+- [How do you deal with conflicting dependencies caused by pip installs? (blog, April 2020)](https://www.ei8fdb.org/thoughts/2020/04/how-do-you-deal-with-conflicting-dependencies-caused-by-pip-installs/)
+- [pip UX studies: response data (blog, March 2020)](https://www.ei8fdb.org/thoughts/2020/03/pip-ux-studies-response-data/)
 
 Other PyPA UX work:
 
diff --git a/news/11050.bugfix.rst b/news/11050.bugfix.rst
deleted file mode 100644
index 01aa5ea7b..000000000
--- a/news/11050.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix error on checkout for subversion and bazaar with verbose mode on.
diff --git a/news/12630.trivial.rst b/news/12630.trivial.rst
deleted file mode 100644
index 8beb79ec3..000000000
--- a/news/12630.trivial.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Add ``render: shell`` to the bug report template to format output as code
diff --git a/news/5a771372-fb26-11ee-8cc4-f72623cb6607.trivial.rst b/news/5a771372-fb26-11ee-8cc4-f72623cb6607.trivial.rst
deleted file mode 100644
index e69de29bb..000000000
diff --git a/news/a870a527-a6ea-46fd-b4aa-c0b0d9b669b0.trivial.rst b/news/a870a527-a6ea-46fd-b4aa-c0b0d9b669b0.trivial.rst
new file mode 100644
index 000000000..d9cbf94d3
--- /dev/null
+++ b/news/a870a527-a6ea-46fd-b4aa-c0b0d9b669b0.trivial.rst
@@ -0,0 +1,1 @@
+Bump pre-commit hooks.
diff --git a/news/b2631650-69cc-4747-8a50-24e574a0ae57.trivial.rst b/news/b2631650-69cc-4747-8a50-24e574a0ae57.trivial.rst
deleted file mode 100644
index e69de29bb..000000000
diff --git a/news/c678d9e3-4844-4298-a46c-80768b38f652.trivial.rst b/news/c678d9e3-4844-4298-a46c-80768b38f652.trivial.rst
deleted file mode 100644
index 8837b6298..000000000
--- a/news/c678d9e3-4844-4298-a46c-80768b38f652.trivial.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Replace ``captured_output()`` and ``get_url_scheme()`` with stdlib alternatives.
diff --git a/news/f14947e7-deea-4e17-bdc2-dd8dab2a1fa5.trivial.rst b/news/f14947e7-deea-4e17-bdc2-dd8dab2a1fa5.trivial.rst
deleted file mode 100644
index 001cec343..000000000
--- a/news/f14947e7-deea-4e17-bdc2-dd8dab2a1fa5.trivial.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-Convert numerous internal classes to dataclasses for readability and stricter
-enforcement of immutability across the codebase. A conservative approach was
-taken in selecting which classes to convert. Classes which did not convert
-cleanly into a dataclass or were "too complex" (e.g. maintains interconnected
-state) were left alone.
diff --git a/src/pip/_internal/index/collector.py b/src/pip/_internal/index/collector.py
index 5f8fdee3d..a22e3b91f 100644
--- a/src/pip/_internal/index/collector.py
+++ b/src/pip/_internal/index/collector.py
@@ -11,7 +11,6 @@ import logging
 import os
 import urllib.parse
 import urllib.request
-from dataclasses import dataclass
 from html.parser import HTMLParser
 from optparse import Values
 from typing import (
@@ -249,22 +248,29 @@ def parse_links(page: "IndexContent") -> Iterable[Link]:
         yield link
 
 
-@dataclass(frozen=True)
 class IndexContent:
-    """Represents one response (or page), along with its URL.
+    """Represents one response (or page), along with its URL"""
 
-    :param encoding: the encoding to decode the given content.
-    :param url: the URL from which the HTML was downloaded.
-    :param cache_link_parsing: whether links parsed from this page's url
-                               should be cached. PyPI index urls should
-                               have this set to False, for example.
-    """
-
-    content: bytes
-    content_type: str
-    encoding: Optional[str]
-    url: str
-    cache_link_parsing: bool = True
+    def __init__(
+        self,
+        content: bytes,
+        content_type: str,
+        encoding: Optional[str],
+        url: str,
+        cache_link_parsing: bool = True,
+    ) -> None:
+        """
+        :param encoding: the encoding to decode the given content.
+        :param url: the URL from which the HTML was downloaded.
+        :param cache_link_parsing: whether links parsed from this page's url
+                                   should be cached. PyPI index urls should
+                                   have this set to False, for example.
+        """
+        self.content = content
+        self.content_type = content_type
+        self.encoding = encoding
+        self.url = url
+        self.cache_link_parsing = cache_link_parsing
 
     def __str__(self) -> str:
         return redact_auth_from_url(self.url)
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index 98ee63155..b5b9679f2 100644
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -5,7 +5,6 @@ import functools
 import itertools
 import logging
 import re
-from dataclasses import dataclass
 from typing import TYPE_CHECKING, FrozenSet, Iterable, List, Optional, Set, Tuple, Union
 
 from pip._vendor.packaging import specifiers
@@ -323,15 +322,22 @@ def filter_unallowed_hashes(
     return filtered
 
 
-@dataclass
 class CandidatePreferences:
     """
     Encapsulates some of the preferences for filtering and sorting
     InstallationCandidate objects.
     """
 
-    prefer_binary: bool = False
-    allow_all_prereleases: bool = False
+    def __init__(
+        self,
+        prefer_binary: bool = False,
+        allow_all_prereleases: bool = False,
+    ) -> None:
+        """
+        :param allow_all_prereleases: Whether to allow all pre-releases.
+        """
+        self.allow_all_prereleases = allow_all_prereleases
+        self.prefer_binary = prefer_binary
 
 
 class BestCandidateResult:
diff --git a/src/pip/_internal/locations/_sysconfig.py b/src/pip/_internal/locations/_sysconfig.py
index ca860ea56..97aef1f1a 100644
--- a/src/pip/_internal/locations/_sysconfig.py
+++ b/src/pip/_internal/locations/_sysconfig.py
@@ -192,10 +192,9 @@ def get_scheme(
         data=paths["data"],
     )
     if root is not None:
-        converted_keys = {}
         for key in SCHEME_KEYS:
-            converted_keys[key] = change_root(root, getattr(scheme, key))
-        scheme = Scheme(**converted_keys)
+            value = change_root(root, getattr(scheme, key))
+            setattr(scheme, key, value)
     return scheme
 
 
diff --git a/src/pip/_internal/metadata/_json.py b/src/pip/_internal/metadata/_json.py
index 27362fc72..9097dd585 100644
--- a/src/pip/_internal/metadata/_json.py
+++ b/src/pip/_internal/metadata/_json.py
@@ -2,7 +2,7 @@
 
 from email.header import Header, decode_header, make_header
 from email.message import Message
-from typing import Any, Dict, List, Union
+from typing import Any, Dict, List, Union, cast
 
 METADATA_FIELDS = [
     # Name, Multiple-Use
@@ -77,7 +77,7 @@ def msg_to_json(msg: Message) -> Dict[str, Any]:
                     value = value.split()
         result[key] = value
 
-    payload = msg.get_payload()
+    payload = cast(str, msg.get_payload())
     if payload:
         result["description"] = payload
 
diff --git a/src/pip/_internal/models/candidate.py b/src/pip/_internal/models/candidate.py
index f27f28315..b12ffeeac 100644
--- a/src/pip/_internal/models/candidate.py
+++ b/src/pip/_internal/models/candidate.py
@@ -1,25 +1,28 @@
-from dataclasses import dataclass
-
-from pip._vendor.packaging.version import Version
 from pip._vendor.packaging.version import parse as parse_version
 
 from pip._internal.models.link import Link
+from pip._internal.utils.models import KeyBasedCompareMixin
 
 
-@dataclass(frozen=True)
-class InstallationCandidate:
+class InstallationCandidate(KeyBasedCompareMixin):
     """Represents a potential "candidate" for installation."""
 
     __slots__ = ["name", "version", "link"]
 
-    name: str
-    version: Version
-    link: Link
-
     def __init__(self, name: str, version: str, link: Link) -> None:
-        object.__setattr__(self, "name", name)
-        object.__setattr__(self, "version", parse_version(version))
-        object.__setattr__(self, "link", link)
+        self.name = name
+        self.version = parse_version(version)
+        self.link = link
+
+        super().__init__(
+            key=(self.name, self.version, self.link),
+            defining_class=InstallationCandidate,
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"<InstallationCandidate({self.name!r}, {self.version!r}, {self.link!r})>"
+        )
 
     def __str__(self) -> str:
         return f"{self.name!r} candidate (version {self.version} at {self.link})"
diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py
index fc5ec8d4a..106395248 100644
--- a/src/pip/_internal/models/direct_url.py
+++ b/src/pip/_internal/models/direct_url.py
@@ -3,8 +3,7 @@
 import json
 import re
 import urllib.parse
-from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, Iterable, Optional, Type, TypeVar, Union
+from typing import Any, Dict, Iterable, Optional, Type, TypeVar, Union
 
 __all__ = [
     "DirectUrl",
@@ -66,13 +65,18 @@ def _filter_none(**kwargs: Any) -> Dict[str, Any]:
     return {k: v for k, v in kwargs.items() if v is not None}
 
 
-@dataclass
 class VcsInfo:
-    name: ClassVar = "vcs_info"
+    name = "vcs_info"
 
-    vcs: str
-    commit_id: str
-    requested_revision: Optional[str] = None
+    def __init__(
+        self,
+        vcs: str,
+        commit_id: str,
+        requested_revision: Optional[str] = None,
+    ) -> None:
+        self.vcs = vcs
+        self.requested_revision = requested_revision
+        self.commit_id = commit_id
 
     @classmethod
     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["VcsInfo"]:
@@ -136,11 +140,14 @@ class ArchiveInfo:
         return _filter_none(hash=self.hash, hashes=self.hashes)
 
 
-@dataclass
 class DirInfo:
-    name: ClassVar = "dir_info"
+    name = "dir_info"
 
-    editable: bool = False
+    def __init__(
+        self,
+        editable: bool = False,
+    ) -> None:
+        self.editable = editable
 
     @classmethod
     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["DirInfo"]:
@@ -155,11 +162,16 @@ class DirInfo:
 InfoType = Union[ArchiveInfo, DirInfo, VcsInfo]
 
 
-@dataclass
 class DirectUrl:
-    url: str
-    info: InfoType
-    subdirectory: Optional[str] = None
+    def __init__(
+        self,
+        url: str,
+        info: InfoType,
+        subdirectory: Optional[str] = None,
+    ) -> None:
+        self.url = url
+        self.info = info
+        self.subdirectory = subdirectory
 
     def _remove_auth_from_netloc(self, netloc: str) -> str:
         if "@" not in netloc:
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
index 2f41f2f6a..73041b864 100644
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -27,6 +27,7 @@ from pip._internal.utils.misc import (
     split_auth_from_netloc,
     splitext,
 )
+from pip._internal.utils.models import KeyBasedCompareMixin
 from pip._internal.utils.urls import path_to_url, url_to_path
 
 if TYPE_CHECKING:
@@ -178,8 +179,7 @@ def _ensure_quoted_url(url: str) -> str:
     return urllib.parse.urlunparse(result._replace(path=path))
 
 
-@functools.total_ordering
-class Link:
+class Link(KeyBasedCompareMixin):
     """Represents a parsed link from a Package Index's simple URL"""
 
     __slots__ = [
@@ -254,6 +254,8 @@ class Link:
         self.yanked_reason = yanked_reason
         self.metadata_file_data = metadata_file_data
 
+        super().__init__(key=url, defining_class=Link)
+
         self.cache_link_parsing = cache_link_parsing
         self.egg_fragment = self._egg_fragment()
 
@@ -373,19 +375,6 @@ class Link:
     def __repr__(self) -> str:
         return f"<Link {self}>"
 
-    def __hash__(self) -> int:
-        return hash(self.url)
-
-    def __eq__(self, other: Any) -> bool:
-        if not isinstance(other, Link):
-            return NotImplemented
-        return self.url == other.url
-
-    def __lt__(self, other: Any) -> bool:
-        if not isinstance(other, Link):
-            return NotImplemented
-        return self.url < other.url
-
     @property
     def url(self) -> str:
         return self._url
diff --git a/src/pip/_internal/models/scheme.py b/src/pip/_internal/models/scheme.py
index 06a9a550e..9d804a673 100644
--- a/src/pip/_internal/models/scheme.py
+++ b/src/pip/_internal/models/scheme.py
@@ -5,12 +5,9 @@ For a general overview of available schemes and their context, see
 https://docs.python.org/3/install/index.html#alternate-installation.
 """
 
-from dataclasses import dataclass
-
 SCHEME_KEYS = ["platlib", "purelib", "headers", "scripts", "data"]
 
 
-@dataclass(frozen=True)
 class Scheme:
     """A Scheme holds paths which are used as the base directories for
     artifacts associated with a Python package.
@@ -18,8 +15,16 @@ class Scheme:
 
     __slots__ = SCHEME_KEYS
 
-    platlib: str
-    purelib: str
-    headers: str
-    scripts: str
-    data: str
+    def __init__(
+        self,
+        platlib: str,
+        purelib: str,
+        headers: str,
+        scripts: str,
+        data: str,
+    ) -> None:
+        self.platlib = platlib
+        self.purelib = purelib
+        self.headers = headers
+        self.scripts = scripts
+        self.data = data
diff --git a/src/pip/_internal/models/search_scope.py b/src/pip/_internal/models/search_scope.py
index ee7bc8622..a96ddcb40 100644
--- a/src/pip/_internal/models/search_scope.py
+++ b/src/pip/_internal/models/search_scope.py
@@ -3,7 +3,6 @@ import logging
 import os
 import posixpath
 import urllib.parse
-from dataclasses import dataclass
 from typing import List
 
 from pip._vendor.packaging.utils import canonicalize_name
@@ -15,7 +14,6 @@ from pip._internal.utils.misc import normalize_path, redact_auth_from_url
 logger = logging.getLogger(__name__)
 
 
-@dataclass(frozen=True)
 class SearchScope:
     """
     Encapsulates the locations that pip is configured to search.
@@ -23,10 +21,6 @@ class SearchScope:
 
     __slots__ = ["find_links", "index_urls", "no_index"]
 
-    find_links: List[str]
-    index_urls: List[str]
-    no_index: bool
-
     @classmethod
     def create(
         cls,
@@ -69,6 +63,16 @@ class SearchScope:
             no_index=no_index,
         )
 
+    def __init__(
+        self,
+        find_links: List[str],
+        index_urls: List[str],
+        no_index: bool,
+    ) -> None:
+        self.find_links = find_links
+        self.index_urls = index_urls
+        self.no_index = no_index
+
     def get_formatted_locations(self) -> str:
         lines = []
         redacted_index_urls = []
diff --git a/src/pip/_internal/models/selection_prefs.py b/src/pip/_internal/models/selection_prefs.py
index e9b50aa51..977bc4caa 100644
--- a/src/pip/_internal/models/selection_prefs.py
+++ b/src/pip/_internal/models/selection_prefs.py
@@ -3,8 +3,6 @@ from typing import Optional
 from pip._internal.models.format_control import FormatControl
 
 
-# TODO: This needs Python 3.10's improved slots support for dataclasses
-# to be converted into a dataclass.
 class SelectionPreferences:
     """
     Encapsulates the candidate selection preferences for downloading
diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py
index a02a193d2..f64c3e9c4 100644
--- a/src/pip/_internal/operations/install/wheel.py
+++ b/src/pip/_internal/operations/install/wheel.py
@@ -51,7 +51,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 captured_stdout, ensure_dir, hash_file, partition
 from pip._internal.utils.unpacking import (
     current_umask,
     is_within_directory,
@@ -603,9 +603,7 @@ def _install_wheel(
 
     # Compile all of the pyc files for the installed files
     if pycompile:
-        with contextlib.redirect_stdout(
-            StreamWrapper.from_stream(sys.stdout)
-        ) as stdout:
+        with captured_stdout() as stdout:
             with warnings.catch_warnings():
                 warnings.filterwarnings("ignore")
                 for path in pyc_source_file_paths():
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
index e6aa34472..956717d1e 100644
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -7,7 +7,6 @@
 import mimetypes
 import os
 import shutil
-from dataclasses import dataclass
 from pathlib import Path
 from typing import Dict, Iterable, List, Optional
 
@@ -81,14 +80,13 @@ def unpack_vcs_link(link: Link, location: str, verbosity: int) -> None:
     vcs_backend.unpack(location, url=hide_url(link.url), verbosity=verbosity)
 
 
-@dataclass
 class File:
-    path: str
-    content_type: Optional[str] = None
-
-    def __post_init__(self) -> None:
-        if self.content_type is None:
-            self.content_type = mimetypes.guess_type(self.path)[0]
+    def __init__(self, path: str, content_type: Optional[str]) -> None:
+        self.path = path
+        if content_type is None:
+            self.content_type = mimetypes.guess_type(path)[0]
+        else:
+            self.content_type = content_type
 
 
 def get_http_url(
diff --git a/src/pip/_internal/req/__init__.py b/src/pip/_internal/req/__init__.py
index 422d851d7..16de903a4 100644
--- a/src/pip/_internal/req/__init__.py
+++ b/src/pip/_internal/req/__init__.py
@@ -1,6 +1,5 @@
 import collections
 import logging
-from dataclasses import dataclass
 from typing import Generator, List, Optional, Sequence, Tuple
 
 from pip._internal.utils.logging import indent_log
@@ -19,9 +18,12 @@ __all__ = [
 logger = logging.getLogger(__name__)
 
 
-@dataclass(frozen=True)
 class InstallationResult:
-    name: str
+    def __init__(self, name: str) -> None:
+        self.name = name
+
+    def __repr__(self) -> str:
+        return f"InstallationResult(name={self.name!r})"
 
 
 def _validate_requirements(
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
index 36f517e59..742963456 100644
--- a/src/pip/_internal/req/constructors.py
+++ b/src/pip/_internal/req/constructors.py
@@ -12,7 +12,6 @@ import copy
 import logging
 import os
 import re
-from dataclasses import dataclass
 from typing import Collection, Dict, List, Optional, Set, Tuple, Union
 
 from pip._vendor.packaging.markers import Marker
@@ -192,12 +191,18 @@ def deduce_helpful_msg(req: str) -> str:
     return msg
 
 
-@dataclass(frozen=True)
 class RequirementParts:
-    requirement: Optional[Requirement]
-    link: Optional[Link]
-    markers: Optional[Marker]
-    extras: Set[str]
+    def __init__(
+        self,
+        requirement: Optional[Requirement],
+        link: Optional[Link],
+        markers: Optional[Marker],
+        extras: Set[str],
+    ):
+        self.requirement = requirement
+        self.link = link
+        self.markers = markers
+        self.extras = extras
 
 
 def parse_req_from_editable(editable_req: str) -> RequirementParts:
diff --git a/src/pip/_internal/req/req_file.py b/src/pip/_internal/req/req_file.py
index 53ad8674c..6f474bce4 100644
--- a/src/pip/_internal/req/req_file.py
+++ b/src/pip/_internal/req/req_file.py
@@ -26,6 +26,7 @@ from pip._internal.cli import cmdoptions
 from pip._internal.exceptions import InstallationError, RequirementsFileParseError
 from pip._internal.models.search_scope import SearchScope
 from pip._internal.utils.encoding import auto_decode
+from pip._internal.utils.urls import get_url_scheme
 
 if TYPE_CHECKING:
     from pip._internal.index.package_finder import PackageFinder
@@ -532,7 +533,8 @@ def get_file_content(url: str, session: "PipSession") -> Tuple[str, str]:
     :param url:         File path or url.
     :param session:     PipSession instance.
     """
-    scheme = urllib.parse.urlsplit(url).scheme
+    scheme = get_url_scheme(url)
+
     # Pip has special support for file:// URLs (LocalFSAdapter).
     if scheme in ["http", "https", "file"]:
         # Delay importing heavy network modules until absolutely necessary.
diff --git a/src/pip/_internal/resolution/resolvelib/base.py b/src/pip/_internal/resolution/resolvelib/base.py
index 4269c7fbe..9c0ef5ca7 100644
--- a/src/pip/_internal/resolution/resolvelib/base.py
+++ b/src/pip/_internal/resolution/resolvelib/base.py
@@ -1,4 +1,3 @@
-from dataclasses import dataclass
 from typing import FrozenSet, Iterable, Optional, Tuple, Union
 
 from pip._vendor.packaging.specifiers import SpecifierSet
@@ -20,11 +19,13 @@ def format_name(project: NormalizedName, extras: FrozenSet[NormalizedName]) -> s
     return f"{project}[{extras_expr}]"
 
 
-@dataclass(frozen=True)
 class Constraint:
-    specifier: SpecifierSet
-    hashes: Hashes
-    links: FrozenSet[Link]
+    def __init__(
+        self, specifier: SpecifierSet, hashes: Hashes, links: FrozenSet[Link]
+    ) -> None:
+        self.specifier = specifier
+        self.hashes = hashes
+        self.links = links
 
     @classmethod
     def empty(cls) -> "Constraint":
diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py
index 82b626132..1ad3f6162 100644
--- a/src/pip/_internal/utils/misc.py
+++ b/src/pip/_internal/utils/misc.py
@@ -1,3 +1,4 @@
+import contextlib
 import errno
 import getpass
 import hashlib
@@ -10,7 +11,6 @@ import stat
 import sys
 import sysconfig
 import urllib.parse
-from dataclasses import dataclass
 from functools import partial
 from io import StringIO
 from itertools import filterfalse, tee, zip_longest
@@ -20,6 +20,7 @@ from typing import (
     Any,
     BinaryIO,
     Callable,
+    ContextManager,
     Dict,
     Generator,
     Iterable,
@@ -55,6 +56,7 @@ __all__ = [
     "normalize_path",
     "renames",
     "get_prog",
+    "captured_stdout",
     "ensure_dir",
     "remove_auth_from_url",
     "check_externally_managed",
@@ -397,6 +399,40 @@ class StreamWrapper(StringIO):
         return self.orig_stream.encoding
 
 
+@contextlib.contextmanager
+def captured_output(stream_name: str) -> Generator[StreamWrapper, None, None]:
+    """Return a context manager used by captured_stdout/stdin/stderr
+    that temporarily replaces the sys stream *stream_name* with a StringIO.
+
+    Taken from Lib/support/__init__.py in the CPython repo.
+    """
+    orig_stdout = getattr(sys, stream_name)
+    setattr(sys, stream_name, StreamWrapper.from_stream(orig_stdout))
+    try:
+        yield getattr(sys, stream_name)
+    finally:
+        setattr(sys, stream_name, orig_stdout)
+
+
+def captured_stdout() -> ContextManager[StreamWrapper]:
+    """Capture the output of sys.stdout:
+
+       with captured_stdout() as stdout:
+           print('hello')
+       self.assertEqual(stdout.getvalue(), 'hello\n')
+
+    Taken from Lib/support/__init__.py in the CPython repo.
+    """
+    return captured_output("stdout")
+
+
+def captured_stderr() -> ContextManager[StreamWrapper]:
+    """
+    See captured_stdout().
+    """
+    return captured_output("stderr")
+
+
 # Simulates an enum
 def enum(*sequential: Any, **named: Any) -> Type[Any]:
     enums = dict(zip(sequential, range(len(sequential))), **named)
@@ -544,10 +580,10 @@ def redact_auth_from_requirement(req: Requirement) -> str:
     return str(req).replace(req.url, redact_auth_from_url(req.url))
 
 
-@dataclass(frozen=True)
 class HiddenText:
-    secret: str
-    redacted: str
+    def __init__(self, secret: str, redacted: str) -> None:
+        self.secret = secret
+        self.redacted = redacted
 
     def __repr__(self) -> str:
         return f"<HiddenText {str(self)!r}>"
diff --git a/src/pip/_internal/utils/models.py b/src/pip/_internal/utils/models.py
new file mode 100644
index 000000000..b6bb21a8b
--- /dev/null
+++ b/src/pip/_internal/utils/models.py
@@ -0,0 +1,39 @@
+"""Utilities for defining models
+"""
+
+import operator
+from typing import Any, Callable, Type
+
+
+class KeyBasedCompareMixin:
+    """Provides comparison capabilities that is based on a key"""
+
+    __slots__ = ["_compare_key", "_defining_class"]
+
+    def __init__(self, key: Any, defining_class: Type["KeyBasedCompareMixin"]) -> None:
+        self._compare_key = key
+        self._defining_class = defining_class
+
+    def __hash__(self) -> int:
+        return hash(self._compare_key)
+
+    def __lt__(self, other: Any) -> bool:
+        return self._compare(other, operator.__lt__)
+
+    def __le__(self, other: Any) -> bool:
+        return self._compare(other, operator.__le__)
+
+    def __gt__(self, other: Any) -> bool:
+        return self._compare(other, operator.__gt__)
+
+    def __ge__(self, other: Any) -> bool:
+        return self._compare(other, operator.__ge__)
+
+    def __eq__(self, other: Any) -> bool:
+        return self._compare(other, operator.__eq__)
+
+    def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> bool:
+        if not isinstance(other, self._defining_class):
+            return NotImplemented
+
+        return method(self._compare_key, other._compare_key)
diff --git a/src/pip/_internal/utils/urls.py b/src/pip/_internal/utils/urls.py
index 9f34f882a..6ba2e04f3 100644
--- a/src/pip/_internal/utils/urls.py
+++ b/src/pip/_internal/utils/urls.py
@@ -2,10 +2,17 @@ import os
 import string
 import urllib.parse
 import urllib.request
+from typing import Optional
 
 from .compat import WINDOWS
 
 
+def get_url_scheme(url: str) -> Optional[str]:
+    if ":" not in url:
+        return None
+    return url.split(":", 1)[0].lower()
+
+
 def path_to_url(path: str) -> str:
     """
     Convert a path to a file: URL.  The path will be made absolute and have
diff --git a/src/pip/_internal/vcs/bazaar.py b/src/pip/_internal/vcs/bazaar.py
index c754b7cc5..20a17ed09 100644
--- a/src/pip/_internal/vcs/bazaar.py
+++ b/src/pip/_internal/vcs/bazaar.py
@@ -44,13 +44,13 @@ class Bazaar(VersionControl):
             display_path(dest),
         )
         if verbosity <= 0:
-            flags = ["--quiet"]
+            flag = "--quiet"
         elif verbosity == 1:
-            flags = []
+            flag = ""
         else:
-            flags = [f"-{'v'*verbosity}"]
+            flag = f"-{'v'*verbosity}"
         cmd_args = make_command(
-            "checkout", "--lightweight", *flags, rev_options.to_args(), url, dest
+            "checkout", "--lightweight", flag, rev_options.to_args(), url, dest
         )
         self.run_command(cmd_args)
 
diff --git a/src/pip/_internal/vcs/git.py b/src/pip/_internal/vcs/git.py
index 0425debb3..8c242cf89 100644
--- a/src/pip/_internal/vcs/git.py
+++ b/src/pip/_internal/vcs/git.py
@@ -4,7 +4,6 @@ import pathlib
 import re
 import urllib.parse
 import urllib.request
-from dataclasses import replace
 from typing import List, Optional, Tuple
 
 from pip._internal.exceptions import BadCommand, InstallationError
@@ -218,7 +217,7 @@ class Git(VersionControl):
 
         if sha is not None:
             rev_options = rev_options.make_new(sha)
-            rev_options = replace(rev_options, branch_name=(rev if is_branch else None))
+            rev_options.branch_name = rev if is_branch else None
 
             return rev_options
 
diff --git a/src/pip/_internal/vcs/subversion.py b/src/pip/_internal/vcs/subversion.py
index f359266d9..16d93a67b 100644
--- a/src/pip/_internal/vcs/subversion.py
+++ b/src/pip/_internal/vcs/subversion.py
@@ -288,12 +288,12 @@ class Subversion(VersionControl):
             display_path(dest),
         )
         if verbosity <= 0:
-            flags = ["--quiet"]
+            flag = "--quiet"
         else:
-            flags = []
+            flag = ""
         cmd_args = make_command(
             "checkout",
-            *flags,
+            flag,
             self.get_remote_call_options(),
             rev_options.to_args(),
             url,
diff --git a/src/pip/_internal/vcs/versioncontrol.py b/src/pip/_internal/vcs/versioncontrol.py
index bd7d509a3..7eef8ec89 100644
--- a/src/pip/_internal/vcs/versioncontrol.py
+++ b/src/pip/_internal/vcs/versioncontrol.py
@@ -5,7 +5,6 @@ import os
 import shutil
 import sys
 import urllib.parse
-from dataclasses import dataclass, field
 from typing import (
     Any,
     Dict,
@@ -38,6 +37,7 @@ from pip._internal.utils.subprocess import (
     format_command_args,
     make_command,
 )
+from pip._internal.utils.urls import get_url_scheme
 
 __all__ = ["vcs"]
 
@@ -51,8 +51,8 @@ def is_url(name: str) -> bool:
     """
     Return true if the name looks like a URL.
     """
-    scheme = urllib.parse.urlsplit(name).scheme
-    if not scheme:
+    scheme = get_url_scheme(name)
+    if scheme is None:
         return False
     return scheme in ["http", "https", "file", "ftp"] + vcs.all_schemes
 
@@ -114,22 +114,33 @@ class RemoteNotValidError(Exception):
         self.url = url
 
 
-@dataclass(frozen=True)
 class RevOptions:
     """
     Encapsulates a VCS-specific revision to install, along with any VCS
     install options.
 
-    Args:
-        vc_class: a VersionControl subclass.
-        rev: the name of the revision to install.
-        extra_args: a list of extra options.
+    Instances of this class should be treated as if immutable.
     """
 
-    vc_class: Type["VersionControl"]
-    rev: Optional[str] = None
-    extra_args: CommandArgs = field(default_factory=list)
-    branch_name: Optional[str] = None
+    def __init__(
+        self,
+        vc_class: Type["VersionControl"],
+        rev: Optional[str] = None,
+        extra_args: Optional[CommandArgs] = None,
+    ) -> None:
+        """
+        Args:
+          vc_class: a VersionControl subclass.
+          rev: the name of the revision to install.
+          extra_args: a list of extra options.
+        """
+        if extra_args is None:
+            extra_args = []
+
+        self.extra_args = extra_args
+        self.rev = rev
+        self.vc_class = vc_class
+        self.branch_name: Optional[str] = None
 
     def __repr__(self) -> str:
         return f"<RevOptions {self.vc_class.name}: rev={self.rev!r}>"
@@ -343,7 +354,7 @@ class VersionControl:
           rev: the name of a revision to install.
           extra_args: a list of extra options.
         """
-        return RevOptions(cls, rev, extra_args=extra_args or [])
+        return RevOptions(cls, rev, extra_args=extra_args)
 
     @classmethod
     def _is_local_repository(cls, repo: str) -> bool:


Test patch:
diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py
index f673ed29d..9d507d742 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -1,7 +1,5 @@
 import logging
 import time
-from contextlib import redirect_stderr, redirect_stdout
-from io import StringIO
 from threading import Thread
 from unittest.mock import patch
 
@@ -13,6 +11,7 @@ from pip._internal.utils.logging import (
     RichPipStreamHandler,
     indent_log,
 )
+from pip._internal.utils.misc import captured_stderr, captured_stdout
 
 logger = logging.getLogger(__name__)
 
@@ -141,7 +140,7 @@ class TestColorizedStreamHandler:
         """
         record = self._make_log_record()
 
-        with redirect_stderr(StringIO()) as stderr:
+        with captured_stderr() as stderr:
             handler = RichPipStreamHandler(stream=stderr, no_color=True)
             with patch("sys.stderr.flush") as mock_flush:
                 mock_flush.side_effect = BrokenPipeError()
@@ -164,7 +163,7 @@ class TestColorizedStreamHandler:
         """
         record = self._make_log_record()
 
-        with redirect_stdout(StringIO()) as stdout:
+        with captured_stdout() as stdout:
             handler = RichPipStreamHandler(stream=stdout, no_color=True)
             with patch("sys.stdout.write") as mock_write:
                 mock_write.side_effect = BrokenPipeError()
@@ -179,7 +178,7 @@ class TestColorizedStreamHandler:
         """
         record = self._make_log_record()
 
-        with redirect_stdout(StringIO()) as stdout:
+        with captured_stdout() as stdout:
             handler = RichPipStreamHandler(stream=stdout, no_color=True)
             with patch("sys.stdout.flush") as mock_flush:
                 mock_flush.side_effect = BrokenPipeError()
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
index 2550cae41..c5545e37d 100644
--- a/tests/unit/test_models.py
+++ b/tests/unit/test_models.py
@@ -49,3 +49,11 @@ class TestInstallationCandidate:
         assert obj.name == "A"
         assert obj.version == parse_version("1.0.0")
         assert obj.link.url == "https://somewhere.com/path/A-1.0.0.tar.gz"
+
+    # NOTE: This isn't checking the ordering logic; only the data provided to
+    #       it is correct.
+    def test_sets_the_right_key(self) -> None:
+        obj = candidate.InstallationCandidate(
+            "A", "1.0.0", Link("https://somewhere.com/path/A-1.0.0.tar.gz")
+        )
+        assert obj._compare_key == (obj.name, obj.version, obj.link)
diff --git a/tests/unit/test_urls.py b/tests/unit/test_urls.py
index 746d02224..56ee80aa8 100644
--- a/tests/unit/test_urls.py
+++ b/tests/unit/test_urls.py
@@ -1,10 +1,24 @@
 import os
 import sys
 import urllib.request
+from typing import Optional
 
 import pytest
 
-from pip._internal.utils.urls import path_to_url, url_to_path
+from pip._internal.utils.urls import get_url_scheme, path_to_url, url_to_path
+
+
+@pytest.mark.parametrize(
+    "url,expected",
+    [
+        ("http://localhost:8080/", "http"),
+        ("file:c:/path/to/file", "file"),
+        ("file:/dev/null", "file"),
+        ("", None),
+    ],
+)
+def test_get_url_scheme(url: str, expected: Optional[str]) -> None:
+    assert get_url_scheme(url) == expected
 
 
 @pytest.mark.skipif("sys.platform == 'win32'")
diff --git a/tests/unit/test_vcs.py b/tests/unit/test_vcs.py
index af5f348dc..a52a6217e 100644
--- a/tests/unit/test_vcs.py
+++ b/tests/unit/test_vcs.py
@@ -777,22 +777,6 @@ class TestSubversionArgs(TestCase):
         assert self.call_subprocess_mock.call_args[0][0] == args
 
     def test_obtain(self) -> None:
-        self.svn.obtain(self.dest, hide_url(self.url), verbosity=1)
-        self.assert_call_args(
-            [
-                "svn",
-                "checkout",
-                "--non-interactive",
-                "--username",
-                "username",
-                "--password",
-                hide_value("password"),
-                hide_url("http://svn.example.com/"),
-                "/tmp/test",
-            ]
-        )
-
-    def test_obtain_quiet(self) -> None:
         self.svn.obtain(self.dest, hide_url(self.url), verbosity=0)
         self.assert_call_args(
             [
@@ -810,18 +794,6 @@ class TestSubversionArgs(TestCase):
         )
 
     def test_fetch_new(self) -> None:
-        self.svn.fetch_new(self.dest, hide_url(self.url), self.rev_options, verbosity=1)
-        self.assert_call_args(
-            [
-                "svn",
-                "checkout",
-                "--non-interactive",
-                hide_url("svn+http://username:password@svn.example.com/"),
-                "/tmp/test",
-            ]
-        )
-
-    def test_fetch_new_quiet(self) -> None:
         self.svn.fetch_new(self.dest, hide_url(self.url), self.rev_options, verbosity=0)
         self.assert_call_args(
             [
@@ -835,21 +807,6 @@ class TestSubversionArgs(TestCase):
         )
 
     def test_fetch_new_revision(self) -> None:
-        rev_options = RevOptions(Subversion, "123")
-        self.svn.fetch_new(self.dest, hide_url(self.url), rev_options, verbosity=1)
-        self.assert_call_args(
-            [
-                "svn",
-                "checkout",
-                "--non-interactive",
-                "-r",
-                "123",
-                hide_url("svn+http://username:password@svn.example.com/"),
-                "/tmp/test",
-            ]
-        )
-
-    def test_fetch_new_revision_quiet(self) -> None:
         rev_options = RevOptions(Subversion, "123")
         self.svn.fetch_new(self.dest, hide_url(self.url), rev_options, verbosity=0)
         self.assert_call_args(