Repository: pypa/pip

Issue:
Pip install local with extra fails dependency resolution
### Description

Let's say we have a package that we install locally via pip (the version name is generated something like 0.20.0.dev48+g4d91197). Then we install the same package locally, but with the indication extra. Pip in this case refuses to install the package due to an error: could not find a version that satisfies the requirement

### Expected behavior

For example, take the gitlint repository: https://github.com/jorisroovers/gitlint

If you use it as a pre-commit, the following will happen: pip install . ./gitlint-core[trusted-deps]

When installing gitlint itself (pip install .) gitlint-core is being installed. Then the installation of gitlint-core[trusted-deps] (pip install ./gitlint-core[trusted-deps]) starts and fails.

pip-23.2.1 - works as needed
pip-23.3.1 - error

### pip version

23.3.1

### Python version

Python 3.11.0

### OS

mac os m1

### How to Reproduce

1) git clone https://github.com/jorisroovers/gitlint.git
2) cd gitlint
3) pip install . ./gitlint-core[trusted-deps]

### Output

Processing /Users/ilyakochankov/Yandex.Disk.localized/Programming/gitlint
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Processing ./gitlint-core
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
INFO: pip is looking at multiple versions of gitlint to determine which version is compatible with other requirements. This could take a while.
ERROR: Could not find a version that satisfies the requirement gitlint-core==0.20.0.dev48+g4d91197 (from gitlint) (from versions: 0.17.0, 0.18.0, 0.19.0.dev73, 0.19.0.dev75, 0.19.0.dev76, 0.19.0.dev77, 0.19.0.dev78, 0.19.0.dev79, 0.19.0.dev80, 0.19.0.dev81, 0.19.0.dev82, 0.19.0rc1, 0.19.0rc2.dev1, 0.19.0rc2, 0.19.0rc3.dev1, 0.19.0, 0.19.1.dev1, 0.19.1.dev2, 0.19.1, 0.19.2.dev1, 0.19.2.dev2, 0.19.2.dev3, 0.19.2.dev4, 0.19.2.dev5, 0.20.0.dev2, 0.20.0.dev3, 0.20.0.dev6, 0.20.0.dev7, 0.20.0.dev8, 0.20.0.dev9, 0.20.0.dev10, 0.20.0.dev11, 0.20.0.dev12, 0.20.0.dev13, 0.20.0.dev14, 0.20.0.dev15, 0.20.0.dev16, 0.20.0.dev17, 0.20.0.dev18, 0.20.0.dev19, 0.20.0.dev20, 0.20.0.dev21, 0.20.0.dev22, 0.20.0.dev23, 0.20.0.dev24, 0.20.0.dev25, 0.20.0.dev27, 0.20.0.dev29, 0.20.0.dev30, 0.20.0.dev31, 0.20.0.dev32, 0.20.0.dev33, 0.20.0.dev34, 0.20.0.dev35, 0.20.0.dev36, 0.20.0.dev37, 0.20.0.dev38, 0.20.0.dev39, 0.20.0.dev40, 0.20.0.dev41, 0.20.0.dev42, 0.20.0.dev43, 0.20.0.dev44, 0.20.0.dev45, 0.20.0.dev48)
ERROR: No matching distribution found for gitlint-core==0.20.0.dev48+g4d91197

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Discussion:
https://github.com/jorisroovers/gitlint/issues/535
@uranusjr @sanderr This seems like it could this be a pip 23.3 regression from either #12002 or #12095?
Tentatively added to the 23.3 milestone.
Seems to be a regression in #12095 (reproduced issue on merge commit b551c09c4, while its parent dfaac0a68 doesn't suffer from it). I don't immediately see how yet but I'll have a more in-depth look.
I have a PoC level fix available [here](https://github.com/pypa/pip/compare/main...sanderr:pip:issue/12373-bugfix-install-local-extra). I haven't considered all consequences yet and it needs some cleaning up, but could you already give it a shot and see if it resolves your issue?

You might also be interested to know that it seems to work fine if you swap the order of the install specifiers, i.e. `pip download ./gitlint-core/[trusted-deps] .`

EDIT: cleaned it up, just need to add a test case and verify that it didn't break anything.
@KochankovID Have you had a chance to try out @sanderr's proposed fix? 

To install that branch, run:
`pip install https://github.com/sanderr/pip/archive/refs/heads/issue/12373-bugfix-install-local-extra.zip`
@edmorley I tried - fixed it! 🔥

Processing /Users/ilyakochankov/gitlint
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Processing ./gitlint-core
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... done
Requirement already satisfied: arrow>=1 in /Users/ilyakochankov/.pyenv/versions/3.11.0/lib/python3.11/site-packages (from gitlint-core==0.20.0.dev48+g4d91197) (1.2.3)
Requirement already satisfied: click>=8 in /Users/ilyakochankov/.pyenv/versions/3.11.0/lib/python3.11/site-packages (from gitlint-core==0.20.0.dev48+g4d91197) (8.1.7)
Requirement already satisfied: python-dateutil>=2.7.0 in /Users/ilyakochankov/.pyenv/versions/3.11.0/lib/python3.11/site-packages (from arrow>=1->gitlint-core==0.20.0.dev48+g4d91197) (2.8.2)
Requirement already satisfied: six>=1.5 in /Users/ilyakochankov/.pyenv/versions/3.11.0/lib/python3.11/site-packages (from python-dateutil>=2.7.0->arrow>=1->gitlint-core==0.20.0.dev48+g4d91197) (1.16.0)
Building wheels for collected packages: gitlint, gitlint-core
  Building wheel for gitlint (pyproject.toml) ... done
  Created wheel for gitlint: filename=gitlint-0.20.0.dev48+g4d91197-py3-none-any.whl size=2882 sha256=7711f00a4d7746e76609f581fe7543a2049e6f3ced2a5eeb22135b0a69785a9d
  Stored in directory: /private/var/folders/tl/2sfc4zys07sfhnn129zy0l4c0000gn/T/pip-ephem-wheel-cache-5pbuliek/wheels/b9/ec/06/afe4f2473f03366d2fe7654a91eeaf889fde03c8fab404d8c5
  Building wheel for gitlint-core (pyproject.toml) ... done
  Created wheel for gitlint-core: filename=gitlint_core-0.20.0.dev48+g4d91197-py3-none-any.whl size=44605 sha256=81c8c224d47424413e0a05727e32f141e5fcf352978dccc67f78cc6e4083b038
  Stored in directory: /Users/ilyakochankov/Library/Caches/pip/wheels/4b/2f/d8/7cb0cb4eb04680eaaaaae9ace7367afc3dee4c88d5f64dd397
Successfully built gitlint gitlint-core
Installing collected packages: gitlint-core, gitlint
Successfully installed gitlint-0.20.0.dev48+g4d91197 gitlint-core-0.20.0.dev48+g4d91197

Resolving patch:
diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py
--- a/src/pip/_internal/resolution/resolvelib/factory.py
+++ b/src/pip/_internal/resolution/resolvelib/factory.py
@@ -36,7 +36,10 @@
 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.constructors import install_req_from_link_and_ireq
+from pip._internal.req.constructors import (
+    install_req_drop_extras,
+    install_req_from_link_and_ireq,
+)
 from pip._internal.req.req_install import (
     InstallRequirement,
     check_invalid_constraint_type,
@@ -176,6 +179,20 @@ def _make_candidate_from_link(
         name: Optional[NormalizedName],
         version: Optional[CandidateVersion],
     ) -> Optional[Candidate]:
+        base: Optional[BaseCandidate] = self._make_base_candidate_from_link(
+            link, template, name, version
+        )
+        if not extras or base is None:
+            return base
+        return self._make_extras_candidate(base, extras, comes_from=template)
+
+    def _make_base_candidate_from_link(
+        self,
+        link: Link,
+        template: InstallRequirement,
+        name: Optional[NormalizedName],
+        version: Optional[CandidateVersion],
+    ) -> Optional[BaseCandidate]:
         # TODO: Check already installed candidate, and use it if the link and
         # editable flag match.
 
@@ -204,7 +221,7 @@ def _make_candidate_from_link(
                     self._build_failures[link] = e
                     return None
 
-            base: BaseCandidate = self._editable_candidate_cache[link]
+            return self._editable_candidate_cache[link]
         else:
             if link not in self._link_candidate_cache:
                 try:
@@ -224,11 +241,7 @@ def _make_candidate_from_link(
                     )
                     self._build_failures[link] = e
                     return None
-            base = self._link_candidate_cache[link]
-
-        if not extras:
-            return base
-        return self._make_extras_candidate(base, extras, comes_from=template)
+            return self._link_candidate_cache[link]
 
     def _iter_found_candidates(
         self,
@@ -362,9 +375,8 @@ def _iter_candidates_from_constraints(
         """
         for link in constraint.links:
             self._fail_if_link_is_unsupported_wheel(link)
-            candidate = self._make_candidate_from_link(
+            candidate = self._make_base_candidate_from_link(
                 link,
-                extras=frozenset(),
                 template=install_req_from_link_and_ireq(link, template),
                 name=canonicalize_name(identifier),
                 version=None,
@@ -454,10 +466,10 @@ def _make_requirements_from_install_req(
         Returns requirement objects associated with the given InstallRequirement. In
         most cases this will be a single object but the following special cases exist:
             - the InstallRequirement has markers that do not apply -> result is empty
-            - the InstallRequirement has both a constraint and extras -> result is split
-                in two requirement objects: one with the constraint and one with the
-                extra. This allows centralized constraint handling for the base,
-                resulting in fewer candidate rejections.
+            - the InstallRequirement has both a constraint (or link) and extras
+                -> result is split in two requirement objects: one with the constraint
+                (or link) and one with the extra. This allows centralized constraint
+                handling for the base, resulting in fewer candidate rejections.
         """
         if not ireq.match_markers(requested_extras):
             logger.info(
@@ -471,10 +483,13 @@ def _make_requirements_from_install_req(
             yield SpecifierRequirement(ireq)
         else:
             self._fail_if_link_is_unsupported_wheel(ireq.link)
-            cand = self._make_candidate_from_link(
+            # Always make the link candidate for the base requirement to make it
+            # available to `find_candidates` for explicit candidate lookup for any
+            # set of extras.
+            # The extras are required separately via a second requirement.
+            cand = self._make_base_candidate_from_link(
                 ireq.link,
-                extras=frozenset(ireq.extras),
-                template=ireq,
+                template=install_req_drop_extras(ireq) if ireq.extras else ireq,
                 name=canonicalize_name(ireq.name) if ireq.name else None,
                 version=None,
             )
@@ -489,7 +504,13 @@ def _make_requirements_from_install_req(
                     raise self._build_failures[ireq.link]
                 yield UnsatisfiableRequirement(canonicalize_name(ireq.name))
             else:
+                # require the base from the link
                 yield self.make_requirement_from_candidate(cand)
+                if ireq.extras:
+                    # require the extras on top of the base candidate
+                    yield self.make_requirement_from_candidate(
+                        self._make_extras_candidate(cand, frozenset(ireq.extras))
+                    )
 
     def collect_root_requirements(
         self, root_ireqs: List[InstallRequirement]


Test patch:
diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py
--- a/tests/functional/test_install_reqs.py
+++ b/tests/functional/test_install_reqs.py
@@ -671,9 +671,9 @@ def test_install_distribution_union_with_versions(
         expect_error=(resolver_variant == "resolvelib"),
     )
     if resolver_variant == "resolvelib":
-        assert "Cannot install localextras[bar]" in result.stderr
-        assert ("localextras[bar] 0.0.1 depends on localextras 0.0.1") in result.stdout
-        assert ("localextras[baz] 0.0.2 depends on localextras 0.0.2") in result.stdout
+        assert "Cannot install localextras" in result.stderr
+        assert ("The user requested localextras 0.0.1") in result.stdout
+        assert ("The user requested localextras 0.0.2") in result.stdout
     else:
         assert (
             "Successfully installed LocalExtras-0.0.1 simple-3.0 singlemodule-0.0.1"
diff --git a/tests/functional/test_new_resolver.py b/tests/functional/test_new_resolver.py
--- a/tests/functional/test_new_resolver.py
+++ b/tests/functional/test_new_resolver.py
@@ -2406,6 +2406,51 @@ def test_new_resolver_respect_user_requested_if_extra_is_installed(
     script.assert_installed(pkg3="1.0", pkg2="2.0", pkg1="1.0")
 
 
+def test_new_resolver_constraint_on_link_with_extra(
+    script: PipTestEnvironment,
+) -> None:
+    """
+    Verify that installing works from a link with both an extra and a constraint.
+    """
+    wheel: pathlib.Path = create_basic_wheel_for_package(
+        script, "pkg", "1.0", extras={"ext": []}
+    )
+
+    script.pip(
+        "install",
+        "--no-cache-dir",
+        # no index, no --find-links: only the explicit path
+        "--no-index",
+        f"{wheel}[ext]",
+        "pkg==1",
+    )
+    script.assert_installed(pkg="1.0")
+
+
+def test_new_resolver_constraint_on_link_with_extra_indirect(
+    script: PipTestEnvironment,
+) -> None:
+    """
+    Verify that installing works from a link with an extra if there is an indirect
+    dependency on that same package with the same extra (#12372).
+    """
+    wheel_one: pathlib.Path = create_basic_wheel_for_package(
+        script, "pkg1", "1.0", extras={"ext": []}
+    )
+    wheel_two: pathlib.Path = create_basic_wheel_for_package(
+        script, "pkg2", "1.0", depends=["pkg1[ext]==1.0"]
+    )
+
+    script.pip(
+        "install",
+        "--no-cache-dir",
+        # no index, no --find-links: only the explicit path
+        wheel_two,
+        f"{wheel_one}[ext]",
+    )
+    script.assert_installed(pkg1="1.0", pkg2="1.0")
+
+
 def test_new_resolver_do_not_backtrack_on_build_failure(
     script: PipTestEnvironment,
 ) -> None: