Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e4086e
Implement build constraints
notatallshaw Aug 9, 2025
1aa1d32
Add build constraints tests
notatallshaw Aug 9, 2025
db4fcf7
Add build constraints to user guide
notatallshaw Aug 9, 2025
d9d5f5d
NEWS ENTRY
notatallshaw Aug 9, 2025
8d170b5
Imply using new behavior when build constraints are provided without …
notatallshaw Aug 9, 2025
9f9032c
Update src/pip/_internal/cli/req_command.py
notatallshaw Aug 14, 2025
a9e81d7
Update src/pip/_internal/build_env.py
notatallshaw Aug 14, 2025
4fbafeb
Merge branch 'main' into add-build-constraints
notatallshaw Aug 14, 2025
8943172
Fix linting
notatallshaw Aug 19, 2025
ef06010
Fix test
notatallshaw Aug 19, 2025
d564457
Consistently use "build constraints" in variables and documentation
notatallshaw Aug 19, 2025
ebd55e7
Simplify deprecation warning
notatallshaw Aug 19, 2025
c41496e
Only emit pip constraint deprecation warning once
notatallshaw Aug 19, 2025
e015f3d
Move `ExtraEnviron` into type checking block
notatallshaw Aug 19, 2025
b333b85
Use standard `assert_installed` in functional tests for build constra…
notatallshaw Aug 20, 2025
bc48f0b
Eagerly assert build constraints files
notatallshaw Aug 20, 2025
fc1bfb5
Add deprecation news item.
notatallshaw Aug 20, 2025
74b08e1
Remove pointless check for `_PIP_IN_BUILD_IGNORE_CONSTRAINTS` in `_de…
notatallshaw Aug 20, 2025
41164aa
Exit `_deprecation_constraint_check` early when build constraints pre…
notatallshaw Aug 20, 2025
e53db93
Remove superfluous `constraints` parameter
notatallshaw Aug 21, 2025
f372c74
Merge branch 'main' into add-build-constraints
notatallshaw Aug 29, 2025
d86d520
Merge branch 'main' into add-build-constraints
notatallshaw Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/html/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`:


Expand Down
3 changes: 3 additions & 0 deletions news/13534.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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) without affecting the final installation.
8 changes: 8 additions & 0 deletions news/13534.removal.rst
Original file line number Diff line number Diff line change
@@ -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``.
77 changes: 75 additions & 2 deletions src/pip/_internal/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
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

from pip import __file__ as pip_location
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
Expand All @@ -28,8 +29,15 @@
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


def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]:
return (a, b) if a != b else (a,)
Expand Down Expand Up @@ -101,8 +109,50 @@ 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,
) -> None:
self.finder = finder
self._build_constraints = build_constraints or []
self._build_constraint_feature_enabled = build_constraint_feature_enabled

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, but only shows the warning once per process.
"""
global _DEPRECATION_WARNING_SHOWN

if self._build_constraint_feature_enabled or self._build_constraints:
return

if _DEPRECATION_WARNING_SHOWN:
return

pip_constraint = os.environ.get("PIP_CONSTRAINT")
if not pip_constraint or not pip_constraint.strip():
return

_DEPRECATION_WARNING_SHOWN = True
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,
Expand All @@ -112,6 +162,8 @@ def install(
kind: str,
for_req: InstallRequirement | None,
) -> None:
self._deprecation_constraint_check()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably issue this deprecation warning only once. In some scenarios, even a single build will trigger this deprecation twice as the build backend can request additional dependencies dynamically.

... although I say that having tried this locally... I can't get the deprecation to be printed twice. Hmm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, I'm not so sure this prevents it being called in the isolated build sub-process, but it will only be called once per process at least.


finder = self.finder
args: list[str] = [
sys.executable,
Expand Down Expand Up @@ -167,13 +219,34 @@ def install(
args.append("--pre")
if finder.prefer_binary:
args.append("--prefer-binary")

# Handle build constraints
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])

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
# 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:
call_subprocess(
args,
command_desc=f"pip subprocess to install {kind}",
spinner=spinner,
**extra_environ,
)


Expand Down
42 changes: 42 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None:
)


def check_build_constraints(options: Values) -> None:
"""Function for validating build constraints 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."
)

# 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)

Expand Down Expand Up @@ -430,6 +456,21 @@ def constraints() -> Option:
)


def build_constraints() -> 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",
Expand Down Expand Up @@ -1072,6 +1113,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.",
Expand Down
53 changes: 38 additions & 15 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import logging
import os
from functools import partial
from optparse import Values
from typing import Any
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -132,12 +143,22 @@ def make_requirement_preparer(
"fast-deps has no effect when used with the legacy resolver."
)

# Handle build constraints
build_constraints = getattr(options, "build_constraints", [])
build_constraint_feature_enabled = (
"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,
),
check_build_deps=options.check_build_deps,
build_tracker=build_tracker,
session=session,
Expand Down Expand Up @@ -221,20 +242,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(
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_constraints())
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.global_options())
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_constraints())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())

Expand Down Expand Up @@ -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())
Expand Down
3 changes: 3 additions & 0 deletions src/pip/_internal/commands/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_constraints())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading