diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index f1334e0e3bf..ab55d67f29b 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -460,18 +460,29 @@ def complete_package( dependency = dependency_package.dependency requires = package.requires - optional_dependencies = [] + found_extras = set() + optional_dependencies = set() _dependencies = [] - # If some extras/features were required, we need to - # add a special dependency representing the base package - # to the current package if dependency.extras: - for extra in dependency.extras: - if extra not in package.extras: + # Find all the optional dependencies that are wanted - taking care to allow + # for self-referential extras. + stack = list(dependency.extras) + while stack: + extra = stack.pop() + if extra in found_extras: continue + found_extras.add(extra) - optional_dependencies += [d.name for d in package.extras[extra]] + extra_dependencies = package.extras.get(extra, []) + for extra_dependency in extra_dependencies: + if extra_dependency.name == dependency.name: + stack += list(extra_dependency.extras) + else: + optional_dependencies.add(extra_dependency.name) + + # If some extras/features were required, we need to add a special dependency + # representing the base package to the current package. dependency_package = dependency_package.with_features( list(dependency.extras) @@ -507,10 +518,7 @@ def complete_package( if not package.is_root() and ( (dep.is_optional() and dep.name not in optional_dependencies) - or ( - dep.in_extras - and not set(dep.in_extras).intersection(dependency.extras) - ) + or (dep.in_extras and not set(dep.in_extras).intersection(found_extras)) ): continue diff --git a/tests/conftest.py b/tests/conftest.py index a1dd7f6d782..2dcb0e8a20a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,10 @@ from keyring.credentials import SimpleCredential from keyring.errors import KeyringError from keyring.errors import KeyringLocked +from packaging.utils import canonicalize_name +from poetry.core.constraints.version import parse_constraint +from poetry.core.packages.dependency import Dependency +from poetry.core.version.markers import parse_marker from pytest import FixtureRequest from poetry.config.config import Config as BaseConfig @@ -29,7 +33,9 @@ from poetry.factory import Factory from poetry.layouts import layout from poetry.packages.direct_origin import _get_package_from_git +from poetry.repositories import Repository from poetry.repositories import RepositoryPool +from poetry.repositories.exceptions import PackageNotFoundError from poetry.repositories.installed_repository import InstalledRepository from poetry.utils.cache import ArtifactCache from poetry.utils.env import EnvManager @@ -57,6 +63,8 @@ from cleo.io.inputs.argument import Argument from cleo.io.inputs.option import Option from keyring.credentials import Credential + from packaging.utils import NormalizedName + from poetry.core.packages.package import Package from pytest import Config as PyTestConfig from pytest import Parser from pytest import TempPathFactory @@ -66,6 +74,7 @@ from tests.types import CommandFactory from tests.types import FixtureCopier from tests.types import FixtureDirGetter + from tests.types import PackageFactory from tests.types import ProjectFactory from tests.types import SetProjectContext @@ -500,6 +509,80 @@ def _factory( return _factory +@pytest.fixture +def create_package(repo: Repository) -> PackageFactory: + """ + This function is a pytest fixture that creates a factory function to generate + and customize package objects. These packages are added to the default repository + fixture and configured with specific versions, optional extras, and self-referenced + extras. This helps in setting up package dependencies for testing purposes. + + :return: A factory function that can be used to create and configure packages. + """ + + def create_new_package( + name: str, + version: str | None = None, + dependencies: list[Dependency] | None = None, + extras: dict[str, list[str]] | None = None, + ) -> Package: + version = version or "1.0" + package = get_package(name, version) + + package_extras: dict[NormalizedName, list[Dependency]] = {} + + for extra, extra_dependencies in (extras or {}).items(): + extra = canonicalize_name(extra) + + if extra not in package_extras: + package_extras[extra] = [] + + for extra_dependency_spec in extra_dependencies: + extra_dependency = Dependency.create_from_pep_508(extra_dependency_spec) + extra_dependency._optional = True + extra_dependency.marker = extra_dependency.marker.intersect( + parse_marker(f"extra == '{extra}'") + ) + + if extra_dependency.name != package.name: + assert extra_dependency.constraint.allows(package.version) + + # if it is not a self-referencing dependency, make sure we add it to the repo + try: + pkg = repo.package(extra_dependency.name, package.version) + except PackageNotFoundError: + pkg = get_package(extra_dependency.name, str(package.version)) + repo.add_package(pkg) + + extra_dependency.constraint = parse_constraint(f"^{pkg.version}") + + # if requirement already exists in the package, update the marker + for requirement in package.requires: + if ( + requirement.name == extra_dependency.name + and requirement.is_optional() + ): + requirement.marker = requirement.marker.union( + extra_dependency.marker + ) + break + else: + package.add_dependency(extra_dependency) + + package_extras[extra].append(extra_dependency) + + package.extras = package_extras + + for dependency in dependencies or []: + package.add_dependency(dependency) + + repo.add_package(package) + + return package + + return create_new_package + + @pytest.fixture(autouse=True) def set_simple_log_formatter() -> None: """ diff --git a/tests/installation/fixtures/with-self-referencing-extras-all-deep.test b/tests/installation/fixtures/with-self-referencing-extras-all-deep.test new file mode 100644 index 00000000000..83df023f8c6 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-all-deep.test @@ -0,0 +1,58 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = {version = "1.0", extras = ["all"]} + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-all-top.test b/tests/installation/fixtures/with-self-referencing-extras-all-top.test new file mode 100644 index 00000000000..794e59c7c3b --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-all-top.test @@ -0,0 +1,46 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-b-markers.test b/tests/installation/fixtures/with-self-referencing-extras-b-markers.test new file mode 100644 index 00000000000..fde040a8349 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-b-markers.test @@ -0,0 +1,42 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install] ; python_version < \"3.9\""] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-deep.test b/tests/installation/fixtures/with-self-referencing-extras-deep.test new file mode 100644 index 00000000000..9bf38eed028 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-deep.test @@ -0,0 +1,36 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = "1.0" + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-download-deep.test b/tests/installation/fixtures/with-self-referencing-extras-download-deep.test new file mode 100644 index 00000000000..b0440a9fc1c --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-download-deep.test @@ -0,0 +1,48 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = {version = "1.0", extras = ["download"]} + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-download-top.test b/tests/installation/fixtures/with-self-referencing-extras-download-top.test new file mode 100644 index 00000000000..df99e1b3513 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-download-top.test @@ -0,0 +1,36 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-install-deep.test b/tests/installation/fixtures/with-self-referencing-extras-install-deep.test new file mode 100644 index 00000000000..f2c44836511 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-install-deep.test @@ -0,0 +1,48 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = {version = "1.0", extras = ["install"]} + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-install-download-deep.test b/tests/installation/fixtures/with-self-referencing-extras-install-download-deep.test new file mode 100644 index 00000000000..6e03019d4c2 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-install-download-deep.test @@ -0,0 +1,58 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = {version = "1.0", extras = ["download", "install"]} + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-install-download-top.test b/tests/installation/fixtures/with-self-referencing-extras-install-download-top.test new file mode 100644 index 00000000000..794e59c7c3b --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-install-download-top.test @@ -0,0 +1,46 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-install-top.test b/tests/installation/fixtures/with-self-referencing-extras-install-top.test new file mode 100644 index 00000000000..e99c603254e --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-install-top.test @@ -0,0 +1,36 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-nested-deep.test b/tests/installation/fixtures/with-self-referencing-extras-nested-deep.test new file mode 100644 index 00000000000..670479088e4 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-nested-deep.test @@ -0,0 +1,58 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "B" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +a = {version = "1.0", extras = ["nested"]} + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-nested-top.test b/tests/installation/fixtures/with-self-referencing-extras-nested-top.test new file mode 100644 index 00000000000..794e59c7c3b --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-nested-top.test @@ -0,0 +1,46 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.dependencies] +download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""} +install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[[package]] +name = "download-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[[package]] +name = "install-package" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/fixtures/with-self-referencing-extras-top.test b/tests/installation/fixtures/with-self-referencing-extras-top.test new file mode 100644 index 00000000000..f6b7523b843 --- /dev/null +++ b/tests/installation/fixtures/with-self-referencing-extras-top.test @@ -0,0 +1,24 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "A" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] + +[package.extras] +all = ["a[download,install]"] +download = ["download-package (>=1.0,<2.0)"] +install = ["install-package (>=1.0,<2.0)"] +nested = ["a[all]"] +py = ["a[py310,py38]"] +py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""] +py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""] + +[metadata] +lock-version = "2.1" +python-versions = "*" +content-hash = "123456789" diff --git a/tests/installation/test_installer.py b/tests/installation/test_installer.py index d03d3a74d87..c4560f31907 100644 --- a/tests/installation/test_installer.py +++ b/tests/installation/test_installer.py @@ -49,6 +49,7 @@ from poetry.utils.env import Env from tests.conftest import Config from tests.types import FixtureDirGetter + from tests.types import PackageFactory class CustomInstalledRepository(InstalledRepository): @@ -1065,6 +1066,101 @@ def test_run_with_dependencies_nested_extras( assert locker.written_data == expected +@pytest.mark.parametrize( + "enabled_extras", + [ + ([]), + (["all"]), + (["nested"]), + (["install", "download"]), + (["install"]), + (["download"]), + ], +) +@pytest.mark.parametrize("top_level_dependency", [True, False]) +def test_solver_resolves_self_referential_extras( + enabled_extras: list[str], + top_level_dependency: bool, + installer: Installer, + locker: Locker, + repo: Repository, + package: ProjectPackage, + create_package: PackageFactory, +) -> None: + dependency = ( + create_package( + "A", + str(package.version), + extras={ + "download": ["download-package"], + "install": ["install-package"], + "py38": ["py38-package ; python_version == '3.8'"], + "py310": ["py310-package ; python_version > '3.8'"], + "all": ["a[download,install]"], + "py": ["a[py38,py310]"], + "nested": ["a[all]"], + }, + ) + .to_dependency() + .with_features(enabled_extras) + ) + + if not top_level_dependency: + dependency = create_package( + "B", "1.0", dependencies=[dependency] + ).to_dependency() + + package.add_dependency(dependency) + + result = installer.run() + assert result == 0 + + name = "-".join( + [ + "with-self-referencing-extras", + *enabled_extras, + "top" if top_level_dependency else "deep", + ] + ) + + expected = fixture(name) + assert locker.written_data == expected + + +def test_solver_resolves_self_referential_extras_with_markers( + installer: Installer, + locker: Locker, + repo: Repository, + package: ProjectPackage, + create_package: PackageFactory, +) -> None: + package.add_dependency( + Factory.create_dependency("A", {"version": "*", "extras": ["all"]}) + ) + + create_package( + "A", + str(package.version), + extras={ + "download": ["download-package"], + "install": ["install-package"], + "all": ["a[download,install] ; python_version < '3.9'"], + }, + ) + + result = installer.run() + assert result == 0 + + name = "-".join(["with-self-referencing-extras", "b", "markers"]) + + # FIXME: At the time of writing this test case, the markers from self-ref extras are not + # correctly propagated into the dependency specs. For example, given this case, + # the package "install-package" should have a final marker of + # "extra == 'install' or extra == 'all' and python_version < '3.9'". + expected = fixture(name) + assert locker.written_data == expected + + @pytest.mark.parametrize("root", [True, False]) @pytest.mark.parametrize("locked", [False, True]) @pytest.mark.parametrize("extra", [None, "extra-one", "extra-two"]) diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py index da366d1bd36..d8ad5b6876f 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -2,10 +2,12 @@ import re import shutil +import sys from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from typing import Literal import pytest @@ -46,6 +48,7 @@ from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.pypi_repository import PyPiRepository from tests.types import FixtureDirGetter + from tests.types import PackageFactory DEFAULT_SOURCE_REF = ( VCSDependency("poetry", "git", "git@github.com:python-poetry/poetry.git").branch @@ -512,6 +515,110 @@ def test_solver_returns_extras_if_requested( assert ops[0].package.marker.is_any() +@pytest.mark.parametrize( + ("enabled_extras", "expected_packages"), + [ + ([], ["a"]), + (["all"], ["download-package", "install-package", "a"]), + (["nested"], ["download-package", "install-package", "a"]), + (["install", "download"], ["download-package", "install-package", "a"]), + (["install"], ["install-package", "a"]), + (["download"], ["download-package", "a"]), + # test to ensure target extra dependencies with markers are respected + (["py"], ["py310-package", "a"]), + ], +) +@pytest.mark.parametrize("top_level_dependency", [True, False]) +def test_solver_resolves_self_referential_extras( + enabled_extras: list[str], + expected_packages: list[Literal["a", "b", "download-package", "install-package"]], + top_level_dependency: bool, + solver: Solver, + repo: Repository, + package: ProjectPackage, + create_package: PackageFactory, +) -> None: + dependency = ( + create_package( + "A", + str(package.version), + extras={ + "download": ["download-package"], + "install": ["install-package"], + "py38": ["py38-package ; python_version == '3.8'"], + "py310": ["py310-package ; python_version > '3.8'"], + "all": ["a[download,install]"], + "py": ["a[py38,py310]"], + "nested": ["a[all]"], + }, + ) + .to_dependency() + .with_features(enabled_extras) + ) + + if not top_level_dependency: + dependency = create_package( + "B", "1.0", dependencies=[dependency] + ).to_dependency() + # we do not use append() here to avoid flaky tests + expected_packages = [*expected_packages, "b"] + + package.add_dependency(dependency) + + # Solving the dependency graph + with solver.use_environment(MockEnv((3, 10, 0))): + transaction = solver.solve() + + # Verifying the results + check_solver_result( + transaction, + [ + {"job": "install", "package": repo.package(name, package.version)} + for name in expected_packages + ], + ) + + +def test_solver_resolves_self_referential_extras_markers( + solver: Solver, + repo: Repository, + package: ProjectPackage, + create_package: PackageFactory, +) -> None: + package.python_versions = ".".join([str(i) for i in sys.version_info[:3]]) + package.add_dependency( + Factory.create_dependency("A", {"version": "*", "extras": ["all"]}) + ) + + create_package( + "A", + str(package.version), + extras={ + "download": ["download-package"], + "install": ["install-package"], + "all": ["a[download,install] ; python_version < '3.9'"], + }, + ) + + # Solving the dependency graph + with solver.use_environment(MockEnv((3, 10, 0))): + transaction = solver.solve() + + # Verifying the results + check_solver_result( + transaction, + [ + {"job": "install", "package": repo.package(name, package.version)} + # FIXME: At the time of writing this test case, the markers from self-ref extras are not + # correctly propagated into the dependency specs. For example, given this case, + # the package "install-package" should have a final marker of + # "extra == 'install' or extra == 'all' and python_version < '3.9'". + # Once fixed, this should only install package "a". + for name in ["download-package", "install-package", "a"] + ], + ) + + @pytest.mark.parametrize("enabled_extra", ["one", "two", None]) def test_solver_returns_extras_only_requested( solver: Solver, diff --git a/tests/types.py b/tests/types.py index 7027be6af79..bd7ad9197f8 100644 --- a/tests/types.py +++ b/tests/types.py @@ -16,6 +16,8 @@ from cleo.testers.command_tester import CommandTester from httpretty.core import HTTPrettyRequest from packaging.utils import NormalizedName + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.package import Package from poetry.config.config import Config from poetry.config.source import Source @@ -68,6 +70,16 @@ def __call__( ) -> Poetry: ... +class PackageFactory(Protocol): + def __call__( + self, + name: str, + version: str | None = None, + dependencies: list[Dependency] | None = None, + extras: dict[str, list[str]] | None = None, + ) -> Package: ... + + class CommandFactory(Protocol): def __call__( self,