diff --git a/docs/concepts/bootstrapper-architecture.rst b/docs/concepts/bootstrapper-architecture.rst index 970d36af..5663739d 100644 --- a/docs/concepts/bootstrapper-architecture.rst +++ b/docs/concepts/bootstrapper-architecture.rst @@ -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 ---------------------------------- diff --git a/src/fromager/build_environment.py b/src/fromager/build_environment.py index 1be2fe5b..0b3a8e73 100644 --- a/src/fromager/build_environment.py +++ b/src/fromager/build_environment.py @@ -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( @@ -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}" @@ -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", @@ -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, @@ -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) @@ -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) diff --git a/tests/test_build_environment.py b/tests/test_build_environment.py index 28c504af..21a6f5d5 100644 --- a/tests/test_build_environment.py +++ b/tests/test_build_environment.py @@ -1,4 +1,6 @@ import json +import pathlib +import subprocess import textwrap from unittest.mock import Mock, patch @@ -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,