Skip to content
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
6 changes: 6 additions & 0 deletions docs/concepts/bootstrapper-architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ Each phase wraps a ``WorkItem`` dataclass that accumulates per-package
state (resolved version, build environment, build result, etc.) as it
moves through the pipeline.

.. versionchanged:: 0.103.0
Later build dependency installs retain explicit requirements from earlier
phases within the same build environment. A backend or sdist dependency that
conflicts with an earlier build-system bound fails resolution instead of
silently replacing the earlier dependency with an incompatible version.

Bootstrapper and Phase Interaction
----------------------------------

Expand Down
16 changes: 13 additions & 3 deletions src/fromager/build_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ def __init__(
req_type: RequirementType,
req: Requirement | str | None,
all_reqs: typing.Iterable[Requirement],
earlier_reqs: typing.Iterable[Requirement] = (),
):
self.all_reqs = all_reqs
resolutions = []
earlier_reqs = tuple(earlier_reqs)
all_reqs = tuple(all_reqs)
self.all_reqs = earlier_reqs + all_reqs
resolutions = [f"{r} (earlier bound)" for r in earlier_reqs]
for r in all_reqs:
try:
_, version = resolver.resolve(
Expand Down Expand Up @@ -93,6 +96,7 @@ def __init__(
self._ctx = ctx
self._req = req
self._sdist_root_dir = sdist_root_dir
self._installed_requirements: tuple[Requirement, ...] = ()
# abiflags adds 't' for free-threaded (nogil) builds
self.path = sdist_root_dir.parent.absolute().joinpath(
f"build-{platform.python_version()}{sys.abiflags}"
Expand Down Expand Up @@ -213,9 +217,12 @@ def _createenv(self) -> None:
logger.info("created build environment in %s", self.path)

def install(self, reqs: typing.Iterable[Requirement]) -> None:
reqs = tuple(reqs)
if not reqs:
return

all_reqs = self._installed_requirements + reqs

# UV does not compile byte code by default
cmd = [
"uv",
Expand All @@ -228,13 +235,14 @@ def install(self, reqs: typing.Iterable[Requirement]) -> None:
]
cmd.extend(self._ctx.pip_constraint_args)
cmd.extend(self._ctx.pip_wheel_server_args)
cmd.extend(str(req) for req in reqs)
cmd.extend(str(req) for req in all_reqs)

self.run(
cmd,
cwd=str(self.path.parent),
network_isolation=False,
)
self._installed_requirements = all_reqs
logger.info(
"installed dependencies %s into build environment in %s",
reqs,
Expand Down Expand Up @@ -347,6 +355,7 @@ def _safe_install(
deps: typing.Iterable[Requirement],
dep_req_type: RequirementType,
) -> None:
deps = tuple(deps)
if not deps:
return
logger.debug("installing %s %s", dep_req_type, deps)
Expand All @@ -364,6 +373,7 @@ def _safe_install(
dep_req_type,
req_info,
deps,
earlier_reqs=build_env._installed_requirements,
) from err

logger.info("installed %s requirements %s", dep_req_type, deps)
64 changes: 64 additions & 0 deletions tests/test_build_environment.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import pathlib
import subprocess
import textwrap
from unittest.mock import Mock, patch

Expand All @@ -11,6 +13,68 @@
from fromager.requirements_file import RequirementType


def test_install_keeps_earlier_explicit_bounds_across_phases(
tmp_path: pathlib.Path,
) -> None:
"""A later transitive upgrade must still satisfy the first phase's cap."""
ctx = Mock(pip_constraint_args=[], pip_wheel_server_args=[])
with patch.object(build_environment.BuildEnvironment, "_createenv"):
env = build_environment.BuildEnvironment(
ctx=ctx, req=Requirement("pyarrow==15.0.2"), sdist_root_dir=tmp_path
)
with patch.object(env, "run") as run:
env.install(iter([Requirement("setuptools<82")]))
env.install(
iter([Requirement("setuptools_scm<8"), Requirement("cython>=0.29")])
)

second_command = run.call_args_list[1].args[0]
assert second_command[-3:] == [
"setuptools<82",
"setuptools_scm<8",
"cython>=0.29",
]


def test_failed_later_install_reports_earlier_bounds(tmp_path: pathlib.Path) -> None:
"""A conflict report includes the bound from the first phase."""
ctx = Mock(pip_constraint_args=[], pip_wheel_server_args=[])
with patch.object(build_environment.BuildEnvironment, "_createenv"):
env = build_environment.BuildEnvironment(
ctx=ctx, req=Requirement("pyarrow"), sdist_root_dir=tmp_path
)
failure = subprocess.CalledProcessError(
1, "uv pip install", output="Because you require setuptools>=82"
)
with (
patch.object(env, "run", side_effect=[None, failure]),
patch("fromager.resolver.resolve", return_value=("", Version("81"))) as resolve,
):
build_environment._safe_install(
ctx,
Requirement("pyarrow"),
env,
[Requirement("setuptools<82")],
RequirementType.BUILD_SYSTEM,
)
with pytest.raises(build_environment.MissingDependency) as exc:
build_environment._safe_install(
ctx,
Requirement("pyarrow"),
env,
[Requirement("setuptools>=82")],
RequirementType.BUILD_BACKEND,
)

assert resolve.call_count == 1
assert [str(req) for req in exc.value.all_reqs] == [
"setuptools<82",
"setuptools>=82",
]
assert "setuptools<82 (earlier bound)" in str(exc.value)
assert [str(req) for req in env._installed_requirements] == ["setuptools<82"]


@patch("fromager.resolver.resolve")
def test_missing_dependency_format(
resolve_dist: Mock,
Expand Down
Loading