Repository: pypa/pip
Pull request #11098 (2022-05-08 13:28:18)

Replace `cgi` module with `email.message`
As of Python 3.11.0b1, the cgi module is deprecated per PEP 594
(Removing dead batteries from the standard library), and raises
DeprecationWarning when imported. The only places pip relies on this
are pip._internal.index.collector._get_encoding_from_headers() and
pip._internal.network.download.parse_content_disposition() calling
parse_headers(), so replace that with the solution recommended in an
old PEP 594 thread from 2019: https://discuss.python.org/t/1704/14

Fixes: #11099


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index c691a0058..2a6921300 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -61,13 +61,12 @@ body:
       description: >-
         Provide the output of the steps above, including the commands
         themselves and pip's output/traceback etc. If you're familiar with
-        Markdown, this block will have triple backticks added automatically
-        around it -- you don't have to add them.
+        Markdown, DO NOT add backticks. They're added automatically.
 
-        If you want to present output from multiple commands, please present
-        that as a shell session (commands you run get prefixed with `$ `).
-        Please also ensure that the "How to reproduce" section contains matching
-        instructions for reproducing this.
+        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: sh-session
 
   - type: checkboxes
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0b8ba644..e467b3e50 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -219,11 +219,12 @@ jobs:
         env:
           TEMP: "R:\\Temp"
 
+  # TODO: Remove this when we add Python 3.11 to CI.
   tests-importlib-metadata:
     name: tests for importlib.metadata backend
     runs-on: ubuntu-latest
     env:
-      _PIP_METADATA_BACKEND_IMPORTLIB: egg-compat
+      _PIP_USE_IMPORTLIB_METADATA: 'true'
 
     needs: [pre-commit, packaging, determine-changes]
     if: >-
@@ -241,7 +242,6 @@ jobs:
 
       - run: pip install nox 'virtualenv<20'
 
-      # Main check
       - name: Run unit tests
         run: >-
           nox -s test-3.10 --
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 5b33615ab..ac4dd1337 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -402,6 +402,7 @@ Masklinn
 Matej Stuchlik
 Mathew Jennings
 Mathieu Bridon
+Mathieu Kniewallner
 Matt Bacchi
 Matt Good
 Matt Maker
diff --git a/NEWS.rst b/NEWS.rst
index 961768250..b8906ea75 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -9,6 +9,38 @@
 
 .. towncrier release notes start
 
+22.1.1 (2022-05-20)
+===================
+
+Bug Fixes
+---------
+
+- Properly filter out optional dependencies (i.e. extras) when checking build environment distributions. (`#11112 <https://github.com/pypa/pip/issues/11112>`_)
+- Change the build environment dependency checking to be opt-in. (`#11116 <https://github.com/pypa/pip/issues/11116>`_)
+- Allow using a pre-release version to satisfy a build requirement. This helps
+  manually populated build environments to more accurately detect build-time
+  requirement conflicts. (`#11123 <https://github.com/pypa/pip/issues/11123>`_)
+
+
+22.1 (2022-05-11)
+=================
+
+Process
+-------
+
+- Enable the ``importlib.metadata`` metadata implementation by default on
+  Python 3.11 (or later). The environment variable ``_PIP_USE_IMPORTLIB_METADATA``
+  can still be used to enable the implementation on 3.10 and earlier, or disable
+  it on 3.11 (by setting it to ``0`` or ``false``).
+
+Bug Fixes
+---------
+
+- Revert `#9243 <https://github.com/pypa/pip/issues/9243>`_ since it introduced a regression in certain edge cases. (`#10962 <https://github.com/pypa/pip/issues/10962>`_)
+- Fix missing ``REQUESTED`` metadata when using URL constraints. (`#11079 <https://github.com/pypa/pip/issues/11079>`_)
+- ``pip config`` now normalizes names by converting underscores into dashes. (`#9330 <https://github.com/pypa/pip/issues/9330>`_)
+
+
 22.1b1 (2022-04-30)
 ===================
 
diff --git a/docs/html/index.md b/docs/html/index.md
index 34a017449..ab0b40dc1 100644
--- a/docs/html/index.md
+++ b/docs/html/index.md
@@ -48,3 +48,5 @@ lists or chat rooms:
 [packaging-discourse]: https://discuss.python.org/c/packaging/14
 [irc-pypa]: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa
 [irc-pypa-dev]: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa-dev
+
+If you find any security issues, please report to [security@python.org](mailto:security@python.org)
diff --git a/news/10962.bugfix.rst b/news/10962.bugfix.rst
deleted file mode 100644
index 8ff4b4283..000000000
--- a/news/10962.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Revert `#9243 <https://github.com/pypa/pip/issues/9243>`_ since it introduced a regression in certain edge cases.
diff --git a/news/11079.bugfix.rst b/news/11079.bugfix.rst
deleted file mode 100644
index 61305a765..000000000
--- a/news/11079.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix missing ``REQUESTED`` metadata when using URL constraints.
diff --git a/news/11099.process.rst b/news/11099.process.rst
new file mode 100644
index 000000000..2dc111a8e
--- /dev/null
+++ b/news/11099.process.rst
@@ -0,0 +1,1 @@
+Remove reliance on the stdlib cgi module, which is deprecated in Python 3.11.
diff --git a/news/9330.bugfix.rst b/news/9330.bugfix.rst
deleted file mode 100644
index 63e44c238..000000000
--- a/news/9330.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-``pip config`` now normalizes names by converting underscores into dashes.
diff --git a/src/pip/__init__.py b/src/pip/__init__.py
index c44b971cc..9cd8466e4 100644
--- a/src/pip/__init__.py
+++ b/src/pip/__init__.py
@@ -1,6 +1,6 @@
 from typing import List, Optional
 
-__version__ = "22.1.dev1"
+__version__ = "22.2.dev0"
 
 
 def main(args: Optional[List[str]] = None) -> int:
diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py
index 7783c9acd..ccf2b4bc4 100644
--- a/src/pip/_internal/build_env.py
+++ b/src/pip/_internal/build_env.py
@@ -175,8 +175,10 @@ class BuildEnvironment:
             )
             for req_str in reqs:
                 req = Requirement(req_str)
