From 2e4086ea67cb2572f75301aa74d3f4993b3adf9e Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:18:53 -0400 Subject: [PATCH 01/19] Implement build constraints --- src/pip/_internal/build_env.py | 71 +++++++++++++++++++++++++- src/pip/_internal/cli/cmdoptions.py | 34 ++++++++++++ src/pip/_internal/cli/req_command.py | 57 +++++++++++++++------ src/pip/_internal/commands/download.py | 2 + src/pip/_internal/commands/install.py | 2 + src/pip/_internal/commands/lock.py | 3 ++ src/pip/_internal/commands/wheel.py | 3 ++ 7 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 3a246a1e349..68bf235486d 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,49 @@ 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 not self._constraints: + return + + if not os.environ.get("PIP_CONSTRAINT"): + return + + pip_constraint_files = [ + f.strip() for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() + ] + if pip_constraint_files and set(pip_constraint_files) == set(self._constraints): + deprecated( + reason=( + "Setting PIP_CONSTRAINT will not affect " + "build constraints in the future," + ), + replacement=( + 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' + ), + gone_in="26.2", + issue=None, + ) def install( self, @@ -112,6 +158,8 @@ def install( kind: str, for_req: InstallRequirement | None, ) -> None: + self._deprecation_constraint_check() + finder = self.finder args: list[str] = [ sys.executable, @@ -167,6 +215,24 @@ 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: + # Build constraints must be passed as both constraints + # and build constraints to the subprocess + for constraint_file in self._build_constraints: + args.extend(["--constraint", constraint_file]) + args.extend(["--build-constraint", constraint_file]) + args.extend(["--use-feature", "build-constraint"]) + + # If there are no build constraints but the build constraint + # process is enabled then we must ignore regular constraints + if not self._build_constraints: + 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 +240,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..d0be83316d0 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -101,6 +101,23 @@ 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 "build-constraint" not in options.features_enabled: + raise CommandError( + "To use --build-constraint, you must enable this feature with " + "--use-feature=build-constraint." + ) + 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 +447,22 @@ 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. " + "Requires --use-feature=build-constraint." + ), + ) + + def requirements() -> Option: return Option( "-r", @@ -1072,6 +1105,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) From 1aa1d32863bbaa22bd0029552a66d5592b6974f2 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:19:02 -0400 Subject: [PATCH 02/19] Add build constraints tests --- tests/functional/test_build_constraints.py | 176 +++++++++++++++++++++ tests/unit/test_build_constraints.py | 150 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 tests/functional/test_build_constraints.py create mode 100644 tests/unit/test_build_constraints.py diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py new file mode 100644 index 00000000000..b3a9067bcd1 --- /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), + expect_error=True, + ) + + +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 requires the feature flag.""" + 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 + ) + assert result.returncode != 0 + assert "build-constraint" in result.stderr.lower() diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py new file mode 100644 index 00000000000..c851a4505d7 --- /dev/null +++ b/tests/unit/test_build_constraints.py @@ -0,0 +1,150 @@ +"""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 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="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"}) + def test_deprecation_check_multiple_constraints_different_order(self) -> None: + """Test deprecation warning works when constraints are in different order.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint2.txt", "constraint1.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() From db4fcf71d74d4e0a68cc17e4583229ba3f22a44f Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:42:13 -0400 Subject: [PATCH 03/19] Add build constraints to user guide --- docs/html/user_guide.rst | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index d6a0acf9cd8..b26db5f0221 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -257,6 +257,51 @@ 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.2 +.. note:: + + Build constraints are currently an **experimental feature** and must be + enabled using ``--use-feature=build-constraint``. + +Build constraints are a specialized type of constraints file that apply only +to the build environment when building packages from source. Unlike regular +constraints which affect the installed packages in your environment, build +constraints only influence the versions of packages available during the +build process. + +This is particularly useful when you need to constrain build dependencies +(like ``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 --use-feature=build-constraint SomePackage + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + +Example build constraints file (``build-constraints.txt``): + +.. code-block:: text + + # Constrain setuptools version during build + setuptools>=45.0.0,<60.0.0 + # Pin Cython for packages that use it + cython==0.29.24 + + .. _`Dependency Groups`: From d9d5f5d084d7e7baca0b29f942317b49d7995710 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:45:55 -0400 Subject: [PATCH 04/19] NEWS ENTRY --- news/13534.feature.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 news/13534.feature.rst diff --git a/news/13534.feature.rst b/news/13534.feature.rst new file mode 100644 index 00000000000..8500f8063a2 --- /dev/null +++ b/news/13534.feature.rst @@ -0,0 +1,4 @@ +Add experimental build constraints support via ``--use-feature=build-constraint``. +This allows constraining the versions of packages used during the build process +(e.g., setuptools). Build constraints can be specified via ``PIP_BUILD_CONSTRAINT`` +environment variable or ``--build-constraint`` flag. From 8d170b56915c76345e63f4a5f5c390213badc825 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 9 Aug 2025 10:08:57 -0400 Subject: [PATCH 05/19] Imply using new behavior when build constraints are provided without needing to use the `build-constraint` feature --- docs/html/user_guide.rst | 24 ++++++++----------- news/13534.feature.rst | 12 ++++++---- src/pip/_internal/build_env.py | 28 ++++++++++++++-------- src/pip/_internal/cli/cmdoptions.py | 8 +------ tests/functional/test_build_constraints.py | 8 +++---- tests/unit/test_build_constraints.py | 18 ++++---------- 6 files changed, 45 insertions(+), 53 deletions(-) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index b26db5f0221..30c514f7e59 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -262,20 +262,16 @@ serve them in a centralized place. Build Constraints ----------------- -.. versionadded:: 25.2 -.. note:: - - Build constraints are currently an **experimental feature** and must be - enabled using ``--use-feature=build-constraint``. +.. versionadded:: 25.3 -Build constraints are a specialized type of constraints file that apply only -to the build environment when building packages from source. Unlike regular -constraints which affect the installed packages in your environment, build +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 particularly useful when you need to constrain build dependencies -(like ``setuptools``, ``cython``, etc.) without affecting the +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: @@ -284,21 +280,21 @@ Use build constraints like so: .. code-block:: shell - python -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + python -m pip install --build-constraint build-constraints.txt SomePackage .. tab:: Windows .. code-block:: shell - py -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + 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.0.0,<60.0.0 - # Pin Cython for packages that use it + setuptools>=45,<80 + # Pin Cython for packages that use it to build cython==0.29.24 diff --git a/news/13534.feature.rst b/news/13534.feature.rst index 8500f8063a2..6d7635ace48 100644 --- a/news/13534.feature.rst +++ b/news/13534.feature.rst @@ -1,4 +1,8 @@ -Add experimental build constraints support via ``--use-feature=build-constraint``. -This allows constraining the versions of packages used during the build process -(e.g., setuptools). Build constraints can be specified via ``PIP_BUILD_CONSTRAINT`` -environment variable or ``--build-constraint`` flag. +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 68bf235486d..b78894d014b 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -128,6 +128,9 @@ def _deprecation_constraint_check(self) -> None: if self._build_constraint_feature_enabled: return + if self._build_constraints: + return + if not self._constraints: return @@ -135,16 +138,19 @@ def _deprecation_constraint_check(self) -> None: return pip_constraint_files = [ - f.strip() for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() + f for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() ] - if pip_constraint_files and set(pip_constraint_files) == set(self._constraints): + if pip_constraint_files and pip_constraint_files == self._constraints: deprecated( reason=( "Setting PIP_CONSTRAINT will not affect " "build constraints in the future," ), replacement=( - 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' + "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, @@ -219,19 +225,21 @@ def install( # 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 to the subprocess + # 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]) - args.extend(["--use-feature", "build-constraint"]) + if self._build_constraint_feature_enabled and not self._build_constraints: # If there are no build constraints but the build constraint - # process is enabled then we must ignore regular constraints - if not self._build_constraints: - extra_environ = { - "extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"} - } + # 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) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index d0be83316d0..2367c816743 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -107,11 +107,6 @@ def check_build_constraints(options: Values) -> None: :param options: The OptionParser options. """ if hasattr(options, "build_constraints") and options.build_constraints: - if "build-constraint" not in options.features_enabled: - raise CommandError( - "To use --build-constraint, you must enable this feature with " - "--use-feature=build-constraint." - ) if not options.build_isolation: raise CommandError( "--build-constraint cannot be used with --no-build-isolation." @@ -457,8 +452,7 @@ def build_constraint() -> Option: metavar="file", help=( "Constrain build dependencies using the given constraints file. " - "This option can be used multiple times. " - "Requires --use-feature=build-constraint." + "This option can be used multiple times." ), ) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index b3a9067bcd1..2c3749d3ca6 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -69,7 +69,6 @@ def _run_pip_install_with_build_constraints_no_feature_flag( "--build-constraint", str(constraints_file), str(project_dir), - expect_error=True, ) @@ -164,7 +163,7 @@ def test_build_constraints_file_not_found( def test_build_constraints_without_feature_flag( script: PipTestEnvironment, tmpdir: Path ) -> None: - """Test that --build-constraint requires the feature flag.""" + """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" @@ -172,5 +171,6 @@ def test_build_constraints_without_feature_flag( result = _run_pip_install_with_build_constraints_no_feature_flag( script=script, project_dir=project_dir, constraints_file=constraints_file ) - assert result.returncode != 0 - assert "build-constraint" in result.stderr.lower() + # 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 index c851a4505d7..5da4c32844e 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -81,7 +81,10 @@ def test_deprecation_check_warning_shown(self) -> None: "Setting PIP_CONSTRAINT will not affect build constraints in the future" in message ) - assert 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' 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: @@ -96,19 +99,6 @@ def test_deprecation_check_multiple_constraints(self) -> None: with pytest.warns(PipDeprecationWarning): installer._deprecation_constraint_check() - @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) - def test_deprecation_check_multiple_constraints_different_order(self) -> None: - """Test deprecation warning works when constraints are in different order.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - constraints=["constraint2.txt", "constraint1.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"} ) From 9f9032c6c78aed7864c7b696398f30091c2e126f Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 13 Aug 2025 20:09:31 -0400 Subject: [PATCH 06/19] Update src/pip/_internal/cli/req_command.py Co-authored-by: Richard Si --- src/pip/_internal/cli/req_command.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 31f22fb7914..fabf471627f 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -146,11 +146,7 @@ def make_requirement_preparer( # 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 - ) + build_constraint_feature_enabled = "build-constraint" in options.features_enabled return RequirementPreparer( build_dir=temp_build_dir_path, From a9e81d779cf3ba793dd192ff8552d87d85b69af0 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 13 Aug 2025 20:11:19 -0400 Subject: [PATCH 07/19] Update src/pip/_internal/build_env.py Co-authored-by: Richard Si --- src/pip/_internal/build_env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index b78894d014b..1939ac33194 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -147,8 +147,8 @@ def _deprecation_constraint_check(self) -> None: "build constraints in the future," ), replacement=( - "to specify build constraints use --build-constraint or " - "PIP_BUILD_CONSTRAINT, to disable this warning without " + "to specify build constraints using --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"' ), From 8943172fd950a63e9e8ebf3798194f65218b860b Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 18:54:45 -0400 Subject: [PATCH 08/19] Fix linting --- src/pip/_internal/cli/req_command.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index fabf471627f..62b9d919aae 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -146,7 +146,9 @@ def make_requirement_preparer( # Handle build constraints build_constraints = getattr(options, "build_constraints", []) constraints = getattr(options, "constraints", []) - build_constraint_feature_enabled = "build-constraint" in options.features_enabled + build_constraint_feature_enabled = ( + "build-constraint" in options.features_enabled + ) return RequirementPreparer( build_dir=temp_build_dir_path, From ef06010f448df66503705e2917ca01c0e82c73a8 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:10:41 -0400 Subject: [PATCH 09/19] Fix test --- tests/unit/test_build_constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index 5da4c32844e..daaf20ce092 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -82,7 +82,7 @@ def test_deprecation_check_warning_shown(self) -> None: in message ) assert ( - "to specify build constraints use " + "to specify build constraints using " "--build-constraint or PIP_BUILD_CONSTRAINT" in message ) From d564457343c9b5f822e03ad2254381782bd96e90 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:11:11 -0400 Subject: [PATCH 10/19] Consistently use "build constraints" in variables and documentation --- src/pip/_internal/build_env.py | 2 +- src/pip/_internal/cli/cmdoptions.py | 4 ++-- src/pip/_internal/commands/download.py | 2 +- src/pip/_internal/commands/install.py | 2 +- src/pip/_internal/commands/lock.py | 2 +- src/pip/_internal/commands/wheel.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 1939ac33194..f8668a3a431 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -236,7 +236,7 @@ def install( 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 + # If there are no build constraints but the build constraints # feature is enabled then we must ignore regular constraints # in the isolated build environment extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}} diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 2367c816743..b48abc9c2ac 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -102,7 +102,7 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None: def check_build_constraints(options: Values) -> None: - """Function for validating build constraint options. + """Function for validating build constraints options. :param options: The OptionParser options. """ @@ -442,7 +442,7 @@ def constraints() -> Option: ) -def build_constraint() -> Option: +def build_constraints() -> Option: return Option( "--build-constraint", dest="build_constraints", diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index aa844613870..d8f8fbdacd5 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -36,7 +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.build_constraints()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.global_options()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 550189c6d9e..f545666269f 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -87,7 +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.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index b499a871bdb..71d22007f1f 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -59,7 +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.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 2b2847805b7..1a3287218bf 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -60,7 +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.build_constraints()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.src()) From ebd55e71532dc169b212cbecabc534f9b3972b41 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:42:01 -0400 Subject: [PATCH 11/19] Simplify deprecation warning --- src/pip/_internal/build_env.py | 45 +++++++---------- tests/unit/test_build_constraints.py | 74 +++++++++------------------- 2 files changed, 42 insertions(+), 77 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index f8668a3a431..8c553b1d1c4 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -122,39 +122,30 @@ 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. + This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT + is not empty. """ if self._build_constraint_feature_enabled: return - if self._build_constraints: - return - - if not self._constraints: - return - - if not os.environ.get("PIP_CONSTRAINT"): + pip_constraint = os.environ.get("PIP_CONSTRAINT") + if not pip_constraint or not pip_constraint.strip(): 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 using --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, - ) + deprecated( + reason=( + "Setting PIP_CONSTRAINT will not affect " + "build constraints in the future," + ), + replacement=( + "to specify build constraints using --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, diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index daaf20ce092..b9505b7bd94 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -19,56 +19,59 @@ class TestSubprocessBuildEnvironmentInstaller: @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.""" + """Test no deprecation warning 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 - """ + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": ""}) + def test_deprecation_check_empty_pip_constraint(self) -> None: + """Test no deprecation warning for empty PIP_CONSTRAINT.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], - build_constraint_feature_enabled=True, + build_constraint_feature_enabled=False, ) - # Should not raise any warning + # Should not raise any warning since PIP_CONSTRAINT is empty 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. - """ + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": " "}) + def test_deprecation_check_whitespace_pip_constraint(self) -> None: + """Test no deprecation warning for whitespace-only PIP_CONSTRAINT.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["different.txt"], build_constraint_feature_enabled=False, ) + # Should not raise any warning since PIP_CONSTRAINT is only whitespace + 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 when build-constraint feature is enabled.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + 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_warning_shown(self) -> None: - """Test deprecation warning is shown when conditions are met.""" + """Test deprecation warning emitted when PIP_CONSTRAINT is set + and build-constraint is not enabled.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], build_constraint_feature_enabled=False, ) @@ -86,44 +89,15 @@ def test_deprecation_check_warning_shown(self) -> None: "--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.""" + """Test install method calls deprecation check and proceeds with warning.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], build_constraint_feature_enabled=False, ) prefix = _Prefix(str(tmp_path)) From c41496e5bdd69e9a245ba906605c82fd162a0a67 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:53:09 -0400 Subject: [PATCH 12/19] Only emit pip constraint deprecation warning once --- src/pip/_internal/build_env.py | 15 +++++++++- tests/unit/test_build_constraints.py | 41 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 8c553b1d1c4..6a182128b06 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -31,6 +31,9 @@ logger = logging.getLogger(__name__) +# Global flag to track if deprecation warning has been shown +_DEPRECATION_WARNING_SHOWN = False + class ExtraEnviron(TypedDict, total=False): extra_environ: dict[str, str] @@ -123,15 +126,25 @@ def _deprecation_constraint_check(self) -> None: Check for deprecation warning: PIP_CONSTRAINT affecting build environments. This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT - is not empty. + is not empty, but only shows the warning once per process. """ + global _DEPRECATION_WARNING_SHOWN + if self._build_constraint_feature_enabled: return + if _DEPRECATION_WARNING_SHOWN: + return + pip_constraint = os.environ.get("PIP_CONSTRAINT") if not pip_constraint or not pip_constraint.strip(): return + # Don't warn if we're in a build environment that ignores constraints + if os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1": + return + + _DEPRECATION_WARNING_SHOWN = True deprecated( reason=( "Setting PIP_CONSTRAINT will not affect " diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index b9505b7bd94..d5d467d1aea 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import warnings from pathlib import Path from unittest import mock @@ -17,6 +18,12 @@ class TestSubprocessBuildEnvironmentInstaller: """Test SubprocessBuildEnvironmentInstaller build constraints functionality.""" + def setup_method(self) -> None: + """Reset the global deprecation warning flag before each test.""" + import pip._internal.build_env + + pip._internal.build_env._DEPRECATION_WARNING_SHOWN = False + @mock.patch.dict(os.environ, {}, clear=True) def test_deprecation_check_no_pip_constraint(self) -> None: """Test no deprecation warning when PIP_CONSTRAINT is not set.""" @@ -112,3 +119,37 @@ def test_install_calls_deprecation_check( # Verify that call_subprocess was called (install proceeded after warning) mock_call_subprocess.assert_called_once() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_deprecation_check_warning_shown_only_once(self) -> None: + """Test deprecation warning is shown only once per process.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning): + installer._deprecation_constraint_check() + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + installer._deprecation_constraint_check() + assert len(warning_list) == 0 + + @mock.patch.dict( + os.environ, + {"PIP_CONSTRAINT": "constraints.txt", "_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}, + ) + def test_deprecation_check_no_warning_when_ignoring_constraints(self) -> None: + """Test no deprecation warning when _PIP_IN_BUILD_IGNORE_CONSTRAINTS is set.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + build_constraint_feature_enabled=False, + ) + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + installer._deprecation_constraint_check() + assert len(warning_list) == 0 From e015f3d961431f253ef6687e8d7768f44804e51d Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:55:15 -0400 Subject: [PATCH 13/19] Move `ExtraEnviron` into type checking block --- src/pip/_internal/build_env.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 6a182128b06..e262aafbc12 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -29,16 +29,16 @@ from pip._internal.index.package_finder import PackageFinder from pip._internal.req.req_install import InstallRequirement + class ExtraEnviron(TypedDict, total=False): + extra_environ: dict[str, str] + + logger = logging.getLogger(__name__) # Global flag to track if deprecation warning has been shown _DEPRECATION_WARNING_SHOWN = False -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,) @@ -227,7 +227,6 @@ def install( args.append("--prefer-binary") # Handle build constraints - extra_environ: ExtraEnviron = {} if self._build_constraint_feature_enabled: args.extend(["--use-feature", "build-constraint"]) @@ -239,6 +238,7 @@ def install( args.extend(["--constraint", constraint_file]) args.extend(["--build-constraint", constraint_file]) + extra_environ: ExtraEnviron = {} if self._build_constraint_feature_enabled and not self._build_constraints: # If there are no build constraints but the build constraints # feature is enabled then we must ignore regular constraints From b333b85e2d9bd9d303987f2ed397379713de315b Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:06:37 -0400 Subject: [PATCH 14/19] Use standard `assert_installed` in functional tests for build constraints --- tests/functional/test_build_constraints.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index 2c3749d3ca6..c56414a082e 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -53,11 +53,6 @@ def _run_pip_install_with_build_constraints( 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, @@ -85,8 +80,8 @@ def test_build_constraints_basic_functionality_simple( 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" + result.assert_installed( + "test-build-constraints", editable=False, without_files=["."] ) @@ -139,7 +134,7 @@ def test_build_constraints_environment_isolation_simple( build_constraints_file=constraints_file, extra_args=["--isolated"], ) - _assert_successful_installation(result=result, package_name="test_env_isolation") + result.assert_installed("test-env-isolation", editable=False, without_files=["."]) def test_build_constraints_file_not_found( @@ -155,8 +150,8 @@ def test_build_constraints_file_not_found( project_dir=project_dir, build_constraints_file=missing_constraints, ) - _assert_successful_installation( - result=result, package_name="test_missing_constraints" + result.assert_installed( + "test-missing-constraints", editable=False, without_files=["."] ) @@ -173,4 +168,4 @@ def test_build_constraints_without_feature_flag( ) # Should succeed now that --build-constraint auto-enables the feature assert result.returncode == 0 - _assert_successful_installation(result=result, package_name="test_no_feature") + result.assert_installed("test-no-feature", editable=False, without_files=["."]) From bc48f0ba6cdc1ba782ea14c6f0483f32906abf4e Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:25:22 -0400 Subject: [PATCH 15/19] Eagerly assert build constraints files --- src/pip/_internal/cli/cmdoptions.py | 14 ++++++++++++++ tests/functional/test_build_constraints.py | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index b48abc9c2ac..6174d0d93ba 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -112,6 +112,20 @@ def check_build_constraints(options: Values) -> None: "--build-constraint cannot be used with --no-build-isolation." ) + # Import here to avoid circular imports + from pip._internal.network.session import PipSession + from pip._internal.req.req_file import get_file_content + + session = PipSession() + try: + # Eagerly check build constraints file contents + # is valid so that we don't fail in when trying + # to check constraints in isolated build process + for constraint_file in options.build_constraints: + get_file_content(constraint_file, session) + finally: + session.close() + def _path_option_check(option: Option, opt: str, value: str) -> str: return os.path.expanduser(value) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index c56414a082e..5ed6dc954fb 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -149,10 +149,10 @@ def test_build_constraints_file_not_found( script=script, project_dir=project_dir, build_constraints_file=missing_constraints, + expect_error=True, ) - result.assert_installed( - "test-missing-constraints", editable=False, without_files=["."] - ) + assert "Could not open requirements file" in result.stderr + assert "No such file or directory" in result.stderr def test_build_constraints_without_feature_flag( From fc1bfb55140163f52fdfca1b066f168bfbbf56f7 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:38:55 -0400 Subject: [PATCH 16/19] Add deprecation news item. --- news/13534.feature.rst | 7 +------ news/13534.removal.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 news/13534.removal.rst diff --git a/news/13534.feature.rst b/news/13534.feature.rst index 6d7635ace48..541fd852d14 100644 --- a/news/13534.feature.rst +++ b/news/13534.feature.rst @@ -1,8 +1,3 @@ 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``. +(e.g., setuptools) without affecting the final installation. diff --git a/news/13534.removal.rst b/news/13534.removal.rst new file mode 100644 index 00000000000..c8212bb15b2 --- /dev/null +++ b/news/13534.removal.rst @@ -0,0 +1,8 @@ +Deprecate the ``PIP_CONSTRAINT`` environment variable for specifying build +constraints. + +Build constraints should now be specified using the ``--build-constraint`` +option or the ``PIP_BUILD_CONSTRAINT`` environment variable. When using build +constraints, ``PIP_CONSTRAINT`` no longer affects isolated build environments. +To opt in to this behavior without specifying any build constraints, use +``--use-feature=build-constraint``. From 74b08e11d027abe5d62978977990bd84f2cad4bc Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:47:01 -0400 Subject: [PATCH 17/19] Remove pointless check for `_PIP_IN_BUILD_IGNORE_CONSTRAINTS` in `_deprecation_constraint_check` --- src/pip/_internal/build_env.py | 4 ---- tests/unit/test_build_constraints.py | 17 ----------------- 2 files changed, 21 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index e262aafbc12..36f07b072e0 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -140,10 +140,6 @@ def _deprecation_constraint_check(self) -> None: if not pip_constraint or not pip_constraint.strip(): return - # Don't warn if we're in a build environment that ignores constraints - if os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1": - return - _DEPRECATION_WARNING_SHOWN = True deprecated( reason=( diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index d5d467d1aea..719fd7ea714 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -136,20 +136,3 @@ def test_deprecation_check_warning_shown_only_once(self) -> None: warnings.simplefilter("always") installer._deprecation_constraint_check() assert len(warning_list) == 0 - - @mock.patch.dict( - os.environ, - {"PIP_CONSTRAINT": "constraints.txt", "_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}, - ) - def test_deprecation_check_no_warning_when_ignoring_constraints(self) -> None: - """Test no deprecation warning when _PIP_IN_BUILD_IGNORE_CONSTRAINTS is set.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - build_constraint_feature_enabled=False, - ) - - with warnings.catch_warnings(record=True) as warning_list: - warnings.simplefilter("always") - installer._deprecation_constraint_check() - assert len(warning_list) == 0 From 41164aa6fc3d840021ca2a7aa76c0b0bb45499ae Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 21:21:41 -0400 Subject: [PATCH 18/19] Exit `_deprecation_constraint_check` early when build constraints present --- src/pip/_internal/build_env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 36f07b072e0..7b125ab5992 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -130,7 +130,7 @@ def _deprecation_constraint_check(self) -> None: """ global _DEPRECATION_WARNING_SHOWN - if self._build_constraint_feature_enabled: + if self._build_constraint_feature_enabled or self._build_constraints: return if _DEPRECATION_WARNING_SHOWN: From e53db93d486701ca3563c29a837418c9b427f26c Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 20 Aug 2025 20:53:35 -0400 Subject: [PATCH 19/19] Remove superfluous `constraints` parameter --- src/pip/_internal/build_env.py | 2 -- src/pip/_internal/cli/req_command.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 7b125ab5992..27475e449b2 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -114,12 +114,10 @@ def __init__( 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: """ diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 62b9d919aae..640ac9fb908 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -145,7 +145,6 @@ def make_requirement_preparer( # Handle build constraints build_constraints = getattr(options, "build_constraints", []) - constraints = getattr(options, "constraints", []) build_constraint_feature_enabled = ( "build-constraint" in options.features_enabled ) @@ -159,7 +158,6 @@ def make_requirement_preparer( 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,