From 5d272ad04893e30ee17b0b3268e74dd6bf6de7f9 Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:44:18 +0100 Subject: [PATCH 1/2] fix(build): preserve earlier build dependency bounds across installs Keep successful explicit requirements in the build environment so later uv upgrades cannot replace a compatible backend outside its declared range. Include earlier bounds in conflict reports. Assisted-by: Hermes Agent (Nous Research) Closes: #1353 Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- docs/concepts/bootstrapper-architecture.rst | 6 ++ src/fromager/build_environment.py | 10 +++- tests/test_build_environment.py | 62 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/concepts/bootstrapper-architecture.rst b/docs/concepts/bootstrapper-architecture.rst index 970d36af9..5663739d2 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 1be2fe5bd..40f040056 100644 --- a/src/fromager/build_environment.py +++ b/src/fromager/build_environment.py @@ -93,6 +93,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 +214,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 +232,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 +352,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) @@ -363,7 +369,7 @@ def _safe_install( ctx, dep_req_type, req_info, - deps, + build_env._installed_requirements + deps, ) 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 28c504af1..c37b66a76 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,66 @@ 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"))), + ): + 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 [str(req) for req in exc.value.all_reqs] == [ + "setuptools<82", + "setuptools>=82", + ] + assert [str(req) for req in env._installed_requirements] == ["setuptools<82"] + + @patch("fromager.resolver.resolve") def test_missing_dependency_format( resolve_dist: Mock, From 9f7399370a2ac1470cb13e6c531f88ec1c42d5d2 Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:53:56 +0100 Subject: [PATCH 2/2] fix(build): report prior bounds without resolving them again Keep earlier bounds visible in failed-install diagnostics while limiting additional lookups to requirements from the failing phase. Assisted-by: Hermes Agent (Nous Research) Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- src/fromager/build_environment.py | 10 +++++++--- tests/test_build_environment.py | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/fromager/build_environment.py b/src/fromager/build_environment.py index 40f040056..0b3a8e73f 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( @@ -369,7 +372,8 @@ def _safe_install( ctx, dep_req_type, req_info, - build_env._installed_requirements + deps, + 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 c37b66a76..21a6f5d50 100644 --- a/tests/test_build_environment.py +++ b/tests/test_build_environment.py @@ -48,7 +48,7 @@ def test_failed_later_install_reports_earlier_bounds(tmp_path: pathlib.Path) -> ) with ( patch.object(env, "run", side_effect=[None, failure]), - patch("fromager.resolver.resolve", return_value=("", Version("81"))), + patch("fromager.resolver.resolve", return_value=("", Version("81"))) as resolve, ): build_environment._safe_install( ctx, @@ -66,10 +66,12 @@ def test_failed_later_install_reports_earlier_bounds(tmp_path: pathlib.Path) -> 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"]