diff --git a/docs/pyproject.md b/docs/pyproject.md index b8cbf350ce7..496ee9e51c3 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -924,6 +924,28 @@ my-plugin = ">=1.0,<2.0" See [Project plugins]({{< relref "plugins#project-plugins" >}}) for more information. +### build-constraints + +In this section, you can specify additional constraints to apply when creating the build +environment for a dependency. This is useful if a package does not provide wheels +(or shall be built from source for other reasons) +and specifies too loose build requirements (without an upper bound) +and is not compatible with current versions of one of its build requirements. + +For example, if your project depends on `some-package`, which only provides an sdist +and defines its build requirements as `build-requires = ["setuptools"]`, +but is incompatible with `setuptools >= 78`, building the package will probably fail +because per default the latest setuptools will be chosen. In this case, you can +work around this issue of `some-package` as follows: + +```toml +[tool.poetry.build-constraints] +some-package = { setuptools = "<78" } +``` + +The syntax for specifying constraints is the same as for specifying dependencies +in the `tool.poetry` section. + ## Poetry and PEP-517 [PEP-517](https://www.python.org/dev/peps/pep-0517/) introduces a standard way diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index 843279c8bd3..511f76f4f08 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -616,6 +616,7 @@ def configure_installer_for_command(command: InstallerCommand, io: IO) -> None: poetry.pool, poetry.config, disable_cache=poetry.disable_cache, + build_constraints=poetry.build_constraints, ) command.set_installer(installer) diff --git a/src/poetry/factory.py b/src/poetry/factory.py index 2a76e1526cd..5b765ecd18f 100644 --- a/src/poetry/factory.py +++ b/src/poetry/factory.py @@ -9,6 +9,7 @@ from typing import cast from cleo.io.null_io import NullIO +from packaging.utils import NormalizedName from packaging.utils import canonicalize_name from poetry.core.constraints.version import Version from poetry.core.constraints.version import parse_constraint @@ -24,6 +25,7 @@ from poetry.plugins.plugin_manager import PluginManager from poetry.poetry import Poetry from poetry.toml.file import TOMLFile +from poetry.utils.isolated_build import CONSTRAINTS_GROUP_NAME if TYPE_CHECKING: @@ -31,6 +33,7 @@ from pathlib import Path from cleo.io.io import IO + from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package from tomlkit.toml_document import TOMLDocument @@ -68,6 +71,23 @@ def create_poetry( f" but you are using Poetry {version}" ) + build_constraints: dict[NormalizedName, list[Dependency]] = {} + for name, constraints in base_poetry.local_config.get( + "build-constraints", {} + ).items(): + name = canonicalize_name(name) + build_constraints[name] = [] + for dep_name, constraint in constraints.items(): + _constraints = ( + constraint if isinstance(constraint, list) else [constraint] + ) + for _constraint in _constraints: + build_constraints[name].append( + Factory.create_dependency( + dep_name, _constraint, groups=[CONSTRAINTS_GROUP_NAME] + ) + ) + poetry_file = base_poetry.pyproject_path locker = Locker(poetry_file.parent / "poetry.lock", base_poetry.pyproject.data) @@ -99,7 +119,8 @@ def create_poetry( base_poetry.package, locker, config, - disable_cache, + disable_cache=disable_cache, + build_constraints=build_constraints, ) poetry.set_pool( diff --git a/src/poetry/installation/chef.py b/src/poetry/installation/chef.py index f82da9a910b..502db008219 100644 --- a/src/poetry/installation/chef.py +++ b/src/poetry/installation/chef.py @@ -16,6 +16,7 @@ from collections.abc import Sequence from build import DistributionType + from poetry.core.packages.dependency import Dependency from poetry.repositories import RepositoryPool from poetry.utils.cache import ArtifactCache @@ -40,6 +41,7 @@ def prepare( *, editable: bool = False, config_settings: Mapping[str, str | Sequence[str]] | None = None, + build_constraints: list[Dependency] | None = None, ) -> Path: if not self._should_prepare(archive): return archive @@ -51,10 +53,14 @@ def prepare( destination=destination, editable=editable, config_settings=config_settings, + build_constraints=build_constraints, ) return self._prepare_sdist( - archive, destination=output_dir, config_settings=config_settings + archive, + destination=output_dir, + config_settings=config_settings, + build_constraints=build_constraints, ) def _prepare( @@ -64,6 +70,7 @@ def _prepare( *, editable: bool = False, config_settings: Mapping[str, str | Sequence[str]] | None = None, + build_constraints: list[Dependency] | None = None, ) -> Path: distribution: DistributionType = "editable" if editable else "wheel" with isolated_builder( @@ -71,6 +78,7 @@ def _prepare( distribution=distribution, python_executable=self._env.python, pool=self._pool, + build_constraints=build_constraints, ) as builder: return Path( builder.build( @@ -85,6 +93,7 @@ def _prepare_sdist( archive: Path, destination: Path | None = None, config_settings: Mapping[str, str | Sequence[str]] | None = None, + build_constraints: list[Dependency] | None = None, ) -> Path: from poetry.core.packages.utils.link import Link @@ -115,6 +124,7 @@ def _prepare_sdist( sdist_dir, destination, config_settings=config_settings, + build_constraints=build_constraints, ) def _should_prepare(self, archive: Path) -> bool: diff --git a/src/poetry/installation/executor.py b/src/poetry/installation/executor.py index fa2798d89ec..9cd4ba52cf8 100644 --- a/src/poetry/installation/executor.py +++ b/src/poetry/installation/executor.py @@ -45,6 +45,7 @@ from cleo.io.io import IO from cleo.io.outputs.section_output import SectionOutput 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 @@ -68,6 +69,8 @@ def __init__( io: IO, parallel: bool | None = None, disable_cache: bool = False, + *, + build_constraints: Mapping[NormalizedName, list[Dependency]] | None = None, ) -> None: self._env = env self._io = io @@ -75,6 +78,7 @@ def __init__( self._enabled = True self._verbose = False self._wheel_installer = WheelInstaller(self._env) + self._build_constraints = build_constraints or {} if parallel is None: parallel = config.get("installer.parallel", True) @@ -647,11 +651,13 @@ def _prepare_archive( self._populate_hashes_dict(archive, package) + name = operation.package.name return self._chef.prepare( archive, editable=package.develop, output_dir=output_dir, - config_settings=self._build_config_settings.get(operation.package.name), + config_settings=self._build_config_settings.get(name), + build_constraints=self._build_constraints.get(name), ) def _prepare_git_archive(self, operation: Install | Update) -> Path: @@ -761,10 +767,12 @@ def _download_link(self, operation: Install | Update, link: Link) -> Path: ) self._write(operation, message) + name = operation.package.name archive = self._chef.prepare( archive, output_dir=original_archive.parent, - config_settings=self._build_config_settings.get(operation.package.name), + config_settings=self._build_config_settings.get(name), + build_constraints=self._build_constraints.get(name), ) # Use the original archive to provide the correct hash. diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py index b4e9eaaaafd..415ad56852f 100644 --- a/src/poetry/installation/installer.py +++ b/src/poetry/installation/installer.py @@ -16,9 +16,11 @@ if TYPE_CHECKING: from collections.abc import Iterable + from collections.abc import Mapping from cleo.io.io import IO from packaging.utils import NormalizedName + from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package from poetry.core.packages.path_dependency import PathDependency from poetry.core.packages.project_package import ProjectPackage @@ -42,6 +44,8 @@ def __init__( installed: InstalledRepository | None = None, executor: Executor | None = None, disable_cache: bool = False, + *, + build_constraints: Mapping[NormalizedName, list[Dependency]] | None = None, ) -> None: self._io = io self._env = env @@ -64,7 +68,12 @@ def __init__( if executor is None: executor = Executor( - self._env, self._pool, config, self._io, disable_cache=disable_cache + self._env, + self._pool, + config, + self._io, + disable_cache=disable_cache, + build_constraints=build_constraints, ) self._executor = executor diff --git a/src/poetry/json/schemas/poetry.json b/src/poetry/json/schemas/poetry.json index ca48f5a9e3c..3a9d79d2b02 100644 --- a/src/poetry/json/schemas/poetry.json +++ b/src/poetry/json/schemas/poetry.json @@ -24,6 +24,15 @@ "items": { "$ref": "#/definitions/repository" } + }, + "build-constraints": { + "type": "object", + "description": "This is a dict of package name (keys) and version constraints (values) to restrict build requirements for a package.", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "$ref": "#/definitions/dependencies" + } + } } }, "definitions": { diff --git a/src/poetry/poetry.py b/src/poetry/poetry.py index 99baedfe2f1..4caa8de7613 100644 --- a/src/poetry/poetry.py +++ b/src/poetry/poetry.py @@ -12,8 +12,11 @@ if TYPE_CHECKING: + from collections.abc import Mapping from pathlib import Path + from packaging.utils import NormalizedName + from poetry.core.packages.dependency import Dependency from poetry.core.packages.project_package import ProjectPackage from poetry.config.config import Config @@ -34,6 +37,8 @@ def __init__( locker: Locker, config: Config, disable_cache: bool = False, + *, + build_constraints: Mapping[NormalizedName, list[Dependency]] | None = None, ) -> None: from poetry.repositories.repository_pool import RepositoryPool @@ -44,6 +49,7 @@ def __init__( self._pool = RepositoryPool(config=config) self._plugin_manager: PluginManager | None = None self._disable_cache = disable_cache + self._build_constraints = build_constraints or {} @property def pyproject(self) -> PyProjectTOML: @@ -70,6 +76,10 @@ def config(self) -> Config: def disable_cache(self) -> bool: return self._disable_cache + @property + def build_constraints(self) -> Mapping[NormalizedName, list[Dependency]]: + return self._build_constraints + def set_locker(self, locker: Locker) -> Poetry: self._locker = locker diff --git a/src/poetry/utils/isolated_build.py b/src/poetry/utils/isolated_build.py index 9d6367258a8..84b0493baaf 100644 --- a/src/poetry/utils/isolated_build.py +++ b/src/poetry/utils/isolated_build.py @@ -10,6 +10,7 @@ from build import BuildBackendException from build.env import IsolatedEnv as BaseIsolatedEnv +from poetry.core.packages.dependency_group import DependencyGroup from poetry.utils._compat import decode from poetry.utils.env import Env @@ -24,10 +25,14 @@ from build import DistributionType from build import ProjectBuilder + from poetry.core.packages.dependency import Dependency from poetry.repositories import RepositoryPool +CONSTRAINTS_GROUP_NAME = "constraints" + + class IsolatedBuildBaseError(Exception): ... @@ -111,7 +116,12 @@ def make_extra_environ(self) -> dict[str, str]: ) } - def install(self, requirements: Collection[str]) -> None: + def install( + self, + requirements: Collection[str], + *, + constraints: list[Dependency] | None = None, + ) -> None: from cleo.io.buffered_io import BufferedIO from poetry.core.packages.dependency import Dependency from poetry.core.packages.project_package import ProjectPackage @@ -136,6 +146,13 @@ def install(self, requirements: Collection[str]) -> None: # safe as this environment is ephemeral package.add_dependency(dependency) + if constraints: + constraints_group = DependencyGroup(CONSTRAINTS_GROUP_NAME, optional=True) + for constraint in constraints: + if constraint.marker.validate(env_markers): + constraints_group.add_dependency(constraint) + package.add_dependency_group(constraints_group) + io = BufferedIO() installer = Installer( @@ -161,6 +178,8 @@ def isolated_builder( distribution: DistributionType = "wheel", python_executable: Path | None = None, pool: RepositoryPool | None = None, + *, + build_constraints: list[Dependency] | None = None, ) -> Iterator[ProjectBuilder]: from build import ProjectBuilder from pyproject_hooks import quiet_subprocess_runner @@ -196,12 +215,15 @@ def isolated_builder( ) with redirect_stdout(stdout): - env.install(builder.build_system_requires) + env.install( + builder.build_system_requires, constraints=build_constraints + ) # we repeat the build system requirements to avoid poetry installer from removing them env.install( builder.build_system_requires - | builder.get_requires_for_build(distribution) + | builder.get_requires_for_build(distribution), + constraints=build_constraints, ) yield builder diff --git a/tests/fixtures/build_constraints/pyproject.toml b/tests/fixtures/build_constraints/pyproject.toml new file mode 100644 index 00000000000..f0da0451624 --- /dev/null +++ b/tests/fixtures/build_constraints/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "build-constraints" +version = "0.1.0" + +[tool.poetry.build-constraints] +Legacy-Lib = { setuptools = "<75" } +no-constraints = {} + +[tool.poetry.build-constraints.c-ext-lib] +Cython = { version = "<3.1", source = "pypi" } +setuptools = [ + { version = ">=60,<75", python = "<3.9" }, + { version = ">=75", python = ">=3.8" } +] diff --git a/tests/fixtures/build_constraints_empty/pyproject.toml b/tests/fixtures/build_constraints_empty/pyproject.toml new file mode 100644 index 00000000000..f6418396a16 --- /dev/null +++ b/tests/fixtures/build_constraints_empty/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "build-constraints" +version = "0.1.0" + +[tool.poetry.build-constraints] diff --git a/tests/installation/test_executor.py b/tests/installation/test_executor.py index da207507336..9ec96be5a9f 100644 --- a/tests/installation/test_executor.py +++ b/tests/installation/test_executor.py @@ -19,6 +19,8 @@ from cleo.formatters.style import Style from cleo.io.buffered_io import BufferedIO from cleo.io.outputs.output import Verbosity +from packaging.utils import canonicalize_name +from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package from poetry.core.packages.utils.utils import path_to_url @@ -52,6 +54,7 @@ class Chef(BaseChef): _directory_wheels: list[Path] | None = None _sdist_wheels: list[Path] | None = None + _use_sdist = False def set_directory_wheel(self, wheels: Path | list[Path]) -> None: if not isinstance(wheels, list): @@ -70,14 +73,17 @@ def _prepare_sdist( archive: Path, destination: Path | None = None, config_settings: Mapping[str, str | Sequence[str]] | None = None, + build_constraints: list[Dependency] | None = None, ) -> Path: if self._sdist_wheels is not None: - wheel = self._sdist_wheels.pop(0) - self._sdist_wheels.append(wheel) - - return wheel + self._use_sdist = True - return super()._prepare_sdist(archive) + return super()._prepare_sdist( + archive, + destination, + config_settings=config_settings, + build_constraints=build_constraints, + ) def _prepare( self, @@ -86,7 +92,15 @@ def _prepare( *, editable: bool = False, config_settings: Mapping[str, str | Sequence[str]] | None = None, + build_constraints: list[Dependency] | None = None, ) -> Path: + if self._use_sdist and self._sdist_wheels is not None: + self._use_sdist = False + wheel = self._sdist_wheels.pop(0) + self._sdist_wheels.append(wheel) + + return wheel + if self._directory_wheels is not None: wheel = self._directory_wheels.pop(0) self._directory_wheels.append(wheel) @@ -96,7 +110,13 @@ def _prepare( shutil.copyfile(wheel, dst_wheel) return dst_wheel - return super()._prepare(directory, destination, editable=editable) + return super()._prepare( + directory, + destination, + editable=editable, + config_settings=config_settings, + build_constraints=build_constraints, + ) @pytest.fixture @@ -260,6 +280,7 @@ def test_execute_executes_a_batch_of_operations( destination=mocker.ANY, editable=False, config_settings=None, + build_constraints=None, ), mocker.call( chef, @@ -267,10 +288,12 @@ def test_execute_executes_a_batch_of_operations( destination=mocker.ANY, editable=True, config_settings=None, + build_constraints=None, ), ] +@pytest.mark.parametrize("source_type", ["git", "file", "url"]) def test_execute_build_config_settings_passed( mocker: MockerFixture, config: Config, @@ -280,6 +303,7 @@ def test_execute_build_config_settings_passed( env: MockEnv, copy_wheel: Callable[[], Path], fixture_dir: FixtureDirGetter, + source_type: str, ) -> None: wheel_install = mocker.patch.object(WheelInstaller, "install") @@ -308,19 +332,113 @@ def test_execute_build_config_settings_passed( source_url=fixture_dir("simple_project").resolve().as_posix(), ) - git_package = Package( - "demo", - "0.1.0", - source_type="git", - source_reference="master", - source_url="https://github.com/demo/demo.git", - develop=True, + if source_type == "git": + ref = "master" + demo_package = Package( + "demo", + "0.1.0", + source_type="git", + source_reference=ref, + source_url="https://github.com/demo/demo.git", + ) + version_info = ref + elif source_type == "file": + url = (fixture_dir("distributions") / "demo-0.1.0.tar.gz").resolve().as_posix() + demo_package = Package("demo", "0.1.0", source_type="file", source_url=url) + version_info = url + elif source_type == "url": + url = "https://files.pythonhosted.org/demo-0.1.0.tar.gz" + demo_package = Package("demo", "0.1.0", source_type="url", source_url=url) + version_info = url + else: + raise ValueError + + return_code = executor.execute( + [ + Install(directory_package), + Install(demo_package), + ] + ) + + expected = f""" +Package operations: 2 installs, 0 updates, 0 removals + + - Installing simple-project (1.2.3 {directory_package.source_url}) + - Installing demo (0.1.0 {version_info}) +""" + + expected_lines = set(expected.splitlines()) + output_lines = set(io.fetch_output().splitlines()) + assert output_lines == expected_lines + assert wheel_install.call_count == 2 + assert return_code == 0 + + assert prepare_spy.call_count == 2 + assert prepare_spy.call_args_list[0].kwargs.get("config_settings") is None + assert ( + prepare_spy.call_args_list[1].kwargs.get("config_settings") + == config_settings_demo ) + +@pytest.mark.parametrize("source_type", ["git", "file"]) +def test_execute_build_constraints_passed( + mocker: MockerFixture, + config: Config, + pool: RepositoryPool, + io: BufferedIO, + tmp_path: Path, + env: MockEnv, + copy_wheel: Callable[[], Path], + fixture_dir: FixtureDirGetter, + source_type: str, +) -> None: + wheel_install = mocker.patch.object(WheelInstaller, "install") + + artifact_cache = ArtifactCache(cache_dir=config.artifacts_cache_directory) + + prepare_spy = mocker.spy(Chef, "_prepare") + chef = Chef(artifact_cache, env, Factory.create_pool(config)) + chef.set_directory_wheel([copy_wheel(), copy_wheel()]) + chef.set_sdist_wheel(copy_wheel()) + + build_constraints_demo = [Dependency("setuptools", "<75")] + build_constraints = {canonicalize_name("demo"): build_constraints_demo} + executor = Executor(env, pool, config, io, build_constraints=build_constraints) + executor._chef = chef + + directory_package = Package( + "simple-project", + "1.2.3", + source_type="directory", + source_url=fixture_dir("simple_project").resolve().as_posix(), + ) + + if source_type == "git": + ref = "master" + demo_package = Package( + "demo", + "0.1.0", + source_type="git", + source_reference=ref, + source_url="https://github.com/demo/demo.git", + ) + version_info = ref + elif source_type == "file": + url = (fixture_dir("distributions") / "demo-0.1.0.tar.gz").resolve().as_posix() + demo_package = Package("demo", "0.1.0", source_type="file", source_url=url) + version_info = url + elif source_type == "url": + url = "https://files.pythonhosted.org/demo-0.1.0.tar.gz" + demo_package = Package("demo", "0.1.0", source_type="url", source_url=url) + version_info = url + else: + raise ValueError + return_code = executor.execute( [ Install(directory_package), - Install(git_package), + Install(demo_package), ] ) @@ -328,7 +446,7 @@ def test_execute_build_config_settings_passed( Package operations: 2 installs, 0 updates, 0 removals - Installing simple-project (1.2.3 {directory_package.source_url}) - - Installing demo (0.1.0 master) + - Installing demo (0.1.0 {version_info}) """ expected_lines = set(expected.splitlines()) @@ -338,22 +456,11 @@ def test_execute_build_config_settings_passed( assert return_code == 0 assert prepare_spy.call_count == 2 - assert prepare_spy.call_args_list == [ - mocker.call( - chef, - mocker.ANY, - destination=mocker.ANY, - editable=False, - config_settings=None, - ), - mocker.call( - chef, - mocker.ANY, - destination=mocker.ANY, - editable=True, - config_settings=config_settings_demo, - ), - ] + assert prepare_spy.call_args_list[0].kwargs.get("build_constraints") is None + assert ( + prepare_spy.call_args_list[1].kwargs.get("build_constraints") + == build_constraints_demo + ) @pytest.mark.parametrize( diff --git a/tests/json/fixtures/build_constraints.toml b/tests/json/fixtures/build_constraints.toml new file mode 100644 index 00000000000..f0da0451624 --- /dev/null +++ b/tests/json/fixtures/build_constraints.toml @@ -0,0 +1,14 @@ +[project] +name = "build-constraints" +version = "0.1.0" + +[tool.poetry.build-constraints] +Legacy-Lib = { setuptools = "<75" } +no-constraints = {} + +[tool.poetry.build-constraints.c-ext-lib] +Cython = { version = "<3.1", source = "pypi" } +setuptools = [ + { version = ">=60,<75", python = "<3.9" }, + { version = ">=75", python = ">=3.8" } +] diff --git a/tests/json/test_schema.py b/tests/json/test_schema.py index 619a65afe5b..7daa18cfa5b 100644 --- a/tests/json/test_schema.py +++ b/tests/json/test_schema.py @@ -57,6 +57,11 @@ def test_self_invalid_plugin() -> None: } +def test_build_constraints() -> None: + toml: dict[str, Any] = TOMLFile(FIXTURE_DIR / "build_constraints.toml").read() + assert Factory.validate(toml) == {"errors": [], "warnings": []} + + def test_dependencies_is_consistent_to_poetry_core_schema() -> None: with SCHEMA_FILE.open(encoding="utf-8") as f: schema = json.load(f) diff --git a/tests/json/test_schema_sources.py b/tests/json/test_schema_sources.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/test_factory.py b/tests/test_factory.py index 68cdbb3a71e..fa616257b14 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -11,6 +11,7 @@ from packaging.utils import canonicalize_name from poetry.core.constraints.version import Version from poetry.core.constraints.version import parse_constraint +from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package from poetry.core.packages.vcs_dependency import VCSDependency @@ -381,6 +382,29 @@ def test_poetry_with_pypi_explicit_only( assert str(e.value) == "At least one source must not be configured as 'explicit'." +def test_poetry_with_build_constraints(fixture_dir: FixtureDirGetter) -> None: + poetry = Factory().create_poetry(fixture_dir("build_constraints")) + assert set(poetry.build_constraints) == { + "legacy-lib", + "no-constraints", + "c-ext-lib", + } + assert poetry.build_constraints[canonicalize_name("legacy-lib")] == [ + Dependency("setuptools", "<75") + ] + assert poetry.build_constraints[canonicalize_name("no-constraints")] == [] + assert poetry.build_constraints[canonicalize_name("c-ext-lib")] == [ + Dependency("Cython", "<3.1"), + Dependency("setuptools", ">=60,<75"), + Dependency("setuptools", ">=75"), + ] + + +def test_poetry_with_empty_build_constraints(fixture_dir: FixtureDirGetter) -> None: + poetry = Factory().create_poetry(fixture_dir("build_constraints_empty")) + assert set(poetry.build_constraints) == set() + + def test_validate(fixture_dir: FixtureDirGetter) -> None: complete = TOMLFile(fixture_dir("complete.toml")) pyproject: dict[str, Any] = complete.read() diff --git a/tests/utils/test_isolated_build.py b/tests/utils/test_isolated_build.py index b8d72ca2390..747237cefd3 100644 --- a/tests/utils/test_isolated_build.py +++ b/tests/utils/test_isolated_build.py @@ -9,12 +9,15 @@ import pytest +from poetry.core.packages.dependency import Dependency + from poetry.factory import Factory from poetry.puzzle.exceptions import SolverProblemError from poetry.puzzle.provider import IncompatibleConstraintsError from poetry.repositories import RepositoryPool from poetry.repositories.installed_repository import InstalledRepository from poetry.utils.env import ephemeral_environment +from poetry.utils.isolated_build import CONSTRAINTS_GROUP_NAME from poetry.utils.isolated_build import IsolatedBuildInstallError from poetry.utils.isolated_build import IsolatedEnv from poetry.utils.isolated_build import isolated_builder @@ -57,6 +60,26 @@ def test_isolated_env_install_success(pool: RepositoryPool) -> None: ) +def test_isolated_env_install_with_constraints_success(pool: RepositoryPool) -> None: + constraints = [ + Dependency("poetry-core", "<2", groups=[CONSTRAINTS_GROUP_NAME]), + Dependency("attrs", ">1", groups=[CONSTRAINTS_GROUP_NAME]), + ] + + with ephemeral_environment(Path(sys.executable)) as venv: + env = IsolatedEnv(venv, pool) + assert not InstalledRepository.load(venv).find_packages( + get_dependency("poetry-core") + ) + assert not InstalledRepository.load(venv).find_packages(get_dependency("attrs")) + + env.install({"poetry-core"}, constraints=constraints) + assert InstalledRepository.load(venv).find_packages( + get_dependency("poetry-core") + ) + assert not InstalledRepository.load(venv).find_packages(get_dependency("attrs")) + + def test_isolated_env_install_discards_requirements_not_needed_by_env( pool: RepositoryPool, ) -> None: @@ -105,6 +128,35 @@ def test_isolated_env_install_error( env.install(requirements) +@pytest.mark.parametrize( + ("requirements", "constraints", "exception"), + [ + ( + {"poetry-core==1.5.0"}, + [("poetry-core", "1.6.0")], + IncompatibleConstraintsError, + ), + ({"black==19.10b0"}, [("attrs", "17.4.0")], SolverProblemError), + ], +) +def test_isolated_env_install_with_constraints_error( + requirements: Collection[str], + constraints: list[tuple[str, str]], + exception: type[Exception], + pool: RepositoryPool, +) -> None: + with ephemeral_environment(Path(sys.executable)) as venv: + env = IsolatedEnv(venv, pool) + with pytest.raises(exception): + env.install( + requirements, + constraints=[ + Dependency(name, version, groups=[CONSTRAINTS_GROUP_NAME]) + for name, version in constraints + ], + ) + + def test_isolated_env_install_failure( pool: RepositoryPool, mocker: MockerFixture ) -> None: