Skip to content

Commit c2f6fb6

Browse files
rd4398claude
andcommitted
refactor(bootstrapper): replace recursion with trampoline for iterative execution
Convert recursive bootstrap methods to generators using the trampoline library, eliminating Python's recursion depth limit for deep/wide dependency graphs. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
1 parent 06e6317 commit c2f6fb6

3 files changed

Lines changed: 58 additions & 36 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies = [
4848
"stevedore",
4949
"tomlkit",
5050
"tqdm",
51+
"trampoline",
5152
"wheel",
5253
"uv>=0.8.19",
5354
"uvicorn",
@@ -207,7 +208,7 @@ exclude = [
207208

208209
[[tool.mypy.overrides]]
209210
# packages without typing annotations and stubs
210-
module = ["hatchling", "hatchling.build", "license_expression", "pyproject_hooks", "requests_mock", "resolver", "spdx_tools.*", "stevedore"]
211+
module = ["hatchling", "hatchling.build", "license_expression", "pyproject_hooks", "requests_mock", "resolver", "spdx_tools.*", "stevedore", "trampoline"]
211212
ignore_missing_imports = true
212213

213214
[tool.basedpyright]

src/fromager/bootstrapper.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from packaging.utils import NormalizedName, canonicalize_name
2020
from packaging.version import Version
2121
from resolvelib.resolvers import ResolverException
22+
from trampoline import trampoline
2223

2324
from . import (
2425
bootstrap_requirement_resolver,
@@ -274,6 +275,16 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
274275
def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
275276
"""Bootstrap a package and its dependencies.
276277
278+
Public entry point that drives the generator-based implementation
279+
via trampoline() for iterative (non-recursive) execution.
280+
"""
281+
trampoline(self._bootstrap_gen(req, req_type))
282+
283+
def _bootstrap_gen(
284+
self, req: Requirement, req_type: RequirementType
285+
) -> typing.Generator[typing.Any, typing.Any, None]:
286+
"""Generator that bootstraps a package and its dependencies.
287+
277288
Handles setup, validation, and error handling. Delegates actual build
278289
work to _bootstrap_impl().
279290
@@ -307,7 +318,9 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
307318

308319
# Bootstrap each resolved version
309320
for source_url, resolved_version in resolved_versions:
310-
self._bootstrap_single_version(req, req_type, source_url, resolved_version)
321+
yield self._bootstrap_single_version(
322+
req, req_type, source_url, resolved_version
323+
)
311324

312325
# In multiple versions mode, report any failures for this requirement
313326
if self.multiple_versions and self._failed_versions:
@@ -329,7 +342,7 @@ def _bootstrap_single_version(
329342
req_type: RequirementType,
330343
source_url: str,
331344
resolved_version: Version,
332-
) -> None:
345+
) -> typing.Generator[typing.Any, typing.Any, None]:
333346
"""Bootstrap a single version of a package.
334347
335348
Extracted from bootstrap() to handle both single and multiple version modes.
@@ -365,7 +378,7 @@ def _bootstrap_single_version(
365378
# Track dependency chain - context manager ensures cleanup even on exception
366379
with self._track_why(req_type, req, resolved_version):
367380
try:
368-
self._bootstrap_impl(
381+
yield self._bootstrap_impl(
369382
req, req_type, source_url, resolved_version, build_sdist_only
370383
)
371384
except Exception as err:
@@ -400,7 +413,7 @@ def _bootstrap_impl(
400413
source_url: str,
401414
resolved_version: Version,
402415
build_sdist_only: bool,
403-
) -> None:
416+
) -> typing.Generator[typing.Any, typing.Any, None]:
404417
"""Internal implementation - performs the actual bootstrap work.
405418
406419
Called by bootstrap() after setup, validation, and seen-checking.
@@ -453,7 +466,7 @@ def _bootstrap_impl(
453466
)
454467

455468
# Build from source (handles test-mode fallback internally)
456-
build_result = self._build_from_source(
469+
build_result = yield self._build_from_source(
457470
req=req,
458471
resolved_version=resolved_version,
459472
source_url=source_url,
@@ -520,9 +533,7 @@ def _bootstrap_impl(
520533
self.progressbar.update_total(len(install_dependencies))
521534
for dep in self._sort_requirements(install_dependencies):
522535
with req_ctxvar_context(dep):
523-
# In test mode, bootstrap() catches and records failures internally.
524-
# In normal mode, it raises immediately which we propagate.
525-
self.bootstrap(req=dep, req_type=RequirementType.INSTALL)
536+
yield self._bootstrap_gen(req=dep, req_type=RequirementType.INSTALL)
526537
self.progressbar.update()
527538

528539
# Clean up build directories
@@ -643,15 +654,15 @@ def _prepare_build_dependencies(
643654
resolved_version: Version | None,
644655
sdist_root_dir: pathlib.Path,
645656
build_env: build_environment.BuildEnvironment,
646-
) -> set[Requirement]:
657+
) -> typing.Generator[typing.Any, typing.Any, set[Requirement]]:
647658
# build system
648659
build_system_dependencies = dependencies.get_build_system_dependencies(
649660
ctx=self.ctx,
650661
req=req,
651662
version=resolved_version,
652663
sdist_root_dir=sdist_root_dir,
653664
)
654-
self._handle_build_requirements(
665+
yield self._handle_build_requirements(
655666
req,
656667
RequirementType.BUILD_SYSTEM,
657668
build_system_dependencies,
@@ -667,7 +678,7 @@ def _prepare_build_dependencies(
667678
sdist_root_dir=sdist_root_dir,
668679
build_env=build_env,
669680
)
670-
self._handle_build_requirements(
681+
yield self._handle_build_requirements(
671682
req,
672683
RequirementType.BUILD_BACKEND,
673684
build_backend_dependencies,
@@ -681,7 +692,7 @@ def _prepare_build_dependencies(
681692
sdist_root_dir=sdist_root_dir,
682693
build_env=build_env,
683694
)
684-
self._handle_build_requirements(
695+
yield self._handle_build_requirements(
685696
req,
686697
RequirementType.BUILD_SDIST,
687698
build_sdist_dependencies,
@@ -702,14 +713,12 @@ def _handle_build_requirements(
702713
req: Requirement,
703714
build_type: RequirementType,
704715
build_dependencies: set[Requirement],
705-
) -> None:
716+
) -> typing.Generator[typing.Any, typing.Any, None]:
706717
self.progressbar.update_total(len(build_dependencies))
707718

708719
for dep in self._sort_requirements(build_dependencies):
709720
with req_ctxvar_context(dep):
710-
# In test mode, bootstrap() catches and records failures internally.
711-
# In normal mode, it raises immediately which we propagate.
712-
self.bootstrap(req=dep, req_type=build_type)
721+
yield self._bootstrap_gen(req=dep, req_type=build_type)
713722
self.progressbar.update()
714723

715724
def _download_prebuilt(
@@ -896,7 +905,7 @@ def _build_from_source(
896905
build_sdist_only: bool,
897906
cached_wheel_filename: pathlib.Path | None,
898907
unpacked_cached_wheel: pathlib.Path | None,
899-
) -> SourceBuildResult:
908+
) -> typing.Generator[typing.Any, typing.Any, SourceBuildResult]:
900909
"""Build package from source.
901910
902911
Orchestrates download, preparation, build environment setup, and build.
@@ -939,9 +948,7 @@ def _build_from_source(
939948
)
940949

941950
# Prepare build dependencies (always needed)
942-
# Note: This may recursively call bootstrap() for build deps,
943-
# which has its own error handling.
944-
self._prepare_build_dependencies(
951+
yield self._prepare_build_dependencies(
945952
req=req,
946953
resolved_version=resolved_version,
947954
sdist_root_dir=sdist_root_dir,
@@ -1334,11 +1341,13 @@ def _get_version_from_package_metadata(
13341341
ctx=self.ctx,
13351342
parent_dir=source_dir.parent,
13361343
)
1337-
build_dependencies = self._prepare_build_dependencies(
1338-
req=req,
1339-
resolved_version=None,
1340-
sdist_root_dir=source_dir,
1341-
build_env=build_env,
1344+
build_dependencies = trampoline(
1345+
self._prepare_build_dependencies(
1346+
req=req,
1347+
resolved_version=None,
1348+
sdist_root_dir=source_dir,
1349+
build_env=build_env,
1350+
)
13421351
)
13431352
build_env.install(build_dependencies)
13441353

tests/test_bootstrapper.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import pathlib
3+
import typing
34
from unittest.mock import Mock, patch
45

56
import pytest
@@ -8,6 +9,7 @@
89
from packaging.utils import canonicalize_name
910
from packaging.version import Version
1011
from resolvelib.resolvers import ResolverException
12+
from trampoline import trampoline
1113

1214
from fromager import bootstrapper, requirements_file
1315
from fromager.context import WorkContext
@@ -270,21 +272,29 @@ def test_build_from_source_returns_dataclass(tmp_context: WorkContext) -> None:
270272
mock_wheel = tmp_context.work_dir / "package-1.0.0-py3-none-any.whl"
271273
expected_unpack_dir = mock_sdist_root.parent
272274

275+
def mock_prepare_gen(
276+
*args: typing.Any, **kwargs: typing.Any
277+
) -> typing.Generator[typing.Any, typing.Any, set[Requirement]]:
278+
return set()
279+
yield
280+
273281
with (
274282
patch("fromager.sources.download_source", return_value=mock_source_file),
275283
patch("fromager.sources.prepare_source", return_value=mock_sdist_root),
276284
patch("fromager.sources.get_source_type", return_value=SourceType.SDIST),
277-
patch.object(bt, "_prepare_build_dependencies"),
285+
patch.object(bt, "_prepare_build_dependencies", side_effect=mock_prepare_gen),
278286
patch.object(bt, "_build_wheel", return_value=(mock_wheel, None)),
279287
):
280-
result = bt._build_from_source(
281-
req=Requirement("test-package"),
282-
resolved_version=Version("1.0.0"),
283-
source_url="https://pypi.org/simple/test-package",
284-
req_type=requirements_file.RequirementType.TOP_LEVEL,
285-
build_sdist_only=False,
286-
cached_wheel_filename=None,
287-
unpacked_cached_wheel=None,
288+
result = trampoline(
289+
bt._build_from_source(
290+
req=Requirement("test-package"),
291+
resolved_version=Version("1.0.0"),
292+
source_url="https://pypi.org/simple/test-package",
293+
req_type=requirements_file.RequirementType.TOP_LEVEL,
294+
build_sdist_only=False,
295+
cached_wheel_filename=None,
296+
unpacked_cached_wheel=None,
297+
)
288298
)
289299

290300
# Verify return type is SourceBuildResult
@@ -323,12 +333,14 @@ def mock_bootstrap_impl(
323333
source_url: str,
324334
resolved_version: Version,
325335
build_sdist_only: bool,
326-
) -> None:
336+
) -> typing.Generator[typing.Any, typing.Any, None]:
327337
call_count["count"] += 1
328338
if str(resolved_version) == "1.5":
329339
raise ValueError("Simulated failure for version 1.5")
330340
# For other versions, just mark as seen to avoid actual build
331341
bt._mark_as_seen(req, resolved_version, build_sdist_only)
342+
return
343+
yield
332344

333345
with patch.object(bt, "_bootstrap_impl", side_effect=mock_bootstrap_impl):
334346
# Mock _has_been_seen to return False so we attempt bootstrap

0 commit comments

Comments
 (0)