Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/10216.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions news/14101.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Support mixing regular and editable requirements to the same location by
prioritizing the editable requirement.
5 changes: 4 additions & 1 deletion src/pip/_internal/resolution/resolvelib/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
Expand Down
15 changes: 15 additions & 0 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
254 changes: 254 additions & 0 deletions tests/functional/test_new_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
[
Expand Down
Loading