diff --git a/src/poetry/core/factory.py b/src/poetry/core/factory.py index eb2fb60eb..1b4719668 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,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]] 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..22e2b2d0c 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 diff --git a/src/poetry/core/packages/dependency_group.py b/src/poetry/core/packages/dependency_group.py index 24dfded08..b2739d4d0 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,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 @@ -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 diff --git a/tests/packages/test_dependency_group.py b/tests/packages/test_dependency_group.py index 065f16eed..b8a4421d7 100644 --- a/tests/packages/test_dependency_group.py +++ b/tests/packages/test_dependency_group.py @@ -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) diff --git a/tests/test_factory.py b/tests/test_factory.py index 2ddf2aad1..62618eee5 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1072,3 +1072,589 @@ 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: + 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 -> 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: + 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: + 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-2 -> group-1 + - Cyclic dependency group include in group-2: group-3 -> group-2 + - Cyclic dependency group include in group-3: 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: + 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-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: + 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"), + ("quux", "root"), + ("quux", "root"), # TODO: is this expected? + ("quux", "shared"), + ] + +def test_create_poetry_with_shared_dependency_groups_more_complicated( + temporary_directory: Path, +) -> None: + 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"), + ("quux", "root"), + ("quux", "root"), # TODO: is this expected? + ("quux", "shared"), + ] + +def test_create_poetry_with_complicated_cyclic_diamond_dependency_groups( + temporary_directory: Path, +) -> None: + 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: grandchild -> child-2 + - Cyclic dependency group include in root: grandchild -> child-2 + - Cyclic dependency group include in child-1: child-2 -> shared + - Cyclic dependency group include in child-2: grandchild -> child-2 + - Cyclic dependency group include in shared: child-2 -> shared + - Cyclic dependency group include in grandchild: 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: + 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-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 + ] == [ + ("foo", "parent"), + ("foo", "parent"), # TODO: dupe! + ("foo", "child"), + ] \ No newline at end of file