diff --git a/news/10216.bugfix.rst b/news/10216.bugfix.rst new file mode 100644 index 0000000000..7175b77c3d --- /dev/null +++ b/news/10216.bugfix.rst @@ -0,0 +1,2 @@ +Allow installing a package as editable alongside another package that +depends on it, via a direct URL reference to the same location. diff --git a/news/14101.feature.rst b/news/14101.feature.rst new file mode 100644 index 0000000000..4902f33ef8 --- /dev/null +++ b/news/14101.feature.rst @@ -0,0 +1,2 @@ +Support mixing regular and editable requirements to the same location by +prioritizing the editable requirement. diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index 4bfd829610..9d8a40d1d7 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -169,7 +169,10 @@ def __init__( self._hash: int | None = None def __str__(self) -> str: - return f"{self.name} {self.version}" + if self.is_editable: + return f"{self.name} {self.version} (editable)" + else: + return f"{self.name} {self.version}" def __repr__(self) -> str: return f"{self.__class__.__name__}({str(self._link)!r})" diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index ff1dab54c3..834176083b 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -201,6 +201,7 @@ def _make_base_candidate_from_link( # Don't bother trying again. return None + # Editables are prioritized over matching regular direct URL requirements. if template.editable: if link not in self._editable_candidate_cache: try: @@ -221,6 +222,10 @@ def _make_base_candidate_from_link( self._build_failures[link] = e return None + return self._editable_candidate_cache[link] + # If there is a pre-existing editable candidate for this link, use it + # (even if the link comes from a direct non-editable requirement). + elif link in self._editable_candidate_cache: return self._editable_candidate_cache[link] else: if link not in self._link_candidate_cache: @@ -578,6 +583,16 @@ def _make_requirements_from_install_req( def collect_root_requirements( self, root_ireqs: list[InstallRequirement] ) -> CollectedRootRequirements: + # Move editable requirements to the front since they take priority. + # By processing editables first, matching direct URL requirements will + # be sastified by the editable candidate (avoiding a dependency + # conflict and unnecessary non-editable backend calls). + for ireq in reversed(root_ireqs[:]): + if ireq.editable and not ireq.constraint: + assert ireq.link is not None, f"editable must have link: {ireq.link=}" + root_ireqs.remove(ireq) + root_ireqs.insert(0, ireq) + collected = CollectedRootRequirements([], {}, {}) for i, ireq in enumerate(root_ireqs): if ireq.constraint: diff --git a/tests/functional/test_new_resolver.py b/tests/functional/test_new_resolver.py index 38da7ef288..f0966930c0 100644 --- a/tests/functional/test_new_resolver.py +++ b/tests/functional/test_new_resolver.py @@ -3,6 +3,7 @@ import sys import textwrap from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING, Protocol import pytest @@ -329,6 +330,259 @@ def test_new_resolver_installs_editable(script: PipTestEnvironment) -> None: script.assert_installed_editable("dep") +def test_new_resolver_editable_satisfies_direct_url_dep( + script: PipTestEnvironment, +) -> None: + """Regression test for https://github.com/pypa/pip/issues/10216. + + Installing ``-e A -e B`` used to fail with ``ResolutionImpossible`` when ``A`` + depends on ``B`` via a direct URL (PEP 440) reference pointing at the same + location as the editable ``B``: + + > Cannot install package-a and package-b because these package versions have + > conflicting dependencies. + + This was because the resolver created both an ``EditableCandidate`` (from the + user-supplied ``-e B``) and a ``LinkCandidate`` (from ``A``'s transitive + direct-URL dependency) for the same link, and treated them as conflicting. + """ + dep_path = create_test_package_with_setup(script, name="dep", version="0.1.0") + dep_url = dep_path.as_uri() + base_path = script.scratch_path / "base" + base_path.mkdir() + base_path.joinpath("setup.py").write_text(textwrap.dedent(f""" + from setuptools import setup + setup( + name="base", + version="0.1.0", + install_requires=["dep @ {dep_url}"], + ) + """)) + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + "-e", + base_path, + "-e", + dep_path, + ) + script.assert_installed(base="0.1.0", dep="0.1.0") + script.assert_installed_editable("base") + script.assert_installed_editable("dep") + + +@pytest.mark.xfail +def test_new_resolver_editable_satisfies_equivalent_direct_url_dep( + script: PipTestEnvironment, +) -> None: + """ + Regression test for https://github.com/pypa/pip/issues/10216. + + Similar to test_new_resolver_editable_satisfies_direct_url_dep but + the direct URL is not an exact match. + """ + dep_path = create_test_package_with_setup(script, name="dep", version="0.1") + base_path = create_test_package_with_setup( + script, + name="base", + version="0.1", + install_requires=[f"dep @ {_to_localhost_uri(dep_path)}"], + ) + + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + "-e", + dep_path, + "-e", + base_path, + ) + script.assert_installed(base="0.1", dep="0.1") + script.assert_installed_editable("base") + script.assert_installed_editable("dep") + + +@pytest.mark.xfail +@pytest.mark.parametrize("is_editable_relative", [True, False]) +def test_new_resolver_relative_editable_satisfies_equivalent_direct_url_dep( + script: PipTestEnvironment, is_editable_relative: bool +) -> None: + """ + Regression test for https://github.com/pypa/pip/issues/10216. + + Similar to test_new_resolver_editable_satisfies_direct_url_dep but + but the file direct URL dependency is relative. + """ + dep_path = create_test_package_with_setup(script, name="dep", version="0.1") + relative_dep_path = dep_path.relative_to(script.scratch_path) + base_path = create_test_package_with_setup( + script, + name="base", + version="0.1", + install_requires=[f"dep @ file:{relative_dep_path.as_posix()}"], + ) + + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + "-e", + relative_dep_path if is_editable_relative else dep_path, + "-e", + base_path, + ) + script.assert_installed(base="0.1", dep="0.1") + script.assert_installed_editable("base") + script.assert_installed_editable("dep") + + +def test_new_resolver_editable_priority_preserves_top_level_regular_extras( + script: PipTestEnvironment, +) -> None: + extra_dep_path = create_test_package_with_setup( + script, name="extra_dep", version="0.1.0" + ) + pkg_path = create_test_package_with_setup( + script, + name="pkg", + version="0.1.0", + extras_require={"extra": [f"extra_dep @ {extra_dep_path.as_uri()}"]}, + ) + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + f"pkg[extra] @ {pkg_path.as_uri()}", + "-e", + pkg_path, + ) + script.assert_installed(pkg="0.1.0", extra_dep="0.1.0") + script.assert_installed_editable("pkg") + + +def test_new_resolver_editable_priority_preserves_regular_extras_dep( + script: PipTestEnvironment, +) -> None: + """Extras from a regular direct URL requirement should be preserved + even if the requirement was sastified by an editable to the same location. + """ + extra_path = create_test_package_with_setup(script, name="extra", version="0.1.0") + dep_path = create_test_package_with_setup( + script, + name="dep", + version="0.1.0", + extras_require={"extra": [f"extra @ {extra_path.as_uri()}"]}, + ) + root_path = script.scratch_path / "root" + root_path.mkdir() + root_path.joinpath("setup.py").write_text(textwrap.dedent(f""" + from setuptools import setup + setup( + name="root", + version="0.1.0", + install_requires=["dep[extra] @ {dep_path.as_uri()}"], + ) + """)) + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + root_path, + "-e", + dep_path, + ) + script.assert_installed(root="0.1.0", dep="0.1.0", extra="0.1.0") + script.assert_installed_editable("dep") + + +@pytest.mark.parametrize("use_direct_url", [False, True]) +def test_new_resolver_editable_priority_over_top_level_regular_requirement( + use_direct_url: bool, script: PipTestEnvironment +) -> None: + """ + pip install -e pkg pkg should install pkg in editable mode without + building pkg as a wheel. + + This is different from editable_satisfies_direct_url_dep since the + regular requirement is user provided (and thus resolved much earlier). + """ + pkg_path = create_test_package_with_setup(script, name="pkg", version="0.1.0") + pkg_url = pkg_path.as_uri() + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + f"pkg @ {pkg_url}" if use_direct_url else pkg_path, + "-e", + pkg_path, + ) + script.assert_installed(pkg="0.1.0") + script.assert_installed_editable("pkg") + + +@pytest.mark.xfail +def test_new_resolver_editable_priority_over_top_level_regular_relative_requirement( + script: PipTestEnvironment, +) -> None: + """ + pip install -e pkg file:pkg should install pkg in editable mode despite + the difference in scheme. + """ + pkg_path = create_test_package_with_setup(script, name="pkg", version="0.1.0") + relative_pkg_path = pkg_path.relative_to(script.scratch_path) + script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + f"file:{relative_pkg_path.as_posix()}", + "-e", + relative_pkg_path, + ) + script.assert_installed(pkg="0.1.0") + script.assert_installed_editable("pkg") + + +@pytest.mark.parametrize("use_req_file", [False, True]) +def test_new_resolver_editable_priority_over_top_level_regular_requirement_constraint( + use_req_file: bool, script: PipTestEnvironment +) -> None: + """ + pip install -e pkg pkg should install pkg in editable mode without + building pkg as a wheel, where pkg is constrained to the same URL. + """ + pkg_path = create_test_package_with_setup(script, name="pkg", version="0.1.0") + pkg_url = pkg_path.as_uri() + constraints_file = script.temporary_file("c.txt", f"pkg @ {pkg_url}") + req_file = script.temporary_file( + "r.txt", "\n".join([f"-e {pkg_path.as_posix()}", "pkg"]) + ) + + args: list[str | Path] = ( + ["-r", req_file] if use_req_file else ["pkg", "-e", pkg_path] + ) + result = script.pip( + "install", + "--no-build-isolation", + "--no-cache-dir", + "--no-index", + "--constraint", + constraints_file, + *args, + ) + script.assert_installed(pkg="0.1.0") + script.assert_installed_editable("pkg") + assert "Getting requirements to build wheel" not in result.stdout + + @pytest.mark.parametrize( "requires_python, ignore_requires_python, dep_version", [