Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
45 changes: 45 additions & 0 deletions docs/html/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`:


Expand Down
4 changes: 4 additions & 0 deletions news/13534.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 69 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,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,
Expand All @@ -112,6 +158,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 +215,32 @@ 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:
call_subprocess(
args,
command_desc=f"pip subprocess to install {kind}",
spinner=spinner,
**extra_environ,
)


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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
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