Skip to content

Add build constraints #13534

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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]


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

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.

: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