Skip to content
Merged
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
51 changes: 50 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,37 @@ 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 root in group_includes:
# group, path to group, ancestors
stack: list[
tuple[NormalizedName, list[NormalizedName], set[NormalizedName]]
] = [(root, [], {root})]
while stack:
group, path, ancestors = stack.pop()
for include in group_includes.get(group, []):
new_path = [*path, include]
if include in ancestors:
result["errors"].append(
f"Cyclic dependency group include in {root}:"
f" {' -> '.join(new_path)}"
)
else:
stack.append((include, new_path, ancestors | {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
13 changes: 7 additions & 6 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 Expand Up @@ -504,7 +505,7 @@ def _make_file_or_dir_dep(
Helper function to create a file or directoru dependency with the given arguments.

If path is not a file or directory that exists, a guess is made based on the suffix
of the given path. This is done to prevent dependendencies from being parsed as normal
of the given path. This is done to prevent dependencies from being parsed as normal
dependencies. This allows for downstream error handling.

See also: poetry#10068
Expand Down
115 changes: 78 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,89 @@ def pretty_name(self) -> str:

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

if not dependencies:
# legacy mode
return self._poetry_dependencies
if self._mixed_dynamic and self._poetry_dependencies:
dependencies = self._poetry_dependencies
elif self._mixed_dynamic and self._poetry_dependencies:
if all(dep.is_optional() for dep in self._dependencies):
return [
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):
dependencies = [
*self._dependencies,
*(d for d in self._poetry_dependencies if d.is_optional()),
]
return self._dependencies

return 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

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

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 +153,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
12 changes: 12 additions & 0 deletions tests/packages/test_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ def test_with_constraint() -> None:

assert new.name == dependency.name
assert str(new.constraint) == ">=1.2.6,<2.0.0"
assert str(dependency.constraint) == ">=1.2.3,<2.0.0"
assert new.is_optional()
assert new.groups == frozenset(["dev"])
assert new.allows_prereleases()
Expand All @@ -348,6 +349,17 @@ def test_with_constraint() -> None:
assert new.python_constraint == dependency.python_constraint


def test_with_groups() -> None:
dependency = Dependency("foo", "^1.2.3", groups=["DEV"])

new = dependency.with_groups(["DOC", "test"])

assert new.name == dependency.name
assert str(dependency.constraint) == ">=1.2.3,<2.0.0"
assert new.groups == frozenset(["doc", "test"])
assert dependency.groups == frozenset(["dev"])


@pytest.mark.parametrize(
"marker, expected",
[
Expand Down
Loading