diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index d6a0acf9cd8..30c514f7e59 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -257,6 +257,47 @@ e.g. http://example.com/constraints.txt, so that your organization can store and serve them in a centralized place. +.. _`Build Constraints`: + +Build Constraints +----------------- + +.. versionadded:: 25.3 + +Build constraints are a type of constraints file that applies only to isolated +build environments used for building packages from source. Unlike regular +constraints, which affect the packages installed in your environment, build +constraints only influence the versions of packages available during the +build process. + +This is useful when you need to constrain build dependencies +(such as ``setuptools``, ``cython``, etc.) without affecting the +final installed environment. + +Use build constraints like so: + +.. tab:: Unix/macOS + + .. code-block:: shell + + python -m pip install --build-constraint build-constraints.txt SomePackage + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install --build-constraint build-constraints.txt SomePackage + +Example build constraints file (``build-constraints.txt``): + +.. code-block:: text + + # Constrain setuptools version during build + setuptools>=45,<80 + # Pin Cython for packages that use it to build + cython==0.29.24 + + .. _`Dependency Groups`: diff --git a/news/13534.feature.rst b/news/13534.feature.rst new file mode 100644 index 00000000000..6d7635ace48 --- /dev/null +++ b/news/13534.feature.rst @@ -0,0 +1,8 @@ +Add support for build constraints via the ``--build-constraint`` option. This +allows constraining the versions of packages used during the build process +(e.g., setuptools). + +When using ``--build-constraint``, you can no longer pass constraints to +isolated build environments via the ``PIP_CONSTRAINT`` environment variable. +To opt in to this behavior without specifying any build constraints, use +``--use-feature=build-constraint``. diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 3a246a1e349..b78894d014b 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -11,7 +11,7 @@ from collections import OrderedDict from collections.abc import Iterable from types import TracebackType -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, TypedDict from pip._vendor.packaging.version import Version @@ -19,6 +19,7 @@ from pip._internal.cli.spinners import open_spinner from pip._internal.locations import get_platlib, get_purelib, get_scheme from pip._internal.metadata import get_default_environment, get_environment +from pip._internal.utils.deprecation import deprecated from pip._internal.utils.logging import VERBOSE from pip._internal.utils.packaging import get_requirement from pip._internal.utils.subprocess import call_subprocess @@ -31,6 +32,10 @@ logger = logging.getLogger(__name__) +class ExtraEnviron(TypedDict, total=False): + extra_environ: dict[str, str] + + def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]: return (a, b) if a != b else (a,) @@ -101,8 +106,55 @@ class SubprocessBuildEnvironmentInstaller: Install build dependencies by calling pip in a subprocess. """ - def __init__(self, finder: PackageFinder) -> None: + def __init__( + self, + finder: PackageFinder, + build_constraints: list[str] | None = None, + build_constraint_feature_enabled: bool = False, + constraints: list[str] | None = None, + ) -> None: self.finder = finder + self._build_constraints = build_constraints or [] + self._build_constraint_feature_enabled = build_constraint_feature_enabled + self._constraints = constraints or [] + + def _deprecation_constraint_check(self) -> None: + """ + Check for deprecation warning: PIP_CONSTRAINT affecting build environments. + + This warns when build-constraint feature is NOT enabled but regular constraints + match what PIP_CONSTRAINT environment variable points to. + """ + if self._build_constraint_feature_enabled: + return + + if self._build_constraints: + return + + if not self._constraints: + return + + if not os.environ.get("PIP_CONSTRAINT"): + return + + pip_constraint_files = [ + f for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() + ] + if pip_constraint_files and pip_constraint_files == self._constraints: + deprecated( + reason=( + "Setting PIP_CONSTRAINT will not affect " + "build constraints in the future," + ), + replacement=( + "to specify build constraints use --build-constraint or " + "PIP_BUILD_CONSTRAINT, to disable this warning without " + "any build constraints set --use-feature=build-constraint or " + 'PIP_USE_FEATURE="build-constraint"' + ), + gone_in="26.2", + issue=None, + ) def install( self, @@ -112,6 +164,8 @@ def install( kind: str, for_req: InstallRequirement | None, ) -> None: + self._deprecation_constraint_check() + finder = self.finder args: list[str] = [ sys.executable, @@ -167,6 +221,26 @@ def install( args.append("--pre") if finder.prefer_binary: args.append("--prefer-binary") + + # Handle build constraints + extra_environ: ExtraEnviron = {} + if self._build_constraint_feature_enabled: + args.extend(["--use-feature", "build-constraint"]) + + if self._build_constraints: + # Build constraints must be passed as both constraints + # and build constraints, so that nested builds receive + # build constraints + for constraint_file in self._build_constraints: + args.extend(["--constraint", constraint_file]) + args.extend(["--build-constraint", constraint_file]) + + if self._build_constraint_feature_enabled and not self._build_constraints: + # If there are no build constraints but the build constraint + # feature is enabled then we must ignore regular constraints + # in the isolated build environment + extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}} + args.append("--") args.extend(requirements) with open_spinner(f"Installing {kind}") as spinner: @@ -174,6 +248,7 @@ def install( args, command_desc=f"pip subprocess to install {kind}", spinner=spinner, + **extra_environ, ) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 3519dadf13d..2367c816743 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -101,6 +101,18 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None: ) +def check_build_constraints(options: Values) -> None: + """Function for validating build constraint options. + + :param options: The OptionParser options. + """ + if hasattr(options, "build_constraints") and options.build_constraints: + if not options.build_isolation: + raise CommandError( + "--build-constraint cannot be used with --no-build-isolation." + ) + + def _path_option_check(option: Option, opt: str, value: str) -> str: return os.path.expanduser(value) @@ -430,6 +442,21 @@ def constraints() -> Option: ) +def build_constraint() -> Option: + return Option( + "--build-constraint", + dest="build_constraints", + action="append", + type="str", + default=[], + metavar="file", + help=( + "Constrain build dependencies using the given constraints file. " + "This option can be used multiple times." + ), + ) + + def requirements() -> Option: return Option( "-r", @@ -1072,6 +1099,7 @@ def check_list_path_option(options: Values) -> None: default=[], choices=[ "fast-deps", + "build-constraint", ] + ALWAYS_ENABLED_FEATURES, help="Enable new functionality, that may be backward incompatible.", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index dc1328ff019..31f22fb7914 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import os from functools import partial from optparse import Values from typing import Any @@ -44,6 +45,16 @@ logger = logging.getLogger(__name__) +def should_ignore_regular_constraints(options: Values) -> bool: + """ + Check if regular constraints should be ignored because + we are in a isolated build process and build constraints + feature is enabled but no build constraints were passed. + """ + + return os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1" + + KEEPABLE_TEMPDIR_TYPES = [ tempdir_kinds.BUILD_ENV, tempdir_kinds.EPHEM_WHEEL_CACHE, @@ -132,12 +143,26 @@ def make_requirement_preparer( "fast-deps has no effect when used with the legacy resolver." ) + # Handle build constraints + build_constraints = getattr(options, "build_constraints", []) + constraints = getattr(options, "constraints", []) + build_constraint_feature_enabled = ( + hasattr(options, "features_enabled") + and options.features_enabled + and "build-constraint" in options.features_enabled + ) + return RequirementPreparer( build_dir=temp_build_dir_path, src_dir=options.src_dir, download_dir=download_dir, build_isolation=options.build_isolation, - build_isolation_installer=SubprocessBuildEnvironmentInstaller(finder), + build_isolation_installer=SubprocessBuildEnvironmentInstaller( + finder, + build_constraints=build_constraints, + build_constraint_feature_enabled=build_constraint_feature_enabled, + constraints=constraints, + ), check_build_deps=options.check_build_deps, build_tracker=build_tracker, session=session, @@ -221,20 +246,22 @@ def get_requirements( Parse command-line arguments into the corresponding requirements. """ requirements: list[InstallRequirement] = [] - for filename in options.constraints: - for parsed_req in parse_requirements( - filename, - constraint=True, - finder=finder, - options=options, - session=session, - ): - req_to_add = install_req_from_parsed_requirement( - parsed_req, - isolated=options.isolated_mode, - user_supplied=False, - ) - requirements.append(req_to_add) + + if not should_ignore_regular_constraints(options): + for filename in options.constraints: + for parsed_req in parse_requirements( + filename, + constraint=True, + finder=finder, + options=options, + session=session, + ): + req_to_add = install_req_from_parsed_requirement( + parsed_req, + isolated=options.isolated_mode, + user_supplied=False, + ) + requirements.append(req_to_add) for req in args: req_to_add = install_req_from_line( diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 900fb403d6f..aa844613870 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -36,6 +36,7 @@ class DownloadCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.build_constraint()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.global_options()) @@ -81,6 +82,7 @@ def run(self, options: Values, args: list[str]) -> int: options.editables = [] cmdoptions.check_dist_restriction(options) + cmdoptions.check_build_constraints(options) options.download_dir = normalize_path(options.download_dir) ensure_dir(options.download_dir) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 1ef7a0f4410..550189c6d9e 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -87,6 +87,7 @@ class InstallCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.build_constraint()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) @@ -303,6 +304,7 @@ def run(self, options: Values, args: list[str]) -> int: if options.upgrade: upgrade_strategy = options.upgrade_strategy + cmdoptions.check_build_constraints(options) cmdoptions.check_dist_restriction(options, check_target=True) logger.verbose("Using %s", get_pip_version()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index e4a978d5aaa..b499a871bdb 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -59,6 +59,7 @@ def add_options(self) -> None: ) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.build_constraint()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) @@ -98,6 +99,8 @@ def run(self, options: Values, args: list[str]) -> int: "without prior warning." ) + cmdoptions.check_build_constraints(options) + session = self.get_default_session(options) finder = self._build_package_finder( diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 61be254912f..2b2847805b7 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -60,6 +60,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.no_use_pep517()) self.cmd_opts.add_option(cmdoptions.check_build_deps()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.build_constraint()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.src()) @@ -101,6 +102,8 @@ def add_options(self) -> None: @with_cleanup def run(self, options: Values, args: list[str]) -> int: + cmdoptions.check_build_constraints(options) + session = self.get_default_session(options) finder = self._build_package_finder(options, session) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py new file mode 100644 index 00000000000..2c3749d3ca6 --- /dev/null +++ b/tests/functional/test_build_constraints.py @@ -0,0 +1,176 @@ +"""Tests for the build constraints feature.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.lib import PipTestEnvironment, TestPipResult, create_test_package_with_setup + + +def _create_simple_test_package(script: PipTestEnvironment, name: str) -> Path: + """Create a simple test package with minimal setup.""" + return create_test_package_with_setup( + script, + name=name, + version="1.0", + py_modules=[name], + ) + + +def _create_constraints_file( + script: PipTestEnvironment, filename: str, content: str +) -> Path: + """Create a constraints file with the given content.""" + constraints_file = script.scratch_path / filename + constraints_file.write_text(content) + return constraints_file + + +def _run_pip_install_with_build_constraints( + script: PipTestEnvironment, + project_dir: Path, + build_constraints_file: Path, + extra_args: list[str] | None = None, + expect_error: bool = False, +) -> TestPipResult: + """Run pip install with build constraints and common arguments.""" + args = [ + "install", + "--no-cache-dir", + "--build-constraint", + str(build_constraints_file), + "--use-feature", + "build-constraint", + ] + + if extra_args: + args.extend(extra_args) + + args.append(str(project_dir)) + + return script.pip(*args, expect_error=expect_error) + + +def _assert_successful_installation(result: TestPipResult, package_name: str) -> None: + """Assert that the package was successfully installed.""" + assert f"Successfully installed {package_name}" in result.stdout + + +def _run_pip_install_with_build_constraints_no_feature_flag( + script: PipTestEnvironment, + project_dir: Path, + constraints_file: Path, +) -> TestPipResult: + """Run pip install with build constraints but without the feature flag.""" + return script.pip( + "install", + "--build-constraint", + str(constraints_file), + str(project_dir), + ) + + +def test_build_constraints_basic_functionality_simple( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test that build constraints options are accepted and processed.""" + project_dir = _create_simple_test_package( + script=script, name="test_build_constraints" + ) + constraints_file = _create_constraints_file( + script=script, filename="constraints.txt", content="setuptools>=40.0.0\n" + ) + result = _run_pip_install_with_build_constraints( + script=script, project_dir=project_dir, build_constraints_file=constraints_file + ) + _assert_successful_installation( + result=result, package_name="test_build_constraints" + ) + + +@pytest.mark.network +def test_build_constraints_vs_regular_constraints_simple( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test that build constraints and regular constraints work independently.""" + project_dir = create_test_package_with_setup( + script, + name="test_isolation", + version="1.0", + py_modules=["test_isolation"], + install_requires=["six"], + ) + build_constraints_file = _create_constraints_file( + script=script, filename="build_constraints.txt", content="setuptools>=40.0.0\n" + ) + regular_constraints_file = _create_constraints_file( + script=script, filename="constraints.txt", content="six>=1.10.0\n" + ) + result = script.pip( + "install", + "--no-cache-dir", + "--build-constraint", + build_constraints_file, + "--constraint", + regular_constraints_file, + "--use-feature", + "build-constraint", + str(project_dir), + expect_error=False, + ) + assert "Successfully installed" in result.stdout + assert "test_isolation" in result.stdout + + +@pytest.mark.network +def test_build_constraints_environment_isolation_simple( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test that build constraints work correctly in isolated build environments.""" + project_dir = _create_simple_test_package(script=script, name="test_env_isolation") + constraints_file = _create_constraints_file( + script=script, filename="build_constraints.txt", content="setuptools>=40.0.0\n" + ) + result = _run_pip_install_with_build_constraints( + script=script, + project_dir=project_dir, + build_constraints_file=constraints_file, + extra_args=["--isolated"], + ) + _assert_successful_installation(result=result, package_name="test_env_isolation") + + +def test_build_constraints_file_not_found( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test behavior when build constraints file doesn't exist.""" + project_dir = _create_simple_test_package( + script=script, name="test_missing_constraints" + ) + missing_constraints = script.scratch_path / "missing_constraints.txt" + result = _run_pip_install_with_build_constraints( + script=script, + project_dir=project_dir, + build_constraints_file=missing_constraints, + ) + _assert_successful_installation( + result=result, package_name="test_missing_constraints" + ) + + +def test_build_constraints_without_feature_flag( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test that --build-constraint automatically enables the feature.""" + project_dir = _create_simple_test_package(script=script, name="test_no_feature") + constraints_file = _create_constraints_file( + script=script, filename="constraints.txt", content="setuptools==45.0.0\n" + ) + result = _run_pip_install_with_build_constraints_no_feature_flag( + script=script, project_dir=project_dir, constraints_file=constraints_file + ) + # Should succeed now that --build-constraint auto-enables the feature + assert result.returncode == 0 + _assert_successful_installation(result=result, package_name="test_no_feature") diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py new file mode 100644 index 00000000000..5da4c32844e --- /dev/null +++ b/tests/unit/test_build_constraints.py @@ -0,0 +1,140 @@ +"""Tests for build constraints functionality.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest import mock + +import pytest + +from pip._internal.build_env import SubprocessBuildEnvironmentInstaller, _Prefix +from pip._internal.utils.deprecation import PipDeprecationWarning + +from tests.lib import make_test_finder + + +class TestSubprocessBuildEnvironmentInstaller: + """Test SubprocessBuildEnvironmentInstaller build constraints functionality.""" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_deprecation_check_no_pip_constraint(self) -> None: + """Test no deprecation warning is shown when PIP_CONSTRAINT is not set.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + build_constraint_feature_enabled=False, + ) + + # Should not raise any warning + installer._deprecation_constraint_check() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_deprecation_check_feature_enabled(self) -> None: + """ + Test no deprecation warning is shown when + build-constraint feature is enabled + """ + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + build_constraint_feature_enabled=True, + ) + + # Should not raise any warning + installer._deprecation_constraint_check() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_deprecation_check_constraint_mismatch(self) -> None: + """ + Test no deprecation warning is shown when + PIP_CONSTRAINT doesn't match regular constraints. + """ + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["different.txt"], + build_constraint_feature_enabled=False, + ) + + # Should not raise any warning + installer._deprecation_constraint_check() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_deprecation_check_warning_shown(self) -> None: + """Test deprecation warning is shown when conditions are met.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning) as warning_info: + installer._deprecation_constraint_check() + + assert len(warning_info) == 1 + message = str(warning_info[0].message) + assert ( + "Setting PIP_CONSTRAINT will not affect build constraints in the future" + in message + ) + assert ( + "to specify build constraints use " + "--build-constraint or PIP_BUILD_CONSTRAINT" in message + ) + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) + def test_deprecation_check_multiple_constraints(self) -> None: + """Test deprecation warning works with multiple constraints.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint1.txt", "constraint2.txt"], + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning): + installer._deprecation_constraint_check() + + @mock.patch.dict( + os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt extra.txt"} + ) + def test_deprecation_check_partial_match_no_warning(self) -> None: + """Test no deprecation warning is shown when only partial match.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint1.txt", "constraint2.txt"], + build_constraint_feature_enabled=False, + ) + + # Should not raise any warning since PIP_CONSTRAINT has extra file + installer._deprecation_constraint_check() + + @mock.patch("pip._internal.build_env.call_subprocess") + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_install_calls_deprecation_check( + self, mock_call_subprocess: mock.Mock, tmp_path: Path + ) -> None: + """Test install method calls deprecation check.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + build_constraint_feature_enabled=False, + ) + prefix = _Prefix(str(tmp_path)) + + with pytest.warns(PipDeprecationWarning): + installer.install( + requirements=["setuptools"], + prefix=prefix, + kind="build dependencies", + for_req=None, + ) + + # Verify that call_subprocess was called (install proceeded after warning) + mock_call_subprocess.assert_called_once()