Skip to content
Open
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
59 changes: 58 additions & 1 deletion src/poetry/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,25 @@ def _configure_package_dependencies(
cls._add_package_group_dependencies(
package=package,
group=group,
dependencies=group_config["dependencies"],
dependencies=group_config.get("dependencies", {}),
)

for group_name, group_config in tool_poetry["group"].items():
if include_groups := group_config.get("include-groups", []):
current_group = package.dependency_group(group_name)
for name in include_groups:
try:
# `name` isn't normalized, but `.dependency_group()`
# handles that.
group_to_include = package.dependency_group(name)
except ValueError as e:
raise ValueError(
f"Group '{group_name}' includes group '{name}'"
" which is not defined."
) from e

current_group.include_dependency_group(group_to_include)

if with_groups and "dev-dependencies" in tool_poetry:
cls._add_package_group_dependencies(
package=package,
Expand Down Expand Up @@ -614,6 +630,8 @@ def validate(
' Use "poetry.group.dev.dependencies" instead.'
)

cls._validate_dependency_groups_includes(toml_data, result)

if strict:
# Validate relation between [project] and [tool.poetry]
cls._validate_legacy_vs_project(toml_data, result)
Expand All @@ -622,6 +640,45 @@ def validate(

return result

@classmethod
def _validate_dependency_groups_includes(
cls, toml_data: dict[str, Any], result: dict[str, list[str]]
) -> None:
"""Ensure that dependency groups do not include themselves."""
config = toml_data.setdefault("tool", {}).setdefault("poetry", {})

group_includes: dict[NormalizedName, list[NormalizedName]] = {}
for group_name, group_config in config.get("group", {}).items():
if include_groups := group_config.get("include-groups", []):
group_includes[canonicalize_name(group_name)] = [
canonicalize_name(name) for name in include_groups
]

for group_name in group_includes:
ancestors: defaultdict[NormalizedName, set[NormalizedName]] = defaultdict(set)
stack = [group_name]
while stack:
group = stack.pop()
current_ancestors = ancestors.get(group, set())
child_ancestors = current_ancestors.union({group})

for include in group_includes.get(group, []):
if include in child_ancestors:
result["errors"].append(
f"Cyclic dependency group include in {group_name}:"
f" {group} -> {include}"
)
# Avoid infinite loop; we've already found an error.
continue

# This must be a union with any existing known ancestors.
# Otherwise, we might accidentally miss a cycle (since we'd
# be tossing out known dependencies). That cycle will be
# caught when we use the share dependency as `group_name`,
# but best to be safe.
ancestors[include] = ancestors.get(include, set()).union(child_ancestors)
stack.append(include)

@classmethod
def _validate_legacy_vs_project(
cls, toml_data: dict[str, Any], result: dict[str, list[str]]
Expand Down
20 changes: 18 additions & 2 deletions src/poetry/core/json/schemas/poetry-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,30 @@
"^[a-zA-Z-_.0-9]+$": {
"type": "object",
"description": "This represents a single dependency group",
"required": [
"dependencies"
"anyOf": [
{
"required": [
"dependencies"
]
},
{
"required": [
"include-groups"
]
}
],
"properties": {
"optional": {
"type": "boolean",
"description": "Whether the dependency group is optional or not"
},
"include-groups": {
"type": "array",
"description": "List of dependency group names included in this group.",
"items": {
"type": "string"
}
},
"dependencies": {
"type": "object",
"description": "The dependencies of this dependency group",
Expand Down
11 changes: 6 additions & 5 deletions src/poetry/core/packages/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def __init__(
if not groups:
groups = [MAIN_GROUP]

self._groups = frozenset(canonicalize_name(g) for g in groups)
self.groups = frozenset(canonicalize_name(g) for g in groups)
self._allows_prereleases = allows_prereleases
# "_develop" is only required for enriching [project] dependencies
self._develop = False
Expand Down Expand Up @@ -115,10 +115,6 @@ def pretty_constraint(self) -> str:
def pretty_name(self) -> str:
return self._pretty_name

@property
def groups(self) -> frozenset[NormalizedName]:
return self._groups

@property
def python_versions(self) -> str:
return self._python_versions
Expand Down Expand Up @@ -332,6 +328,11 @@ def with_constraint(self: T, constraint: str | VersionConstraint) -> T:
dependency.constraint = constraint # type: ignore[assignment]
return dependency

def with_groups(self, groups: Iterable[str]) -> Dependency:
dependency = self.clone()
dependency.groups = frozenset(canonicalize_name(g) for g in groups)
return dependency

@classmethod
def create_from_pep_508(
cls, name: str, relative_to: Path | None = None
Expand Down
105 changes: 68 additions & 37 deletions src/poetry/core/packages/dependency_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(
self._mixed_dynamic = mixed_dynamic
self._dependencies: list[Dependency] = []
self._poetry_dependencies: list[Dependency] = []
self._included_dependency_groups: dict[NormalizedName, DependencyGroup] = {}

@property
def name(self) -> NormalizedName:
Expand All @@ -37,59 +38,79 @@ def pretty_name(self) -> str:

@property
def dependencies(self) -> list[Dependency]:
if not self._dependencies:
group_dependencies = self._dependencies
included_group_dependencies = self._resolve_included_dependency_groups(dependencies_for_locking=False)

if not group_dependencies:
# legacy mode
return self._poetry_dependencies
if self._mixed_dynamic and self._poetry_dependencies:
group_dependencies = self._poetry_dependencies
elif self._mixed_dynamic and self._poetry_dependencies:
if all(dep.is_optional() for dep in self._dependencies):
return [
group_dependencies = [
*self._dependencies,
*(d for d in self._poetry_dependencies if not d.is_optional()),
]
if all(not dep.is_optional() for dep in self._dependencies):
return [
elif all(not dep.is_optional() for dep in self._dependencies):
group_dependencies = [
*self._dependencies,
*(d for d in self._poetry_dependencies if d.is_optional()),
]
return self._dependencies

return group_dependencies + included_group_dependencies

@property
def dependencies_for_locking(self) -> list[Dependency]:
if not self._poetry_dependencies:
return self._dependencies
if not self._dependencies:
return self._poetry_dependencies
included_group_dependencies = self._resolve_included_dependency_groups(dependencies_for_locking=True)

poetry_dependencies_by_name = defaultdict(list)
for dep in self._poetry_dependencies:
poetry_dependencies_by_name[dep.name].append(dep)

dependencies = []
for dep in self.dependencies:
if dep.name in poetry_dependencies_by_name:
enriched = False
dep_marker = dep.marker
if dep.in_extras:
dep_marker = dep.marker.intersect(
parse_marker(
" or ".join(
f"extra == '{extra}'" for extra in dep.in_extras
if not self._poetry_dependencies:
dependencies = self._dependencies
elif not self._dependencies:
dependencies = self._poetry_dependencies
else:
poetry_dependencies_by_name = defaultdict(list)
for dep in self._poetry_dependencies:
poetry_dependencies_by_name[dep.name].append(dep)

dependencies = []
for dep in self.dependencies:
if dep.name in poetry_dependencies_by_name:
enriched = False
dep_marker = dep.marker
if dep.in_extras:
dep_marker = dep.marker.intersect(
parse_marker(
" or ".join(
f"extra == '{extra}'" for extra in dep.in_extras
)
)
)
)
for poetry_dep in poetry_dependencies_by_name[dep.name]:
marker = dep_marker.intersect(poetry_dep.marker)
if not marker.is_empty():
if marker == dep_marker:
marker = dep.marker
enriched = True
dependencies.append(_enrich_dependency(dep, poetry_dep, marker))
if not enriched:
for poetry_dep in poetry_dependencies_by_name[dep.name]:
marker = dep_marker.intersect(poetry_dep.marker)
if not marker.is_empty():
if marker == dep_marker:
marker = dep.marker
enriched = True
dependencies.append(
_enrich_dependency(dep, poetry_dep, marker)
)
if not enriched:
dependencies.append(dep)
else:
dependencies.append(dep)
else:
dependencies.append(dep)

return dependencies
return dependencies + included_group_dependencies

def _resolve_included_dependency_groups(self, dependencies_for_locking: bool = False) -> list[Dependency]:
"""Resolves and returns the dependencies from included dependency groups.

This method iterates over all included dependency groups and collects
their dependencies, associating them with the current group.
"""
return [
dependency.with_groups([self.name])
for dependency_group in self._included_dependency_groups.values()
for dependency in (dependency_group.dependencies_for_locking if dependencies_for_locking else dependency_group.dependencies)
]

def is_optional(self) -> bool:
return self._optional
Expand Down Expand Up @@ -122,6 +143,16 @@ def remove_dependency(self, name: str) -> None:
dependencies.append(dependency)
self._poetry_dependencies = dependencies

def include_dependency_group(self, dependency_group: DependencyGroup) -> None:
if dependency_group.name == self.name:
raise ValueError("Cannot include the dependency group to itself.")
if dependency_group.name in self._included_dependency_groups:
raise ValueError(
f"Dependency group {dependency_group.pretty_name} is already included"
)

self._included_dependency_groups[dependency_group.name] = dependency_group

def __eq__(self, other: object) -> bool:
if not isinstance(other, DependencyGroup):
return NotImplemented
Expand Down
83 changes: 83 additions & 0 deletions tests/packages/test_dependency_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,86 @@ def test_dependency_group_use_canonicalize_name(
group = DependencyGroup(pretty_name)
assert group.name == canonicalized_name
assert group.pretty_name == pretty_name


def test_include_dependency_groups() -> None:
group = DependencyGroup(name="group")
group.add_poetry_dependency(
Dependency(name="foo", constraint="*", groups=["group"])
)

group_2 = DependencyGroup(name="group2")
group_2.add_poetry_dependency(
Dependency(name="bar", constraint="*", groups=["group2"])
)

# This will resolve to a more precise constraint in dependencies_for_locking.
group_3 = DependencyGroup(name="group3")
group_3._dependencies = [
Dependency(name="baz", constraint="<2", groups=["group3"])
]
group_3._poetry_dependencies = [
Dependency(name="baz", constraint=">=1", groups=["group3"])
]

group.include_dependency_group(group_2)
group.include_dependency_group(group_3)

assert [dep.name for dep in group.dependencies] == ["foo", "bar", "baz"]
assert [dep.name for dep in group.dependencies_for_locking] == ["foo", "bar", "baz"]
assert [dep.pretty_constraint for dep in group.dependencies] == ["*", "*", "<2"]
assert [dep.pretty_constraint for dep in group.dependencies_for_locking] == [
"*", "*", ">=1,<2"
]
for dep in group.dependencies:
assert dep.groups == {"group"}

assert [dep.name for dep in group_2.dependencies] == ["bar"]
assert [dep.name for dep in group_2.dependencies_for_locking] == ["bar"]
for dep in group_2.dependencies:
assert dep.groups == {"group2"}

assert [dep.name for dep in group_3.dependencies] == ["baz"]
assert [dep.name for dep in group_3.dependencies_for_locking] == ["baz"]
assert [dep.pretty_constraint for dep in group_3.dependencies] == ["<2"]
assert [dep.pretty_constraint for dep in group_3.dependencies_for_locking] == [">=1,<2"]
for dep in group_3.dependencies:
assert dep.groups == {"group3"}


@pytest.mark.parametrize("group_name", ["group_2", "Group-2", "group-2"])
def test_include_dependency_group_raise_if_including_itself(group_name: str) -> None:
group = DependencyGroup(name="group-2")
group.add_poetry_dependency(
Dependency(name="foo", constraint="*", groups=["group"])
)

with pytest.raises(
ValueError, match="Cannot include the dependency group to itself"
):
group.include_dependency_group(DependencyGroup(name=group_name))


@pytest.mark.parametrize("group_name", ["group_2", "Group-2", "group-2"])
def test_include_dependency_group_raise_if_already_included(group_name: str) -> None:
group = DependencyGroup(name="group")
group.add_poetry_dependency(
Dependency(name="foo", constraint="*", groups=["group"])
)

group_2 = DependencyGroup(name="group_2")
group_2.add_poetry_dependency(
Dependency(name="bar", constraint="*", groups=["group2"])
)

group.include_dependency_group(group_2)

group_3 = DependencyGroup(name=group_name)
group_3.add_poetry_dependency(
Dependency(name="bar", constraint="*", groups=["group2"])
)

with pytest.raises(
ValueError, match=f"Dependency group {group_name} is already included"
):
group.include_dependency_group(group_3)
Loading