-                if req.marker is not None and not req.marker.evaluate():
-                    continue  # FIXME: Consider extras?
+                # We're explicitly evaluating with an empty extra value, since build
+                # environments are not provided any mechanism to select specific extras.
+                if req.marker is not None and not req.marker.evaluate({"extra": ""}):
+                    continue
                 dist = env.get_distribution(req.name)
                 if not dist:
                     missing.add(req_str)
@@ -185,7 +187,7 @@ class BuildEnvironment:
                     installed_req_str = f"{req.name}=={dist.version}"
                 else:
                     installed_req_str = f"{req.name}==={dist.version}"
-                if dist.version not in req.specifier:
+                if not req.specifier.contains(dist.version, prereleases=True):
                     conflicting.add((installed_req_str, req_str))
                 # FIXME: Consider direct URL?
         return conflicting, missing
diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py
index c84ecabd3..e96b8e5e3 100644
--- a/src/pip/_internal/cli/cmdoptions.py
+++ b/src/pip/_internal/cli/cmdoptions.py
@@ -749,6 +749,15 @@ no_build_isolation: Callable[..., Option] = partial(
     "if this option is used.",
 )
 
+check_build_deps: Callable[..., Option] = partial(
+    Option,
+    "--check-build-dependencies",
+    dest="check_build_deps",
+    action="store_true",
+    default=False,
+    help="Check the build dependencies when PEP517 is used.",
+)
+
 
 def _handle_no_use_pep517(
     option: Option, opt: str, value: str, parser: OptionParser
diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py
index aab177002..539d21dff 100644
--- a/src/pip/_internal/cli/req_command.py
+++ b/src/pip/_internal/cli/req_command.py
@@ -293,6 +293,7 @@ class RequirementCommand(IndexGroupCommand):
             src_dir=options.src_dir,
             download_dir=download_dir,
             build_isolation=options.build_isolation,
+            check_build_deps=options.check_build_deps,
             build_tracker=build_tracker,
             session=session,
             progress_bar=options.progress_bar,
diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py
index a6d7e628f..d70ce4f1f 100644
--- a/src/pip/_internal/commands/download.py
+++ b/src/pip/_internal/commands/download.py
@@ -49,6 +49,7 @@ class DownloadCommand(RequirementCommand):
         self.cmd_opts.add_option(cmdoptions.no_build_isolation())
         self.cmd_opts.add_option(cmdoptions.use_pep517())
         self.cmd_opts.add_option(cmdoptions.no_use_pep517())
+        self.cmd_opts.add_option(cmdoptions.check_build_deps())
         self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
 
         self.cmd_opts.add_option(
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index 55edb280c..3634ea04c 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -189,6 +189,7 @@ class InstallCommand(RequirementCommand):
         self.cmd_opts.add_option(cmdoptions.no_build_isolation())
         self.cmd_opts.add_option(cmdoptions.use_pep517())
         self.cmd_opts.add_option(cmdoptions.no_use_pep517())
+        self.cmd_opts.add_option(cmdoptions.check_build_deps())
 
         self.cmd_opts.add_option(cmdoptions.config_settings())
         self.cmd_opts.add_option(cmdoptions.install_options())
diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py
index 05b91d473..9dd6c82f2 100644
--- a/src/pip/_internal/commands/wheel.py
+++ b/src/pip/_internal/commands/wheel.py
@@ -57,6 +57,7 @@ class WheelCommand(RequirementCommand):
         self.cmd_opts.add_option(cmdoptions.no_build_isolation())
         self.cmd_opts.add_option(cmdoptions.use_pep517())
         self.cmd_opts.add_option(cmdoptions.no_use_pep517())
+        self.cmd_opts.add_option(cmdoptions.check_build_deps())
         self.cmd_opts.add_option(cmdoptions.constraints())
         self.cmd_opts.add_option(cmdoptions.editable())
         self.cmd_opts.add_option(cmdoptions.requirements())
diff --git a/src/pip/_internal/distributions/base.py b/src/pip/_internal/distributions/base.py
index 149fff55d..75ce2dc90 100644
--- a/src/pip/_internal/distributions/base.py
+++ b/src/pip/_internal/distributions/base.py
@@ -31,6 +31,9 @@ class AbstractDistribution(metaclass=abc.ABCMeta):
 
     @abc.abstractmethod
     def prepare_distribution_metadata(
-        self, finder: PackageFinder, build_isolation: bool
+        self,
+        finder: PackageFinder,
+        build_isolation: bool,
+        check_build_deps: bool,
     ) -> None:
         raise NotImplementedError()
diff --git a/src/pip/_internal/distributions/installed.py b/src/pip/_internal/distributions/installed.py
index be5962f98..edb38aa1a 100644
--- a/src/pip/_internal/distributions/installed.py
+++ b/src/pip/_internal/distributions/installed.py
@@ -15,6 +15,9 @@ class InstalledDistribution(AbstractDistribution):
         return self.req.satisfied_by
 
     def prepare_distribution_metadata(
-        self, finder: PackageFinder, build_isolation: bool
+        self,
+        finder: PackageFinder,
+        build_isolation: bool,
+        check_build_deps: bool,
     ) -> None:
         pass
diff --git a/src/pip/_internal/distributions/sdist.py b/src/pip/_internal/distributions/sdist.py
index 463c409d4..4c2564793 100644
--- a/src/pip/_internal/distributions/sdist.py
+++ b/src/pip/_internal/distributions/sdist.py
@@ -22,7 +22,10 @@ class SourceDistribution(AbstractDistribution):
         return self.req.get_dist()
 
     def prepare_distribution_metadata(
-        self, finder: PackageFinder, build_isolation: bool
+        self,
+        finder: PackageFinder,
+        build_isolation: bool,
+        check_build_deps: bool,
     ) -> None:
         # Load pyproject.toml, to determine whether PEP 517 is to be used
         self.req.load_pyproject_toml()
@@ -43,7 +46,9 @@ class SourceDistribution(AbstractDistribution):
             self.req.isolated_editable_sanity_check()
             # Install the dynamic build requirements.
             self._install_build_reqs(finder)
-        elif self.req.use_pep517:
+        # Check if the current environment provides build dependencies
+        should_check_deps = self.req.use_pep517 and check_build_deps
+        if should_check_deps:
             pyproject_requires = self.req.pyproject_requires
             assert pyproject_requires is not None
             conflicting, missing = self.req.build_env.check_requirements(
diff --git a/src/pip/_internal/distributions/wheel.py b/src/pip/_internal/distributions/wheel.py
index 340b0f3c5..03aac775b 100644
--- a/src/pip/_internal/distributions/wheel.py
+++ b/src/pip/_internal/distributions/wheel.py
@@ -26,6 +26,9 @@ class WheelDistribution(AbstractDistribution):
         return get_wheel_distribution(wheel, canonicalize_name(self.req.name))
 
     def prepare_distribution_metadata(
-        self, finder: PackageFinder, build_isolation: bool
+        self,
+        finder: PackageFinder,
+        build_isolation: bool,
+        check_build_deps: bool,
     ) -> None:
         pass
diff --git a/src/pip/_internal/index/collector.py b/src/pip/_internal/index/collector.py
index e6e9469af..c79e2410c 100644
--- a/src/pip/_internal/index/collector.py
+++ b/src/pip/_internal/index/collector.py
@@ -2,8 +2,8 @@
 The main purpose of this module is to expose LinkCollector.collect_sources().
 """
 
-import cgi
 import collections
+import email.message
 import functools
 import itertools
 import logging
@@ -155,9 +155,11 @@ def _get_html_response(url: str, session: PipSession) -> Response:
 def _get_encoding_from_headers(headers: ResponseHeaders) -> Optional[str]:
     """Determine if we have any encoding information in our headers."""
     if headers and "Content-Type" in headers:
-        content_type, params = cgi.parse_header(headers["Content-Type"])
-        if "charset" in params:
-            return params["charset"]
+        m = email.message.Message()
+        m["content-type"] = headers["Content-Type"]
+        charset = m.get_param("charset")
+        if charset:
+            return str(charset)
     return None
 
 
diff --git a/src/pip/_internal/metadata/__init__.py b/src/pip/_internal/metadata/__init__.py
index aa9a80822..01c35f94b 100644
--- a/src/pip/_internal/metadata/__init__.py
+++ b/src/pip/_internal/metadata/__init__.py
@@ -1,7 +1,11 @@
+import contextlib
 import functools
 import os
+import sys
 from typing import TYPE_CHECKING, List, Optional, Type, cast
 
+from pip._internal.utils.misc import strtobool
+
 from .base import BaseDistribution, BaseEnvironment, FilesystemWheel, MemoryWheel, Wheel
 
 if TYPE_CHECKING:
@@ -22,6 +26,29 @@ __all__ = [
 ]
 
 
+def _should_use_importlib_metadata() -> bool:
+    """Whether to use the ``importlib.metadata`` or ``pkg_resources`` backend.
+
+    By default, pip uses ``importlib.metadata`` on Python 3.11+, and
+    ``pkg_resourcess`` otherwise. This can be overriden by a couple of ways:
+
+    * If environment variable ``_PIP_USE_IMPORTLIB_METADATA`` is set, it
+      dictates whether ``importlib.metadata`` is used, regardless of Python
+      version.
+    * On Python 3.11+, Python distributors can patch ``importlib.metadata``
+      to add a global constant ``_PIP_USE_IMPORTLIB_METADATA = False``. This
+      makes pip use ``pkg_resources`` (unless the user set the aforementioned
+      environment variable to *True*).
+    """
+    with contextlib.suppress(KeyError, ValueError):
+        return bool(strtobool(os.environ["_PIP_USE_IMPORTLIB_METADATA"]))
+    if sys.version_info < (3, 11):
+        return False
+    import importlib.metadata
+
+    return bool(getattr(importlib.metadata, "_PIP_USE_IMPORTLIB_METADATA", True))
+
+
 class Backend(Protocol):
     Distribution: Type[BaseDistribution]
     Environment: Type[BaseEnvironment]
@@ -29,7 +56,7 @@ class Backend(Protocol):
 
 @functools.lru_cache(maxsize=None)
 def select_backend() -> Backend:
-    if os.environ.get("_PIP_METADATA_BACKEND_IMPORTLIB"):
+    if _should_use_importlib_metadata():
         from . import importlib
 
         return cast(Backend, importlib)
diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py
index 35bc970e2..79b82a570 100644
--- a/src/pip/_internal/network/download.py
+++ b/src/pip/_internal/network/download.py
@@ -1,6 +1,6 @@
 """Download files with progress indicators.
 """
-import cgi
+import email.message
 import logging
 import mimetypes
 import os
@@ -81,12 +81,13 @@ def parse_content_disposition(content_disposition: str, default_filename: str) -
     Parse the "filename" value from a Content-Disposition header, and
     return the default filename if the result is empty.
     """
-    _type, params = cgi.parse_header(content_disposition)
-    filename = params.get("filename")
+    m = email.message.Message()
+    m["content-type"] = content_disposition
+    filename = m.get_param("filename")
     if filename:
         # We need to sanitize the filename to prevent directory traversal
         # in case the filename contains ".." path parts.
-        filename = sanitize_content_filename(filename)
+        filename = sanitize_content_filename(str(filename))
     return filename or default_filename
 
 
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
index 7550d3a90..df1016ebb 100644
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -50,11 +50,14 @@ def _get_prepared_distribution(
     build_tracker: BuildTracker,
     finder: PackageFinder,
     build_isolation: bool,
+    check_build_deps: bool,
 ) -> BaseDistribution:
     """Prepare a distribution for installation."""
     abstract_dist = make_distribution_for_install_requirement(req)
     with build_tracker.track(req):
-        abstract_dist.prepare_distribution_metadata(finder, build_isolation)
+        abstract_dist.prepare_distribution_metadata(
+            finder, build_isolation, check_build_deps
+        )
     return abstract_dist.get_metadata_distribution()
 
 
@@ -199,6 +202,7 @@ class RequirementPreparer:
         download_dir: Optional[str],
         src_dir: str,
         build_isolation: bool,
+        check_build_deps: bool,
         build_tracker: BuildTracker,
         session: PipSession,
         progress_bar: str,
@@ -225,6 +229,9 @@ class RequirementPreparer:
         # Is build isolation allowed?
         self.build_isolation = build_isolation
 
+        # Should check build dependencies?
+        self.check_build_deps = check_build_deps
+
         # Should hash-checking be required?
         self.require_hashes = require_hashes
 
@@ -492,6 +499,7 @@ class RequirementPreparer:
             self.build_tracker,
             self.finder,
             self.build_isolation,
+            self.check_build_deps,
         )
         return dist
 
@@ -545,6 +553,7 @@ class RequirementPreparer:
                 self.build_tracker,
                 self.finder,
                 self.build_isolation,
+                self.check_build_deps,
             )
 
             req.check_if_exists(self.use_user_site)
diff --git a/src/pip/_internal/req/req_set.py b/src/pip/_internal/req/req_set.py
index 6626c37e2..0f550bf1e 100644
--- a/src/pip/_internal/req/req_set.py
+++ b/src/pip/_internal/req/req_set.py
@@ -1,13 +1,10 @@
 import logging
 from collections import OrderedDict
-from typing import Dict, Iterable, List, Optional, Tuple
+from typing import Dict, List
 
 from pip._vendor.packaging.utils import canonicalize_name
 
-from pip._internal.exceptions import InstallationError
-from pip._internal.models.wheel import Wheel
 from pip._internal.req.req_install import InstallRequirement
-from pip._internal.utils import compatibility_tags
 
 logger = logging.getLogger(__name__)
 
@@ -51,123 +48,6 @@ class RequirementSet:
         project_name = canonicalize_name(install_req.name)
         self.requirements[project_name] = install_req
 
-    def add_requirement(
-        self,
-        install_req: InstallRequirement,
-        parent_req_name: Optional[str] = None,
-        extras_requested: Optional[Iterable[str]] = None,
-    ) -> Tuple[List[InstallRequirement], Optional[InstallRequirement]]:
-        """Add install_req as a requirement to install.
-
-        :param parent_req_name: The name of the requirement that needed this
-            added. The name is used because when multiple unnamed requirements
-            resolve to the same name, we could otherwise end up with dependency
-            links that point outside the Requirements set. parent_req must
-            already be added. Note that None implies that this is a user
-            supplied requirement, vs an inferred one.
-        :param extras_requested: an iterable of extras used to evaluate the
-            environment markers.
-        :return: Additional requirements to scan. That is either [] if
-            the requirement is not applicable, or [install_req] if the
-            requirement is applicable and has just been added.
-        """
-        # If the markers do not match, ignore this requirement.
-        if not install_req.match_markers(extras_requested):
-            logger.info(
-                "Ignoring %s: markers '%s' don't match your environment",
-                install_req.name,
-                install_req.markers,
-            )
-            return [], None
-
-        # If the wheel is not supported, raise an error.
-        # Should check this after filtering out based on environment markers to
-        # allow specifying different wheels based on the environment/OS, in a
-        # single requirements file.
-        if install_req.link and install_req.link.is_wheel:
-            wheel = Wheel(install_req.link.filename)
-            tags = compatibility_tags.get_supported()
-            if self.check_supported_wheels and not wheel.supported(tags):
-                raise InstallationError(
-                    "{} is not a supported wheel on this platform.".format(
-                        wheel.filename
-                    )
-                )
-
-        # This next bit is really a sanity check.
-        assert (
-            not install_req.user_supplied or parent_req_name is None
-        ), "a user supplied req shouldn't have a parent"
-
-        # Unnamed requirements are scanned again and the requirement won't be
-        # added as a dependency until after scanning.
-        if not install_req.name:
-            self.add_unnamed_requirement(install_req)
-            return [install_req], None
-
-        try:
-            existing_req: Optional[InstallRequirement] = self.get_requirement(
-                install_req.name
-            )
-        except KeyError:
-            existing_req = None
-
-        has_conflicting_requirement = (
-            parent_req_name is None
-            and existing_req
-            and not existing_req.constraint
-            and existing_req.extras == install_req.extras
-            and existing_req.req
-            and install_req.req
-            and existing_req.req.specifier != install_req.req.specifier
-        )
-        if has_conflicting_requirement:
-            raise InstallationError(
-                "Double requirement given: {} (already in {}, name={!r})".format(
-                    install_req, existing_req, install_req.name
-                )
-            )
-
-        # When no existing requirement exists, add the requirement as a
-        # dependency and it will be scanned again after.
-        if not existing_req:
-            self.add_named_requirement(install_req)
-            # We'd want to rescan this requirement later
-            return [install_req], install_req
-
-        # Assume there's no need to scan, and that we've already
-        # encountered this for scanning.
-        if install_req.constraint or not existing_req.constraint:
-            return [], existing_req
-
-        does_not_satisfy_constraint = install_req.link and not (
-            existing_req.link and install_req.link.path == existing_req.link.path
-        )
-        if does_not_satisfy_constraint:
-            raise InstallationError(
-                "Could not satisfy constraints for '{}': "
-                "installation from path or url cannot be "
-                "constrained to a version".format(install_req.name)
-            )
-        # If we're now installing a constraint, mark the existing
-        # object for real installation.
-        existing_req.constraint = False
-        # If we're now installing a user supplied requirement,
-        # mark the existing object as such.
-        if install_req.user_supplied:
-            existing_req.user_supplied = True
-        existing_req.extras = tuple(
-            sorted(set(existing_req.extras) | set(install_req.extras))
-        )
-        logger.debug(
-            "Setting %s extras to: %s",
-            existing_req,
-            existing_req.extras,
-        )
-        # Return the existing requirement for addition to the parent and
-        # scanning again.
-        return [existing_req], existing_req
-
     def has_requirement(self, name: str) -> bool:
         project_name = canonicalize_name(name)
 
diff --git a/src/pip/_internal/resolution/legacy/resolver.py b/src/pip/_internal/resolution/legacy/resolver.py
index 8c149d437..1225ae70f 100644
--- a/src/pip/_internal/resolution/legacy/resolver.py
+++ b/src/pip/_internal/resolution/legacy/resolver.py
@@ -28,12 +28,14 @@ from pip._internal.exceptions import (
     DistributionNotFound,
     HashError,
     HashErrors,
+    InstallationError,
     NoneMetadataError,
     UnsupportedPythonVersion,
 )
 from pip._internal.index.package_finder import PackageFinder
 from pip._internal.metadata import BaseDistribution
 from pip._internal.models.link import Link
+from pip._internal.models.wheel import Wheel
 from pip._internal.operations.prepare import RequirementPreparer
 from pip._internal.req.req_install import (
     InstallRequirement,
@@ -41,6 +43,7 @@ from pip._internal.req.req_install import (
 )
 from pip._internal.req.req_set import RequirementSet
 from pip._internal.resolution.base import BaseResolver, InstallRequirementProvider
+from pip._internal.utils import compatibility_tags
 from pip._internal.utils.compatibility_tags import get_supported
 from pip._internal.utils.logging import indent_log
 from pip._internal.utils.misc import normalize_version_info
@@ -168,7 +171,7 @@ class Resolver(BaseResolver):
         for req in root_reqs:
             if req.constraint:
                 check_invalid_constraint_type(req)
-            requirement_set.add_requirement(req)
+            self._add_requirement_to_set(requirement_set, req)
 
         # Actually prepare the files, and collect any exceptions. Most hash
         # exceptions cannot be checked ahead of time, because
@@ -188,6 +191,124 @@ class Resolver(BaseResolver):
 
         return requirement_set
 
+    def _add_requirement_to_set(
+        self,
+        requirement_set: RequirementSet,
+        install_req: InstallRequirement,
+        parent_req_name: Optional[str] = None,
+        extras_requested: Optional[Iterable[str]] = None,
+    ) -> Tuple[List[InstallRequirement], Optional[InstallRequirement]]:
+        """Add install_req as a requirement to install.
+
+        :param parent_req_name: The name of the requirement that needed this
+            added. The name is used because when multiple unnamed requirements
+            resolve to the same name, we could otherwise end up with dependency
+            links that point outside the Requirements set. parent_req must
+            already be added. Note that None implies that this is a user
+            supplied requirement, vs an inferred one.
+        :param extras_requested: an iterable of extras used to evaluate the
+            environment markers.
+        :return: Additional requirements to scan. That is either [] if
+            the requirement is not applicable, or [install_req] if the
+            requirement is applicable and has just been added.
+        """
+        # If the markers do not match, ignore this requirement.
+        if not install_req.match_markers(extras_requested):
+            logger.info(
+                "Ignoring %s: markers '%s' don't match your environment",
+                install_req.name,
+                install_req.markers,
+            )
+            return [], None
+
+        # If the wheel is not supported, raise an error.
+        # Should check this after filtering out based on environment markers to
+        # allow specifying different wheels based on the environment/OS, in a
+        # single requirements file.
+        if install_req.link and install_req.link.is_wheel:
+            wheel = Wheel(install_req.link.filename)
+            tags = compatibility_tags.get_supported()
+            if requirement_set.check_supported_wheels and not wheel.supported(tags):
+                raise InstallationError(
+                    "{} is not a supported wheel on this platform.".format(
+                        wheel.filename
+                    )
+                )
+
+        # This next bit is really a sanity check.
+        assert (
+            not install_req.user_supplied or parent_req_name is None
+        ), "a user supplied req shouldn't have a parent"
+
+        # Unnamed requirements are scanned again and the requirement won't be
+        # added as a dependency until after scanning.
+        if not install_req.name:
+            requirement_set.add_unnamed_requirement(install_req)
+            return [install_req], None
+
+        try:
+            existing_req: Optional[
+                InstallRequirement
+            ] = requirement_set.get_requirement(install_req.name)
+        except KeyError:
+            existing_req = None
+
+        has_conflicting_requirement = (
+            parent_req_name is None
+            and existing_req
+            and not existing_req.constraint
+            and existing_req.extras == install_req.extras
+            and existing_req.req
+            and install_req.req
+            and existing_req.req.specifier != install_req.req.specifier
+        )
+        if has_conflicting_requirement:
+            raise InstallationError(
+                "Double requirement given: {} (already in {}, name={!r})".format(
+                    install_req, existing_req, install_req.name
+                )
+            )
+
+        # When no existing requirement exists, add the requirement as a
+        # dependency and it will be scanned again after.
+        if not existing_req:
+            requirement_set.add_named_requirement(install_req)
+            # We'd want to rescan this requirement later
+            return [install_req], install_req
+
+        # Assume there's no need to scan, and that we've already
+        # encountered this for scanning.
+        if install_req.constraint or not existing_req.constraint:
+            return [], existing_req
+
+        does_not_satisfy_constraint = install_req.link and not (
+            existing_req.link and install_req.link.path == existing_req.link.path
+        )
+        if does_not_satisfy_constraint:
+            raise InstallationError(
+                "Could not satisfy constraints for '{}': "
+                "installation from path or url cannot be "
+                "constrained to a version".format(install_req.name)
+            )
+        # If we're now installing a constraint, mark the existing
+        # object for real installation.
+        existing_req.constraint = False
+        # If we're now installing a user supplied requirement,
+        # mark the existing object as such.
+        if install_req.user_supplied:
+            existing_req.user_supplied = True
+        existing_req.extras = tuple(
+            sorted(set(existing_req.extras) | set(install_req.extras))
+        )
+        logger.debug(
+            "Setting %s extras to: %s",
+            existing_req,
+            existing_req.extras,
+        )
+        # Return the existing requirement for addition to the parent and
+        # scanning again.
+        return [existing_req], existing_req
+
     def _is_upgrade_allowed(self, req: InstallRequirement) -> bool:
         if self.upgrade_strategy == "to-satisfy-only":
             return False
@@ -393,7 +514,8 @@ class Resolver(BaseResolver):
             # the legacy resolver so I'm just not going to bother refactoring.
             sub_install_req = self._make_install_req(str(subreq), req_to_install)
             parent_req_name = req_to_install.name
-            to_scan_again, add_to_parent = requirement_set.add_requirement(
+            to_scan_again, add_to_parent = self._add_requirement_to_set(
+                requirement_set,
                 sub_install_req,
                 parent_req_name=parent_req_name,
                 extras_requested=extras_requested,
@@ -410,7 +532,9 @@ class Resolver(BaseResolver):
                 # 'unnamed' requirements can only come from being directly
                 # provided by the user.
                 assert req_to_install.user_supplied
-                requirement_set.add_requirement(req_to_install, parent_req_name=None)
+                self._add_requirement_to_set(
+                    requirement_set, req_to_install, parent_req_name=None
+                )
 
             if not self.ignore_dependencies:
                 if req_to_install.extras:


Test patch:
diff --git a/tests/functional/test_build_env.py b/tests/functional/test_build_env.py
index a7f89687c..1b9f42d94 100644
--- a/tests/functional/test_build_env.py
+++ b/tests/functional/test_build_env.py
@@ -178,6 +178,7 @@ def test_build_env_requirements_check(script: PipTestEnvironment) -> None:
             [
                 "bar==2.0; python_version < '3.0'",
                 "bar==3.0; python_version >= '3.0'",
+                "foo==4.0; extra == 'dev'",
             ],
         )
         assert r == (set(), set()), repr(r)
diff --git a/tests/functional/test_pep517.py b/tests/functional/test_pep517.py
index cae3040ab..7f913c215 100644
--- a/tests/functional/test_pep517.py
+++ b/tests/functional/test_pep517.py
@@ -5,7 +5,13 @@ import tomli_w
 
 from pip._internal.build_env import BuildEnvironment
 from pip._internal.req import InstallRequirement
-from tests.lib import PipTestEnvironment, TestData, make_test_finder, path_to_url
+from tests.lib import (
+    PipTestEnvironment,
+    TestData,
+    create_basic_wheel_for_package,
+    make_test_finder,
+    path_to_url,
+)
 from tests.lib.path import Path
 
 
@@ -160,6 +166,26 @@ def test_conflicting_pep517_backend_requirements(
     assert result.returncode != 0 and msg in result.stderr, str(result)
 
 
+def test_no_check_build_deps(
+    script: PipTestEnvironment, tmpdir: Path, data: TestData
+) -> None:
+    project_dir = make_project(
+        tmpdir, requires=["simplewheel==2.0"], backend="test_backend"
+    )
+    script.pip(
+        "install",
+        "simplewheel==1.0",
+        "test_backend",
+        "--no-index",
+        "-f",
+        data.packages,
+        "-f",
+        data.backends,
+    )
+    result = script.pip("install", "--no-build-isolation", project_dir)
+    result.assert_installed("project", editable=False)
+
+
 def test_validate_missing_pep517_backend_requirements(
     script: PipTestEnvironment, tmpdir: Path, data: TestData
 ) -> None:
@@ -174,6 +200,7 @@ def test_validate_missing_pep517_backend_requirements(
         "-f",
         data.packages,
         "--no-build-isolation",
+        "--check-build-dependencies",
         project_dir,
         expect_error=True,
     )
@@ -199,6 +226,7 @@ def test_validate_conflicting_pep517_backend_requirements(
         "-f",
         data.packages,
         "--no-build-isolation",
+        "--check-build-dependencies",
         project_dir,
         expect_error=True,
     )
@@ -210,6 +238,25 @@ def test_validate_conflicting_pep517_backend_requirements(
     assert result.returncode != 0 and msg in result.stderr, str(result)
 
 
+def test_pep517_backend_requirements_satisfied_by_prerelease(
+    script: PipTestEnvironment,
+    data: TestData,
+) -> None:
+    create_basic_wheel_for_package(script, "myreq", "1.0a1")
+    script.pip("install", "myreq==1.0a1", "--no-index", "-f", script.scratch_path)
+    script.pip("install", "test_backend", "--no-index", "-f", data.backends)
+
+    project_dir = make_project(
+        script.temp_path,
+        requires=["test_backend", "myreq"],
+        backend="test_backend",
+    )
+    project_dir.joinpath("backend_reqs.txt").write_text("myreq")
+
+    result = script.pip("install", "--no-index", "--no-build-isolation", project_dir)
+    assert "Installing backend dependencies:" not in result.stdout
+
+
 def test_pep517_backend_requirements_already_satisfied(
     script: PipTestEnvironment, tmpdir: Path, data: TestData
 ) -> None:
diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py
index 74f4a8bab..ac68fa4df 100644
--- a/tests/unit/test_req.py
+++ b/tests/unit/test_req.py
@@ -91,6 +91,7 @@ class TestRequirementSet:
                 src_dir=os.path.join(self.tempdir, "src"),
                 download_dir=None,
                 build_isolation=True,
+                check_build_deps=False,
                 build_tracker=tracker,
                 session=session,
                 progress_bar="on",
@@ -123,7 +124,7 @@ class TestRequirementSet:
         reqset = RequirementSet()
         req = install_req_from_line("simple")
         req.user_supplied = True
-        reqset.add_requirement(req)
+        reqset.add_named_requirement(req)
         finder = make_test_finder(find_links=[data.find_links])
         with self._basic_resolver(finder) as resolver:
             with pytest.raises(
@@ -144,7 +145,7 @@ class TestRequirementSet:
         reqset = RequirementSet()
         req = install_req_from_editable(data.packages.joinpath("LocalEnvironMarker"))
         req.user_supplied = True
-        reqset.add_requirement(req)
+        reqset.add_unnamed_requirement(req)
         finder = make_test_finder(find_links=[data.find_links])
         with self._basic_resolver(finder) as resolver:
             reqset = resolver.resolve(reqset.all_requirements, True)
@@ -155,7 +156,9 @@ class TestRequirementSet:
         are missing.
         """
         reqset = RequirementSet()
-        reqset.add_requirement(get_processed_req_from_line("simple==1.0", lineno=1))
+        reqset.add_named_requirement(
+            get_processed_req_from_line("simple==1.0", lineno=1)
+        )
 
         finder = make_test_finder(find_links=[data.find_links])
 
@@ -194,14 +197,14 @@ class TestRequirementSet:
 
         """
         reqset = RequirementSet()
-        reqset.add_requirement(
+        reqset.add_unnamed_requirement(
             get_processed_req_from_line(
                 "git+git://github.com/pypa/pip-test-package --hash=sha256:123",
                 lineno=1,
             )
         )
         dir_path = data.packages.joinpath("FSPkg")
-        reqset.add_requirement(
+        reqset.add_unnamed_requirement(
             get_processed_req_from_line(
                 f"file://{dir_path}",
                 lineno=2,
@@ -235,7 +238,7 @@ class TestRequirementSet:
         """
         reqset = RequirementSet()
         # Test that there must be exactly 1 specifier:
-        reqset.add_requirement(
+        reqset.add_named_requirement(
             get_processed_req_from_line(
                 "simple --hash=sha256:a90427ae31f5d1d0d7ec06ee97d9fcf2d0fc9a786985"
                 "250c1c83fd68df5911dd",
@@ -243,7 +246,7 @@ class TestRequirementSet:
             )
         )
         # Test that the operator must be ==:
-        reqset.add_requirement(
+        reqset.add_named_requirement(
             get_processed_req_from_line(
                 "simple2>1.0 --hash=sha256:3ad45e1e9aa48b4462af0"
                 "123f6a7e44a9115db1ef945d4d92c123dfe21815a06",
@@ -267,7 +270,7 @@ class TestRequirementSet:
         """A hash mismatch should raise an error."""
         file_url = path_to_url((data.packages / "simple-1.0.tar.gz").resolve())
         reqset = RequirementSet()
-        reqset.add_requirement(
+        reqset.add_unnamed_requirement(
             get_processed_req_from_line(
                 f"{file_url} --hash=sha256:badbad",
                 lineno=1,
@@ -292,7 +295,7 @@ class TestRequirementSet:
         dependencies get complained about when --require-hashes is on."""
         reqset = RequirementSet()
         finder = make_test_finder(find_links=[data.find_links])
-        reqset.add_requirement(
+        reqset.add_named_requirement(
             get_processed_req_from_line(
                 "TopoRequires2==0.0.1 "  # requires TopoRequires
                 "--hash=sha256:eaf9a01242c9f2f42cf2bd82a6a848cd"
@@ -322,7 +325,7 @@ class TestRequirementSet:
 
         """
         reqset = RequirementSet()
-        reqset.add_requirement(
+        reqset.add_named_requirement(
             get_processed_req_from_line(
                 "TopoRequires2==0.0.1 "  # requires TopoRequires
                 "--hash=sha256:eaf9a01242c9f2f42cf2bd82a6a848cd"
@@ -330,7 +333,7 @@ class TestRequirementSet:
                 lineno=1,
             )
         )
-        reqset.add_requirement(
+        reqset.add_named_requirement(
             get_processed_req_from_line(
                 "TopoRequires==0.0.1 "
                 "--hash=sha256:d6dd1e22e60df512fdcf3640ced3039b3b02a56ab2cee81ebcb"
@@ -382,32 +385,6 @@ class TestInstallRequirement:
         assert req.link.scheme == "https"
         assert req.link.url == url
 
-    def test_unsupported_wheel_link_requirement_raises(self) -> None:
-        reqset = RequirementSet()
-        req = install_req_from_line(
-            "https://whatever.com/peppercorn-0.4-py2.py3-bogus-any.whl",
-        )
-        assert req.link is not None
-        assert req.link.is_wheel
-        assert req.link.scheme == "https"
-
-        with pytest.raises(InstallationError):
-            reqset.add_requirement(req)
-
-    def test_unsupported_wheel_local_file_requirement_raises(
-        self, data: TestData
-    ) -> None:
-        reqset = RequirementSet()
-        req = install_req_from_line(
-            data.packages.joinpath("simple.dist-0.1-py1-none-invalid.whl"),
-        )
-        assert req.link is not None
-        assert req.link.is_wheel
-        assert req.link.scheme == "file"
-
-        with pytest.raises(InstallationError):
-            reqset.add_requirement(req)
-
     def test_str(self) -> None:
         req = install_req_from_line("simple==0.1")
         assert str(req) == "simple==0.1"
@@ -658,19 +635,6 @@ def test_parse_editable_local_extras(
     )
 
 
-def test_exclusive_environment_markers() -> None:
-    """Make sure RequirementSet accepts several excluding env markers"""
-    eq36 = install_req_from_line("Django>=1.6.10,<1.7 ; python_version == '3.6'")
-    eq36.user_supplied = True
-    ne36 = install_req_from_line("Django>=1.6.10,<1.8 ; python_version != '3.6'")
-    ne36.user_supplied = True
-
-    req_set = RequirementSet()
-    req_set.add_requirement(eq36)
-    req_set.add_requirement(ne36)
-    assert req_set.has_requirement("Django")
-
-
 def test_mismatched_versions(caplog: pytest.LogCaptureFixture) -> None:
     req = InstallRequirement(
         req=Requirement("simplewheel==2.0"),
diff --git a/tests/unit/test_resolution_legacy_resolver.py b/tests/unit/test_resolution_legacy_resolver.py
index 7b5ba6372..134ff65b4 100644
--- a/tests/unit/test_resolution_legacy_resolver.py
+++ b/tests/unit/test_resolution_legacy_resolver.py
@@ -7,15 +7,20 @@ import pytest
 from pip._vendor.packaging.specifiers import SpecifierSet
 from pip._vendor.packaging.utils import NormalizedName
 
-from pip._internal.exceptions import NoneMetadataError, UnsupportedPythonVersion
+from pip._internal.exceptions import (
+    InstallationError,
+    NoneMetadataError,
+    UnsupportedPythonVersion,
+)
 from pip._internal.metadata import BaseDistribution
 from pip._internal.models.candidate import InstallationCandidate
 from pip._internal.req.constructors import install_req_from_line
+from pip._internal.req.req_set import RequirementSet
 from pip._internal.resolution.legacy.resolver import (
     Resolver,
     _check_dist_requires_python,
 )
-from tests.lib import make_test_finder
+from tests.lib import TestData, make_test_finder
 from tests.lib.index import make_mock_candidate
 
 T = TypeVar("T")
@@ -50,8 +55,94 @@ def make_fake_dist(
     return klass(metadata)  # type: ignore[call-arg]
 
 
-class TestCheckDistRequiresPython:
+def make_test_resolver(
+    monkeypatch: pytest.MonkeyPatch,
+    mock_candidates: List[InstallationCandidate],
+) -> Resolver:
+    def _find_candidates(project_name: str) -> List[InstallationCandidate]:
+        return mock_candidates
+
+    finder = make_test_finder()
+    monkeypatch.setattr(finder, "find_all_candidates", _find_candidates)
+
+    return Resolver(
+        finder=finder,
+        preparer=mock.Mock(),  # Not used.
+        make_install_req=install_req_from_line,
+        wheel_cache=None,
+        use_user_site=False,
+        force_reinstall=False,
+        ignore_dependencies=False,
+        ignore_installed=False,
+        ignore_requires_python=False,
+        upgrade_strategy="to-satisfy-only",
+    )
+
 
+class TestAddRequirement:
+    """
+    Test _add_requirement_to_set().
+    """
+
+    def test_unsupported_wheel_link_requirement_raises(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # GIVEN
+        resolver = make_test_resolver(monkeypatch, [])
+        requirement_set = RequirementSet(check_supported_wheels=True)
+
+        install_req = install_req_from_line(
+            "https://whatever.com/peppercorn-0.4-py2.py3-bogus-any.whl",
+        )
+        assert install_req.link is not None
+        assert install_req.link.is_wheel
+        assert install_req.link.scheme == "https"
+
+        # WHEN / THEN
+        with pytest.raises(InstallationError):
+            resolver._add_requirement_to_set(requirement_set, install_req)
+
+    def test_unsupported_wheel_local_file_requirement_raises(
+        self, data: TestData, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # GIVEN
+        resolver = make_test_resolver(monkeypatch, [])
+        requirement_set = RequirementSet(check_supported_wheels=True)
+
+        install_req = install_req_from_line(
+            data.packages.joinpath("simple.dist-0.1-py1-none-invalid.whl"),
+        )
+        assert install_req.link is not None
+        assert install_req.link.is_wheel
+        assert install_req.link.scheme == "file"
+
+        # WHEN / THEN
+        with pytest.raises(InstallationError):
+            resolver._add_requirement_to_set(requirement_set, install_req)
+
+    def test_exclusive_environment_markers(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Make sure excluding environment markers are handled correctly."""
+        # GIVEN
+        resolver = make_test_resolver(monkeypatch, [])
+        requirement_set = RequirementSet(check_supported_wheels=True)
+
+        eq36 = install_req_from_line("Django>=1.6.10,<1.7 ; python_version == '3.6'")
+        eq36.user_supplied = True
+        ne36 = install_req_from_line("Django>=1.6.10,<1.8 ; python_version != '3.6'")
+        ne36.user_supplied = True
+
+        # WHEN
+        resolver._add_requirement_to_set(requirement_set, eq36)
+        resolver._add_requirement_to_set(requirement_set, ne36)
+
+        # THEN
+        assert requirement_set.has_requirement("Django")
+        assert len(requirement_set.all_requirements) == 1
+
+
+class TestCheckDistRequiresPython:
     """
     Test _check_dist_requires_python().
     """
@@ -179,30 +270,6 @@ class TestYankedWarning:
     Test _populate_link() emits warning if one or more candidates are yanked.
     """
 
-    def _make_test_resolver(
-        self,
-        monkeypatch: pytest.MonkeyPatch,
-        mock_candidates: List[InstallationCandidate],
-    ) -> Resolver:
-        def _find_candidates(project_name: str) -> List[InstallationCandidate]:
-            return mock_candidates
-
-        finder = make_test_finder()
-        monkeypatch.setattr(finder, "find_all_candidates", _find_candidates)
-
-        return Resolver(
-            finder=finder,
-            preparer=mock.Mock(),  # Not used.
-            make_install_req=install_req_from_line,
-            wheel_cache=None,
-            use_user_site=False,
-            force_reinstall=False,
-            ignore_dependencies=False,
-            ignore_installed=False,
-            ignore_requires_python=False,
-            upgrade_strategy="to-satisfy-only",
-        )
-
     def test_sort_best_candidate__has_non_yanked(
         self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
     ) -> None:
@@ -219,7 +286,7 @@ class TestYankedWarning:
         ]
         ireq = install_req_from_line("pkg")
 
-        resolver = self._make_test_resolver(monkeypatch, candidates)
+        resolver = make_test_resolver(monkeypatch, candidates)
         resolver._populate_link(ireq)
 
         assert ireq.link == candidates[0].link
@@ -243,7 +310,7 @@ class TestYankedWarning:
         ]
         ireq = install_req_from_line("pkg")
 
-        resolver = self._make_test_resolver(monkeypatch, candidates)
+        resolver = make_test_resolver(monkeypatch, candidates)
         resolver._populate_link(ireq)
 
         assert ireq.link == candidates[1].link
@@ -287,7 +354,7 @@ class TestYankedWarning:
         ]
         ireq = install_req_from_line("pkg")
 
-        resolver = self._make_test_resolver(monkeypatch, candidates)
+        resolver = make_test_resolver(monkeypatch, candidates)
         resolver._populate_link(ireq)
 
         assert ireq.link == candidates[0].link