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
7 changes: 4 additions & 3 deletions src/poetry/mixology/version_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ def _effective_marker(dependency: Dependency) -> BaseMarker:
The marker describing when a dependency actually applies: its own marker
intersected with the marker of the package that required it (a dependency is
only reached when its requiring package applies).

Extras in the transitive marker belong to the root marker world. Extras in
the dependency's own marker are local to its requiring package.
"""
return dependency.transitive_marker.without_extras().intersect(
dependency.marker.without_extras()
)
return dependency.transitive_marker.intersect(dependency.marker.without_extras())


class Preference(IntEnum):
Expand Down
56 changes: 38 additions & 18 deletions src/poetry/puzzle/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,11 +596,21 @@ def complete_package(
if dep.name in self.UNSAFE_PACKAGES:
continue

dependency_marker = dep.marker
if package.is_root() and dep.in_extras:
dependency_marker = dependency_marker.intersect(
parse_marker(
" or ".join(f'extra == "{extra}"' for extra in dep.in_extras)
)
)

# When this run is restricted to a set of markers (see MARKER_SPLIT),
# skip any dependency that cannot apply within that set; otherwise its
# requirements would leak into a run where it never applies (see
# #5506).
if self._overrides_marker_intersection.intersect(dep.marker).is_empty():
if self._overrides_marker_intersection.intersect(
dependency_marker
).is_empty():
continue

if self._env:
Expand Down Expand Up @@ -630,11 +640,7 @@ def complete_package(
# without an existing lock file because the root package is used
# once for solving and a second time for re-resolving for installation.
dep = dep.clone()
dep.marker = dep.marker.intersect(
parse_marker(
" or ".join(f'extra == "{extra}"' for extra in dep.in_extras)
)
)
dep.marker = dependency_marker

_dependencies.append(dep)

Expand Down Expand Up @@ -750,6 +756,21 @@ def complete_package(
self.debug(msg)
continue

if package.is_root():
marker = next(
(
dep.marker
for dep in deps
if dep.marker.without_extras() != dep.marker
),
None,
)
if marker is not None:
# Root extras are global lock markers. Carry their branch in
# the synthetic marker split instead of a package override,
# whose extras are deliberately local and removed on merge.
raise OverrideNeededError(*self.marker_split_overrides(marker))

# At this point, we raise an exception that will
# tell the solver to make new resolutions with specific overrides.
#
Expand Down Expand Up @@ -788,19 +809,18 @@ def fmt_warning(d: Dependency) -> str:
# Modifying dependencies as needed
clean_dependencies = []
for dep in dependencies:
if not dependency.transitive_marker.without_extras().is_any():
transitive_marker_intersection = (
dependency.transitive_marker.without_extras().intersect(
dep.marker.without_extras()
)
)
if transitive_marker_intersection.is_empty():
# The dependency is not needed, since the markers specified
# for the current package selection are not compatible with
# the markers for the current dependency, so we skip it
continue
transitive_marker = (
dep.marker
if package.is_root()
else dependency.transitive_marker.intersect(dep.marker.without_extras())
)
if transitive_marker.is_empty():
# The dependency is not needed, since the markers specified
# for the current package selection are not compatible with
# the markers for the current dependency, so we skip it
continue

dep.transitive_marker = transitive_marker_intersection
dep.transitive_marker = transitive_marker

if not dependency.python_constraint.is_any():
python_constraint_intersection = dep.python_constraint.intersect(
Expand Down
10 changes: 8 additions & 2 deletions src/poetry/puzzle/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from poetry.packages.transitive_package_info import TransitivePackageInfo
from poetry.puzzle.exceptions import OverrideNeededError
from poetry.puzzle.exceptions import SolverProblemError
from poetry.puzzle.provider import MARKER_SPLIT
from poetry.puzzle.provider import Indicator
from poetry.puzzle.provider import Provider

Expand Down Expand Up @@ -461,9 +462,14 @@ def merge_override_packages(
] = {}
for override, o_packages in override_packages:
override_marker: BaseMarker = AnyMarker()
for deps in override.values():
for override_package, deps in override.items():
for dep in deps.values():
override_marker = override_marker.intersect(dep.marker.without_extras())
marker = (
dep.marker
if override_package.name == MARKER_SPLIT
else dep.marker.without_extras()
)
override_marker = override_marker.intersect(marker)
override_marker = simplify_marker(override_marker, python_constraint)
for package, info in o_packages.items():
for group, marker in info.markers.items():
Expand Down
137 changes: 136 additions & 1 deletion tests/puzzle/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5439,13 +5439,148 @@ def test_solver_resolves_conflicting_dependency_in_root_extras(
)
solved_packages = transaction.get_solved_packages()
assert solved_packages[package_a1].markers[MAIN_GROUP] == parse_marker(
"extra != 'bar' and extra == 'foo'"
"extra == 'foo' and extra != 'bar'"
)
assert solved_packages[package_a2].markers[MAIN_GROUP] == parse_marker(
"extra != 'foo' and extra == 'bar'"
)


def _configure_transitive_extra_conflict(
package: ProjectPackage,
repo: Repository,
*,
disjoint_root_extras: bool,
) -> tuple[Package, Package]:
package_a = get_package("A", "1.0")
package_c1 = get_package("C", "1.0")
package_c2 = get_package("C", "2.0")
package_d = get_package("D", "1.0")
package_e = get_package("E", "1.0")

dep_c1 = get_dependency("C", "1.0", optional=True)
dep_c1._in_extras = [canonicalize_name("one")]
dep_c1.marker = parse_marker("extra == 'one'")
dep_c2 = get_dependency("C", "2.0", optional=True)
dep_c2._in_extras = [canonicalize_name("two")]
dep_c2.marker = parse_marker("extra == 'two'")
package_a.extras = {
canonicalize_name("one"): [dep_c1],
canonicalize_name("two"): [dep_c2],
}
package_a.add_dependency(dep_c1)
package_a.add_dependency(dep_c2)

package_d.add_dependency(get_dependency("A", {"version": "*", "extras": ["one"]}))
package_e.add_dependency(get_dependency("A", {"version": "*", "extras": ["two"]}))

if disjoint_root_extras:
dep_d = get_dependency(
"D", {"version": "*", "markers": "extra != 'y'"}, optional=True
)
dep_d._in_extras = [canonicalize_name("x")]
dep_e = get_dependency(
"E", {"version": "*", "markers": "extra != 'x'"}, optional=True
)
dep_e._in_extras = [canonicalize_name("y")]
package.extras = {
canonicalize_name("x"): [dep_d],
canonicalize_name("y"): [dep_e],
}
package.add_dependency(dep_d)
package.add_dependency(dep_e)
else:
package.add_dependency(get_dependency("D", "*"))
package.add_dependency(get_dependency("E", "*"))

for dependency_package in (
package_a,
package_c1,
package_c2,
package_d,
package_e,
):
repo.add_package(dependency_package)

return package_c1, package_c2


def test_solver_resolves_conflicting_dependency_in_transitive_extras(
package: ProjectPackage,
pool: RepositoryPool,
repo: Repository,
io: NullIO,
) -> None:
package_c1, package_c2 = _configure_transitive_extra_conflict(
package, repo, disjoint_root_extras=True
)

transaction = Solver(package, pool, [], [], io).solve()
solved_packages = transaction.get_solved_packages()

assert solved_packages[package_c1].markers[MAIN_GROUP] == parse_marker(
"extra == 'x' and extra != 'y'"
)
assert solved_packages[package_c2].markers[MAIN_GROUP] == parse_marker(
"extra != 'x' and extra == 'y'"
)


def test_solver_does_not_split_conflicting_transitive_extras_without_root_markers(
package: ProjectPackage,
pool: RepositoryPool,
repo: Repository,
io: NullIO,
) -> None:
_configure_transitive_extra_conflict(package, repo, disjoint_root_extras=False)

with pytest.raises(SolverProblemError):
Solver(package, pool, [], [], io).solve()


def test_solver_keeps_overlapping_root_extra_versions_disjoint(
package: ProjectPackage,
pool: RepositoryPool,
repo: Repository,
io: NullIO,
) -> None:
package_a1 = get_package("A", "1.0")
package_a2 = get_package("A", "2.0")
package_b = get_package("B", "1.0")
package_b.add_dependency(get_dependency("A", "*"))

dep_a_networks = get_dependency("A", "1.0", optional=True)
dep_a_networks._in_extras = [canonicalize_name("networks")]
dep_b_sbml = get_dependency("B", "*", optional=True)
dep_b_sbml._in_extras = [canonicalize_name("sbml")]
dep_a_all = get_dependency("A", "1.0", optional=True)
dep_a_all._in_extras = [canonicalize_name("all")]
dep_b_all = get_dependency("B", "*", optional=True)
dep_b_all._in_extras = [canonicalize_name("all")]

package.extras = {
canonicalize_name("networks"): [dep_a_networks],
canonicalize_name("sbml"): [dep_b_sbml],
canonicalize_name("all"): [dep_a_all, dep_b_all],
}
for dependency in (dep_a_networks, dep_b_sbml, dep_a_all, dep_b_all):
package.add_dependency(dependency)

repo.add_package(package_a1)
repo.add_package(package_a2)
repo.add_package(package_b)

transaction = Solver(package, pool, [], [], io).solve()
solved_packages = transaction.get_solved_packages()

assert solved_packages[package_a1].markers[MAIN_GROUP] == parse_marker(
"extra == 'networks' or extra == 'all'"
)
assert solved_packages[package_a2].markers[MAIN_GROUP] == parse_marker(
"extra == 'sbml' and extra != 'networks' and extra != 'all'"
)


@pytest.mark.parametrize("with_extra", [False, True])
def test_solver_resolves_duplicate_dependency_in_root_extra_for_installation(
package: ProjectPackage,
Expand Down