Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 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
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
8 changes: 8 additions & 0 deletions news/13534.feature.rst
Original file line number Diff line number Diff line change
@@ -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``.
79 changes: 77 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 @@ -31,6 +32,10 @@
logger = logging.getLogger(__name__)


class ExtraEnviron(TypedDict, total=False):
extra_environ: dict[str, str]
Copy link
Member

Choose a reason for hiding this comment

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

What's the benefit of defining this typed dictionary? It seems superfluous IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

I am all open to suggestions that don't make the code more awkward (e.g writing a much larger if block) and keep mypy happy.

I could move it into the if type checking block though to eliminate any run time construction.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've moved in to type checking block, not sure what else to do.



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 +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:
Copy link
Member

Choose a reason for hiding this comment

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

I assume the scenario where the stored constraints do not match PIP_CONSTRAINT mostly involve configuration files?

Copy link
Member Author

Choose a reason for hiding this comment

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

This function got a little confused because I was initially going to throw the deprecation in the isolated build process, but given that doesn't output to the console by default that would have been a poor place to put it.

On reconsideration we should emit a deprecation if PIP_CONSTRAINT is at all non-empty. I will update.

configuration files?

I was mostly hoping that users aren't using configuration files to have build processes be constrained. I don't really know how to detect that.

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, check is far more simple now.

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,
Expand All @@ -112,6 +164,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 +221,34 @@ 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:
call_subprocess(
args,
command_desc=f"pip subprocess to install {kind}",
spinner=spinner,
**extra_environ,
)


Expand Down
28 changes: 28 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

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

Good forward thinking here!


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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
57 changes: 42 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,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,
Expand Down Expand Up @@ -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(
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_constraint())
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_constraint())
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_constraint())
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
3 changes: 3 additions & 0 deletions src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading