Skip to content

Commit 60c563e

Browse files
rd4398claude
andcommitted
feat(bootstrap): add --multiple-versions flag to bootstrap all matching versions
Add --multiple-versions flag to bootstrap command that enables bootstrapping all versions matching a requirement specification, rather than only the highest version. This is useful for creating comprehensive build environments with multiple versions of the same package. Key changes: - Add return_all_versions parameter to BootstrapRequirementResolver.resolve() with type-safe overloads using @overload decorators - Modify Bootstrapper.bootstrap() to iterate over all resolved versions when --multiple-versions flag is enabled - Implement continue-on-error behavior: failed versions are logged, tracked, and reported at the end without stopping the bootstrap process - Add DependencyGraph.remove_dependency() to clean up failed nodes from graph - Apply recursively to entire dependency chain (not just top-level) Testing: - Add 4 unit tests for resolver return_all_versions behavior - Add unit test for continue-on-error and graph cleanup behavior - Add e2e test using tomli>=2.0,<2.1 with constraints to verify multiple versions are bootstrapped successfully Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d86f938 commit 60c563e

8 files changed

Lines changed: 509 additions & 14 deletions

e2e/ci_bootstrap_suite.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ test_section "bootstrap configuration tests"
2525
run_test "bootstrap_prerelease"
2626
run_test "bootstrap_cache"
2727
run_test "bootstrap_sdist_only"
28+
run_test "bootstrap_multiple_versions"
2829

2930
test_section "bootstrap git URL tests"
3031
run_test "bootstrap_git_url"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Test bootstrap with --multiple-versions flag
5+
# Tests that multiple matching versions are bootstrapped
6+
7+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
8+
source "$SCRIPTDIR/common.sh"
9+
10+
# Create constraints file to pin build dependencies (keeps CI fast)
11+
constraints_file=$(mktemp)
12+
trap "rm -f $constraints_file" EXIT
13+
cat > "$constraints_file" <<EOF
14+
flit-core==3.11.0
15+
EOF
16+
17+
# Use tomli with a version range that matches 3 versions (2.0.0, 2.0.1, 2.0.2)
18+
# tomli has no runtime dependencies, making it fast to bootstrap
19+
# It uses flit-core as build backend (pinned above)
20+
# Note: constraints file generation will fail (expected with multiple versions)
21+
fromager \
22+
--log-file="$OUTDIR/bootstrap.log" \
23+
--error-log-file="$OUTDIR/fromager-errors.log" \
24+
--sdists-repo="$OUTDIR/sdists-repo" \
25+
--wheels-repo="$OUTDIR/wheels-repo" \
26+
--work-dir="$OUTDIR/work-dir" \
27+
--constraints-file="$constraints_file" \
28+
bootstrap \
29+
--multiple-versions \
30+
'tomli>=2.0,<2.1' || true
31+
32+
# Check that wheels were built
33+
echo "Checking for wheels..."
34+
find "$OUTDIR/wheels-repo/downloads/" -name 'tomli-*.whl' | sort
35+
36+
# Verify that we have multiple tomli wheels (only count downloads, not simple server copies)
37+
TOMLI_WHEEL_COUNT=$(find "$OUTDIR/wheels-repo/downloads/" -name 'tomli-*.whl' | wc -l)
38+
echo "Found $TOMLI_WHEEL_COUNT tomli wheel(s)"
39+
40+
# We expect exactly 3 versions (2.0.0, 2.0.1, 2.0.2)
41+
if [ "$TOMLI_WHEEL_COUNT" -ne 3 ]; then
42+
echo "ERROR: Expected exactly 3 tomli wheels (2.0.0, 2.0.1, 2.0.2), found $TOMLI_WHEEL_COUNT"
43+
echo "The --multiple-versions flag should have bootstrapped all matching versions"
44+
exit 1
45+
fi
46+
47+
# Check that we have all 3 versions
48+
HAS_2_0_0=false
49+
HAS_2_0_1=false
50+
HAS_2_0_2=false
51+
52+
if find "$OUTDIR/wheels-repo/downloads/" -name "tomli-2.0.0-*.whl" | grep -q .; then
53+
echo "Found wheel for tomli 2.0.0"
54+
HAS_2_0_0=true
55+
fi
56+
57+
if find "$OUTDIR/wheels-repo/downloads/" -name "tomli-2.0.1-*.whl" | grep -q .; then
58+
echo "Found wheel for tomli 2.0.1"
59+
HAS_2_0_1=true
60+
fi
61+
62+
if find "$OUTDIR/wheels-repo/downloads/" -name "tomli-2.0.2-*.whl" | grep -q .; then
63+
echo "Found wheel for tomli 2.0.2"
64+
HAS_2_0_2=true
65+
fi
66+
67+
if [ "$HAS_2_0_0" = false ] || [ "$HAS_2_0_1" = false ] || [ "$HAS_2_0_2" = false ]; then
68+
echo "ERROR: Missing expected versions. Expected all of 2.0.0, 2.0.1, and 2.0.2"
69+
echo "Found wheels:"
70+
find "$OUTDIR/wheels-repo/downloads/" -name 'tomli-*.whl'
71+
exit 1
72+
fi
73+
74+
echo "SUCCESS: Multiple versions of tomli (2.0.0, 2.0.1, 2.0.2) were bootstrapped as expected"

src/fromager/bootstrap_requirement_resolver.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import typing
11+
from typing import overload
1112

1213
from packaging.requirements import Requirement
1314
from packaging.version import Version
@@ -56,13 +57,34 @@ def __init__(
5657
tuple[str, bool], list[tuple[str, Version]]
5758
] = {}
5859

60+
@overload
5961
def resolve(
6062
self,
6163
req: Requirement,
6264
req_type: RequirementType,
6365
parent_req: Requirement | None = None,
6466
pre_built: bool | None = None,
65-
) -> tuple[str, Version]:
67+
return_all_versions: typing.Literal[False] = False,
68+
) -> tuple[str, Version]: ...
69+
70+
@overload
71+
def resolve(
72+
self,
73+
req: Requirement,
74+
req_type: RequirementType,
75+
parent_req: Requirement | None = None,
76+
pre_built: bool | None = None,
77+
return_all_versions: typing.Literal[True] = ...,
78+
) -> list[tuple[str, Version]]: ...
79+
80+
def resolve(
81+
self,
82+
req: Requirement,
83+
req_type: RequirementType,
84+
parent_req: Requirement | None = None,
85+
pre_built: bool | None = None,
86+
return_all_versions: bool = False,
87+
) -> tuple[str, Version] | list[tuple[str, Version]]:
6688
"""Resolve package requirement to the best matching version.
6789
6890
Tries resolution strategies in order:
@@ -76,9 +98,14 @@ def resolve(
7698
parent_req: Parent requirement from dependency chain
7799
pre_built: Optional override to force prebuilt (True) or source (False).
78100
If None (default), uses package build info to determine.
101+
return_all_versions: If True, return all matching versions instead of
102+
just the highest version.
79103
80104
Returns:
81-
(url, version) tuple for the highest matching version
105+
If return_all_versions is False (default):
106+
(url, version) tuple for the highest matching version
107+
If return_all_versions is True:
108+
List of (url, version) tuples sorted by version (highest first)
82109
83110
Raises:
84111
ValueError: If req contains a git URL and pre_built is False
@@ -101,14 +128,14 @@ def resolve(
101128
cached_result = self.get_cached_resolution(req, pre_built)
102129
if cached_result is not None:
103130
logger.debug(f"resolved {req} from cache")
104-
return cached_result[0]
131+
return cached_result if return_all_versions else cached_result[0]
105132

106133
# Resolve using strategies
107134
results = self._resolve(req, req_type, parent_req, pre_built)
108135

109136
# Cache the result
110137
self.cache_resolution(req, pre_built, results)
111-
return results[0]
138+
return results if return_all_versions else results[0]
112139

113140
def _resolve(
114141
self,

src/fromager/bootstrapper.py

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(
8989
cache_wheel_server_url: str | None = None,
9090
sdist_only: bool = False,
9191
test_mode: bool = False,
92+
multiple_versions: bool = False,
9293
) -> None:
9394
if test_mode and sdist_only:
9495
raise ValueError(
@@ -101,6 +102,7 @@ def __init__(
101102
self.cache_wheel_server_url = cache_wheel_server_url or ctx.wheel_server_url
102103
self.sdist_only = sdist_only
103104
self.test_mode = test_mode
105+
self.multiple_versions = multiple_versions
104106
self.why: list[tuple[RequirementType, Requirement, Version]] = []
105107

106108
# Delegate resolution to BootstrapRequirementResolver
@@ -126,6 +128,10 @@ def __init__(
126128
# Track failed packages in test mode (list of typed dicts for JSON export)
127129
self.failed_packages: list[FailureRecord] = []
128130

131+
# Track failed versions in multiple_versions mode
132+
# Maps (package_name, version) -> exception info
133+
self._failed_versions: list[tuple[str, str, Exception]] = []
134+
129135
def resolve_and_add_top_level(
130136
self,
131137
req: Requirement,
@@ -249,22 +255,85 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
249255
250256
In test mode, catches build exceptions, records package name, and continues.
251257
In normal mode, raises exceptions immediately (fail-fast).
258+
259+
When multiple_versions is enabled, bootstraps all matching versions instead
260+
of just the highest version.
252261
"""
253262
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
254263

255-
# Resolve version first so we have it for error reporting.
264+
# Resolve versions - get all if multiple_versions mode is enabled
256265
# In test mode, record resolution failures and continue.
266+
resolved_versions: list[tuple[str, Version]]
257267
try:
258-
source_url, resolved_version = self.resolve_version(
259-
req=req,
260-
req_type=req_type,
261-
)
268+
if self.multiple_versions:
269+
# Get all matching versions
270+
resolved_versions = self._resolver.resolve(
271+
req=req,
272+
req_type=req_type,
273+
parent_req=self.why[-1][1] if self.why else None,
274+
return_all_versions=True,
275+
)
276+
logger.info(
277+
f"multiple versions mode: resolved {len(resolved_versions)} version(s) for {req}"
278+
)
279+
else:
280+
# Get only the highest version (default behavior)
281+
source_url, resolved_version = self.resolve_version(
282+
req=req,
283+
req_type=req_type,
284+
)
285+
resolved_versions = [(source_url, resolved_version)]
262286
except Exception as err:
263287
if not self.test_mode:
264288
raise
265289
self._record_test_mode_failure(req, None, err, "resolution")
266290
return
267291

292+
# Bootstrap each resolved version
293+
for source_url, resolved_version in resolved_versions:
294+
# For top-level requirements, add to graph before bootstrapping
295+
# so that build dependencies can reference the parent node
296+
if req_type == RequirementType.TOP_LEVEL:
297+
pbi = self.ctx.package_build_info(req)
298+
self.ctx.dependency_graph.add_dependency(
299+
parent_name=None,
300+
parent_version=None,
301+
req_type=RequirementType.TOP_LEVEL,
302+
req=req,
303+
req_version=resolved_version,
304+
download_url=source_url,
305+
pre_built=pbi.pre_built,
306+
constraint=self.ctx.constraints.get_constraint(req.name),
307+
)
308+
self.ctx.write_to_graph_to_file()
309+
310+
self._bootstrap_single_version(req, req_type, source_url, resolved_version)
311+
312+
# In multiple versions mode, report any failures for this requirement
313+
if self.multiple_versions and self._failed_versions:
314+
failed_for_req = [
315+
(name, ver, exc)
316+
for name, ver, exc in self._failed_versions
317+
if name == canonicalize_name(req.name)
318+
]
319+
if failed_for_req:
320+
logger.warning(
321+
f"{req.name}: {len(failed_for_req)} version(s) failed to bootstrap"
322+
)
323+
for name, ver, exc in failed_for_req:
324+
logger.warning(f" - {name}=={ver}: {type(exc).__name__}: {exc}")
325+
326+
def _bootstrap_single_version(
327+
self,
328+
req: Requirement,
329+
req_type: RequirementType,
330+
source_url: str,
331+
resolved_version: Version,
332+
) -> None:
333+
"""Bootstrap a single version of a package.
334+
335+
Extracted from bootstrap() to handle both single and multiple version modes.
336+
"""
268337
# Capture parent before _track_why pushes current package onto the stack
269338
parent: tuple[Requirement, Version] | None = None
270339
if self.why:
@@ -298,11 +367,29 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
298367
req, req_type, source_url, resolved_version, build_sdist_only
299368
)
300369
except Exception as err:
301-
if not self.test_mode:
302-
raise
303-
self._record_test_mode_failure(
304-
req, str(resolved_version), err, "bootstrap"
305-
)
370+
# In test_mode, record failure and continue
371+
if self.test_mode:
372+
self._record_test_mode_failure(
373+
req, str(resolved_version), err, "bootstrap"
374+
)
375+
return
376+
377+
# In multiple_versions mode, record failure and continue to next version
378+
if self.multiple_versions:
379+
pkg_name = canonicalize_name(req.name)
380+
self._failed_versions.append((pkg_name, str(resolved_version), err))
381+
logger.warning(
382+
f"{req.name}=={resolved_version}: failed to bootstrap: {type(err).__name__}: {err}"
383+
)
384+
# Remove failed node from graph since bootstrap didn't complete
385+
self.ctx.dependency_graph.remove_dependency(
386+
pkg_name, resolved_version
387+
)
388+
self.ctx.write_to_graph_to_file()
389+
return
390+
391+
# Otherwise, raise the exception (fail-fast)
392+
raise
306393

307394
def _bootstrap_impl(
308395
self,

src/fromager/commands/bootstrap.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ def _get_requirements_from_args(
103103
default=False,
104104
help="Test mode: continue processing after failures, report failures at end",
105105
)
106+
@click.option(
107+
"--multiple-versions",
108+
"multiple_versions",
109+
is_flag=True,
110+
default=False,
111+
help="Bootstrap all matching versions instead of only the highest version",
112+
)
106113
@click.argument("toplevel", nargs=-1)
107114
@click.pass_obj
108115
def bootstrap(
@@ -113,6 +120,7 @@ def bootstrap(
113120
sdist_only: bool,
114121
skip_constraints: bool,
115122
test_mode: bool,
123+
multiple_versions: bool,
116124
toplevel: list[str],
117125
) -> None:
118126
"""Compute and build the dependencies of a set of requirements recursively
@@ -147,6 +155,11 @@ def bootstrap(
147155
"test mode enabled: will continue processing after failures and report at end"
148156
)
149157

158+
if multiple_versions:
159+
logger.info(
160+
"multiple versions mode enabled: will bootstrap all matching versions"
161+
)
162+
150163
pre_built = wkctx.settings.list_pre_built()
151164
if pre_built:
152165
logger.info("treating %s as pre-built wheels", sorted(pre_built))
@@ -161,6 +174,7 @@ def bootstrap(
161174
cache_wheel_server_url,
162175
sdist_only=sdist_only,
163176
test_mode=test_mode,
177+
multiple_versions=multiple_versions,
164178
)
165179

166180
# Pre-resolution phase: Resolve all top-level dependencies before recursive
@@ -463,6 +477,13 @@ def write_constraints_file(
463477
default=None,
464478
help="maximum number of parallel workers to run (default: unlimited)",
465479
)
480+
@click.option(
481+
"--multiple-versions",
482+
"multiple_versions",
483+
is_flag=True,
484+
default=False,
485+
help="Bootstrap all matching versions instead of only the highest version",
486+
)
466487
@click.argument("toplevel", nargs=-1)
467488
@click.pass_obj
468489
@click.pass_context
@@ -476,6 +497,7 @@ def bootstrap_parallel(
476497
skip_constraints: bool,
477498
force: bool,
478499
max_workers: int | None,
500+
multiple_versions: bool,
479501
toplevel: list[str],
480502
) -> None:
481503
"""Bootstrap and build-parallel
@@ -502,6 +524,7 @@ def bootstrap_parallel(
502524
cache_wheel_server_url=cache_wheel_server_url,
503525
sdist_only=True,
504526
skip_constraints=skip_constraints,
527+
multiple_versions=multiple_versions,
505528
toplevel=toplevel,
506529
)
507530

0 commit comments

Comments
 (0)