diff --git a/src/poetry/core/factory.py b/src/poetry/core/factory.py index eb2fb60eb..0fe82a92a 100644 --- a/src/poetry/core/factory.py +++ b/src/poetry/core/factory.py @@ -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, @@ -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) @@ -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]] diff --git a/src/poetry/core/json/schemas/poetry-schema.json b/src/poetry/core/json/schemas/poetry-schema.json index a746328a1..55385b92f 100644 --- a/src/poetry/core/json/schemas/poetry-schema.json +++ b/src/poetry/core/json/schemas/poetry-schema.json @@ -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", diff --git a/src/poetry/core/packages/dependency.py b/src/poetry/core/packages/dependency.py index 53ea864a0..88ed13573 100644 --- a/src/poetry/core/packages/dependency.py +++ b/src/poetry/core/packages/dependency.py @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/src/poetry/core/packages/dependency_group.py b/src/poetry/core/packages/dependency_group.py index 24dfded08..96be6d073 100644 --- a/src/poetry/core/packages/dependency_group.py +++ b/src/poetry/core/packages/dependency_group.py @@ -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: @@ -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 @@ -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 diff --git a/tests/packages/test_dependency.py b/tests/packages/test_dependency.py index 846732ba2..fe05ca229 100644 --- a/tests/packages/test_dependency.py +++ b/tests/packages/test_dependency.py @@ -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() @@ -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", [ diff --git a/tests/packages/test_dependency_group.py b/tests/packages/test_dependency_group.py index 065f16eed..ce1a36137 100644 --- a/tests/packages/test_dependency_group.py +++ b/tests/packages/test_dependency_group.py @@ -544,3 +544,103 @@ 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"} + + +def test_include_empty_dependency_group() -> None: + group_main = DependencyGroup(name="group") + group_main.add_poetry_dependency( + Dependency(name="foo", constraint="*", groups=["group"]) + ) + + empty_group = DependencyGroup(name="empty") + # Include empty dependency group + group_main.include_dependency_group(empty_group) + + # Assert that including an empty group does not affect group_main's dependencies. + assert [dep.name for dep in group_main.dependencies] == ["foo"] + assert [dep.name for dep in group_main.dependencies_for_locking] == ["foo"] + + +@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) diff --git a/tests/test_factory.py b/tests/test_factory.py index 2ddf2aad1..8866c6799 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1072,3 +1072,644 @@ def test_poetry_build_system_dependencies( poetry = Factory().create_poetry(temporary_directory) assert set(poetry.build_system_dependencies) == expected + + +@pytest.mark.parametrize("in_order", [True, False]) +@pytest.mark.parametrize( + ("group_name", "included_group_name"), + [ + ("testing", "testing"), + ("testing", "TESTING"), + ("group_a", "group-a"), + # Examples from the PEP 508 spec + # https://packaging.python.org/en/latest/specifications/name-normalization/#valid-non-normalized-names + ("friendly-bard", "friendly-bard"), + ("friendly-bard", "Friendly-Bard"), + ("friendly-bard", "FRIENDLY-BARD"), + ("friendly-bard", "friendly.bard"), + ("friendly-bard", "friendly_bard"), + ("friendly-bard", "friendly--bard"), + ("friendly-bard", "FrIeNdLy-._.-bArD"), + ("friendly-Bard", "friendly-bard"), + ("FRIENDLY-BARD", "friendly-bard"), + ("friendly.bard", "friendly-bard"), + ("friendly_bard", "friendly-bard"), + ("friendly--bard", "friendly-bard"), + ("FrIeNdLy-._.-bArD", "friendly-bard"), + ], +) +def test_create_poetry_with_nested_dependency_groups( + group_name: str, included_group_name: str, in_order: bool, temporary_directory: Path +) -> None: + pyproject_toml = temporary_directory / "pyproject.toml" + + replace_group_name = "%REPLACE_GROUP_NAME%" + replace_included_group_name = "%REPLACE_INCLUDED_GROUP_NAME%" + in_order_content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.%REPLACE_GROUP_NAME%.dependencies] +pytest = "*" +pytest-cov ="*" + +[tool.poetry.group.dev] +include-groups = [ + "%REPLACE_INCLUDED_GROUP_NAME%", +] +[tool.poetry.group.dev.dependencies] +black = "*" +""" + # The dev group refers to a group that is defined after it. + out_of_order_content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.dev] +include-groups = [ + "%REPLACE_INCLUDED_GROUP_NAME%", +] +[tool.poetry.group.dev.dependencies] +black = "*" + +[tool.poetry.group.%REPLACE_GROUP_NAME%.dependencies] +pytest = "*" +pytest-cov ="*" +""" + + # Generate the content. If `group_name` has a `.` in it, we "escape" it with + # quotes to make it a valid TOML key. + base_content = in_order_content if in_order else out_of_order_content + group_name_to_use = group_name if "." not in group_name else f'"{group_name}"' + content = base_content.replace(replace_group_name, group_name_to_use).replace( + replace_included_group_name, included_group_name + ) + + pyproject_toml.write_text(content) + poetry = Factory().create_poetry(temporary_directory) + + # Groups are reported internally using their canonical names. + canonical_name = canonicalize_name(group_name) + + assert len(poetry.package.all_requires) == 5 + assert sorted( + [(dep.name, ",".join(dep.groups)) for dep in poetry.package.all_requires], + key=lambda x: x[0] + x[1], + ) == sorted( + [ + ("black", "dev"), + ("pytest-cov", "dev"), + ("pytest-cov", canonical_name), + ("pytest", "dev"), + ("pytest", canonical_name), + ], + key=lambda x: x[0] + x[1], + ) + + +def assert_invalid_group_including( + toml_data: str, + expected_error: str, + error_type: type[Exception], + temporary_directory: Path, +) -> None: + pyproject_toml = temporary_directory / "pyproject.toml" + pyproject_toml.write_text(toml_data) + + with pytest.raises(error_type) as error: + _ = Factory().create_poetry(temporary_directory) + + assert str(error.value) == expected_error + + +@pytest.mark.parametrize( + "include_group_name", ["testing_group", "Testing-Group", "testing-group"] +) +def test_create_poetry_with_self_referenced_dependency_groups( + include_group_name: str, + temporary_directory: Path, +) -> None: + """testing-group -> testing-group""" + content = f"""\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.testing-group] +include-groups = [ + "{include_group_name}", +] + +[tool.poetry.group.testing-group.dependencies] +pytest = "*" +pytest-cov ="*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in testing-group: testing-group +""" + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_direct_cyclic_dependency_groups( + temporary_directory: Path, +) -> None: + """ + testing -> dev + dev -> testing + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.testing] +include-groups = [ + "dev", +] + +[tool.poetry.group.testing.dependencies] +pytest = "*" +pytest-cov ="*" + +[tool.poetry.group.dev] +include-groups = [ + "testing", +] +[tool.poetry.group.dev.dependencies] +black = "*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in testing: dev -> testing + - Cyclic dependency group include in dev: testing -> dev +""" + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_indirect_full_cyclic_dependency_groups( + temporary_directory: Path, +) -> None: + """ + group-1 -> group-3 + group-2 -> group-1 + group-3 -> group-2 + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.group_1] +include-groups = [ + "group_3", +] + +[tool.poetry.group.group_1.dependencies] +foo = "*" + +[tool.poetry.group.group_2] +include-groups = [ + "group_1", +] +[tool.poetry.group.group_2.dependencies] +bar = "*" + +[tool.poetry.group.group_3] +include-groups = [ + "group_2", +] +[tool.poetry.group.group_3.dependencies] +baz = "*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in group-1: group-3 -> group-2 -> group-1 + - Cyclic dependency group include in group-2: group-1 -> group-3 -> group-2 + - Cyclic dependency group include in group-3: group-2 -> group-1 -> group-3 +""" + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_indirect_partial_cyclic_dependency_groups( + temporary_directory: Path, +) -> None: + """ + group-1 -> group-2 + group-2 -> group-1 + group-3 -> group-2 + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.group_1] +include-groups = [ + "group_2", +] + +[tool.poetry.group.group_1.dependencies] +foo = "*" + +[tool.poetry.group.group_2] +include-groups = [ + "group_1", +] +[tool.poetry.group.group_2.dependencies] +bar = "*" + +[tool.poetry.group.group_3] +include-groups = [ + "group_2", +] +[tool.poetry.group.group_3.dependencies] +baz = "*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in group-1: group-2 -> group-1 + - Cyclic dependency group include in group-2: group-1 -> group-2 + - Cyclic dependency group include in group-3: group-2 -> group-1 -> group-2 +""" + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_shared_dependency_groups( + temporary_directory: Path, +) -> None: + """ + root -> child-1, child-2 + child-1 -> shared + child-2 -> shared + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.root] +include-groups = [ + "child_1", + "child_2", +] + +[tool.poetry.group.root.dependencies] +foo = "*" + +[tool.poetry.group.child_1] +include-groups = [ + "shared", +] +[tool.poetry.group.child_1.dependencies] +bar = "*" + +[tool.poetry.group.child_2] +include-groups = [ + "shared", +] +[tool.poetry.group.child_2.dependencies] +baz = "*" + +[tool.poetry.group.shared.dependencies] +quux = "*" +""" + pyproject_toml = temporary_directory / "pyproject.toml" + pyproject_toml.write_text(content) + poetry = Factory().create_poetry(temporary_directory) + + assert len(poetry.package.all_requires) == 10 + assert sorted( + [(dep.name, ",".join(dep.groups)) for dep in poetry.package.all_requires], + key=lambda x: x[0] + x[1], + ) == [ + ("bar", "child-1"), + ("bar", "root"), + ("baz", "child-2"), + ("baz", "root"), + ("foo", "root"), + ("quux", "child-1"), + ("quux", "child-2"), + # Duplicates because dependency is included via several groups. + # This is ok because they are merged during dependency resolution. + ("quux", "root"), + ("quux", "root"), + ("quux", "shared"), + ] + + +def test_create_poetry_with_shared_dependency_groups_more_complicated( + temporary_directory: Path, +) -> None: + """ + root -> child-1, child-2 + child-1 -> shared + child-2 -> grandchild + grandchild -> shared + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.root] +include-groups = [ + "child_1", + "child_2", +] + +[tool.poetry.group.root.dependencies] +foo = "*" + +[tool.poetry.group.child_1] +include-groups = [ + "shared", +] +[tool.poetry.group.child_1.dependencies] +bar = "*" + +[tool.poetry.group.child_2] +include-groups = [ + "grandchild", +] +[tool.poetry.group.child_2.dependencies] +baz = "*" + +[tool.poetry.group.grandchild] +include-groups = [ + "shared", +] +[tool.poetry.group.grandchild.dependencies] +bax = "*" + +[tool.poetry.group.shared.dependencies] +quux = "*" +""" + pyproject_toml = temporary_directory / "pyproject.toml" + pyproject_toml.write_text(content) + poetry = Factory().create_poetry(temporary_directory) + + assert len(poetry.package.all_requires) == 14 + assert sorted( + [(dep.name, ",".join(dep.groups)) for dep in poetry.package.all_requires], + key=lambda x: x[0] + x[1], + ) == [ + ("bar", "child-1"), + ("bar", "root"), + ("bax", "child-2"), + ("bax", "grandchild"), + ("bax", "root"), + ("baz", "child-2"), + ("baz", "root"), + ("foo", "root"), + ("quux", "child-1"), + ("quux", "child-2"), + ("quux", "grandchild"), + # Duplicates because dependency is included via several groups. + # This is ok because they are merged during dependency resolution. + ("quux", "root"), + ("quux", "root"), + ("quux", "shared"), + ] + + +def test_create_poetry_with_complicated_cyclic_diamond_dependency_groups( + temporary_directory: Path, +) -> None: + """ + root -> child-1, child-2 + child-1 -> shared + child-2 -> shared + shared -> grandchild + grandchild -> child-2 + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.root] +include-groups = [ + "child_1", + "child_2", +] + +[tool.poetry.group.root.dependencies] +foo = "*" + +[tool.poetry.group.child_1] +include-groups = [ + "shared", +] +[tool.poetry.group.child_1.dependencies] +bar = "*" + +[tool.poetry.group.child_2] +include-groups = [ + "shared", +] +[tool.poetry.group.child_2.dependencies] +baz = "*" + +[tool.poetry.group.shared] +include-groups = [ + "grandchild", +] +[tool.poetry.group.shared.dependencies] +quux = "*" + +[tool.poetry.group.grandchild] +include-groups = [ + "child_2", +] +[tool.poetry.group.grandchild.dependencies] +bar = "*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in root: child-2 -> shared -> grandchild -> child-2 + - Cyclic dependency group include in root: child-1 -> shared -> grandchild -> child-2 -> shared + - Cyclic dependency group include in child-1: shared -> grandchild -> child-2 -> shared + - Cyclic dependency group include in child-2: shared -> grandchild -> child-2 + - Cyclic dependency group include in shared: grandchild -> child-2 -> shared + - Cyclic dependency group include in grandchild: child-2 -> shared -> grandchild +""" + + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_noncanonical_names_cyclic_dependency_groups( + temporary_directory: Path, +) -> None: + """ + group-1 -> group-2 + group-2 -> group-1 + group-3 -> group-2 + """ + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.GROUP_1] +include-groups = [ + "gRoup_2", +] + +[tool.poetry.group.GROUP_1.dependencies] +foo = "*" + +[tool.poetry.group.group_2] +include-groups = [ + "groUp_1", +] +[tool.poetry.group.group_2.dependencies] +bar = "*" + +[tool.poetry.group.group_3] +include-groups = [ + "group_2", +] +[tool.poetry.group.group_3.dependencies] +baz = "*" +""" + + expected = """\ +The Poetry configuration is invalid: + - Cyclic dependency group include in group-1: group-2 -> group-1 + - Cyclic dependency group include in group-2: group-1 -> group-2 + - Cyclic dependency group include in group-3: group-2 -> group-1 -> group-2 +""" + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=RuntimeError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_unknown_nested_dependency_groups( + temporary_directory: Path, +) -> None: + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.dev] +include-groups = [ + "testing", +] +[tool.poetry.group.dev.dependencies] +black = "*" +""" + expected = "Group 'dev' includes group 'testing' which is not defined." + + assert_invalid_group_including( + toml_data=content, + expected_error=expected, + error_type=ValueError, + temporary_directory=temporary_directory, + ) + + +def test_create_poetry_with_included_groups_only(temporary_directory: Path) -> None: + pyproject_toml = temporary_directory / "pyproject.toml" + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.lint.dependencies] +black = "*" + +[tool.poetry.group.testing.dependencies] +pytest = "*" + +[tool.poetry.group.all] +include-groups = [ + "lint", + "testing", +] +""" + pyproject_toml.write_text(content) + + poetry = Factory().create_poetry(temporary_directory) + assert len(poetry.package.all_requires) == 4 + assert [ + (dep.name, ",".join(dep.groups)) for dep in poetry.package.all_requires + ] == [ + ("black", "lint"), + ("pytest", "testing"), + ("black", "all"), + ("pytest", "all"), + ] + + +def test_create_poetry_with_nested_similar_dependencies( + temporary_directory: Path, +) -> None: + pyproject_toml = temporary_directory / "pyproject.toml" + content = """\ +[project] +name = "my-package" +version = "1.2.3" + +[tool.poetry.group.parent.dependencies] +foo = "*" + +[tool.poetry.group.parent] +include-groups = [ + "child", +] + +[tool.poetry.group.child.dependencies] +foo = "*" + +""" + + pyproject_toml.write_text(content) + + poetry = Factory().create_poetry(temporary_directory) + assert len(poetry.package.all_requires) == 3 + assert [ + (dep.name, ",".join(dep.groups)) for dep in poetry.package.all_requires + ] == [ + # Duplicates because dependency is included via several groups. + # This is ok because they are merged during dependency resolution. + ("foo", "parent"), + ("foo", "parent"), + ("foo", "child"), + ]