Skip to content

Commit b8f4441

Browse files
authored
Merge pull request #1035 from rd4398/bootstrap-multiple-version-flag
feat(bootstrap): add --multiple-versions flag to bootstrap all matching versions
2 parents 5370daa + 30e2a28 commit b8f4441

11 files changed

+537
-54
lines changed

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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 exactly 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+
# Using <=2.0.2 instead of <2.1 to be deterministic (tomli 2.1.0 exists)
21+
# Note: constraints file generation will fail (expected with multiple versions)
22+
fromager \
23+
--log-file="$OUTDIR/bootstrap.log" \
24+
--error-log-file="$OUTDIR/fromager-errors.log" \
25+
--sdists-repo="$OUTDIR/sdists-repo" \
26+
--wheels-repo="$OUTDIR/wheels-repo" \
27+
--work-dir="$OUTDIR/work-dir" \
28+
--constraints-file="$constraints_file" \
29+
bootstrap \
30+
--multiple-versions \
31+
'tomli>=2.0,<=2.0.2' || true
32+
33+
# Check that wheels were built
34+
echo "Checking for wheels..."
35+
find "$OUTDIR/wheels-repo/downloads/" -name 'tomli-*.whl' | sort
36+
37+
# Verify that all expected versions were bootstrapped
38+
# Note: We don't check the exact count to avoid test fragility if extra versions appear
39+
EXPECTED_VERSIONS="2.0.0 2.0.1 2.0.2"
40+
MISSING_VERSIONS=""
41+
42+
for version in $EXPECTED_VERSIONS; do
43+
if find "$OUTDIR/wheels-repo/downloads/" -name "tomli-$version-*.whl" | grep -q .; then
44+
echo "✓ Found wheel for tomli $version"
45+
else
46+
echo "✗ Missing wheel for tomli $version"
47+
MISSING_VERSIONS="$MISSING_VERSIONS $version"
48+
fi
49+
done
50+
51+
if [ -n "$MISSING_VERSIONS" ]; then
52+
echo ""
53+
echo "ERROR: Missing expected versions:$MISSING_VERSIONS"
54+
echo "The --multiple-versions flag should have bootstrapped all matching versions"
55+
echo ""
56+
echo "Found wheels:"
57+
find "$OUTDIR/wheels-repo/downloads/" -name 'tomli-*.whl'
58+
exit 1
59+
fi
60+
61+
echo ""
62+
echo "SUCCESS: All expected tomli versions (2.0.0, 2.0.1, 2.0.2) were bootstrapped"

src/fromager/bootstrap_requirement_resolver.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ def resolve(
6262
req_type: RequirementType,
6363
parent_req: Requirement | None = None,
6464
pre_built: bool | None = None,
65-
) -> tuple[str, Version]:
66-
"""Resolve package requirement to the best matching version.
65+
return_all_versions: bool = False,
66+
) -> list[tuple[str, Version]]:
67+
"""Resolve package requirement to matching version(s).
6768
6869
Tries resolution strategies in order:
6970
1. Session cache (if previously resolved)
@@ -76,9 +77,13 @@ def resolve(
7677
parent_req: Parent requirement from dependency chain
7778
pre_built: Optional override to force prebuilt (True) or source (False).
7879
If None (default), uses package build info to determine.
80+
return_all_versions: If True, return all matching versions. If False,
81+
return only the highest matching version.
7982
8083
Returns:
81-
(url, version) tuple for the highest matching version
84+
List of (url, version) tuples sorted by version (highest first).
85+
Contains one item when return_all_versions=False, or all matching
86+
versions when return_all_versions=True.
8287
8388
Raises:
8489
ValueError: If req contains a git URL and pre_built is False
@@ -101,14 +106,14 @@ def resolve(
101106
cached_result = self.get_cached_resolution(req, pre_built)
102107
if cached_result is not None:
103108
logger.debug(f"resolved {req} from cache")
104-
return cached_result[0]
109+
return cached_result if return_all_versions else [cached_result[0]]
105110

106111
# Resolve using strategies
107112
results = self._resolve(req, req_type, parent_req, pre_built)
108113

109114
# Cache the result
110115
self.cache_resolution(req, pre_built, results)
111-
return results[0]
116+
return results if return_all_versions else [results[0]]
112117

113118
def _resolve(
114119
self,

src/fromager/bootstrapper.py

Lines changed: 120 additions & 37 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,
@@ -135,6 +141,10 @@ def resolve_and_add_top_level(
135141
This is the pre-resolution phase before recursive bootstrapping begins.
136142
In test mode, catches resolution errors and records them as failures.
137143
144+
When multiple_versions is enabled, resolves and adds all matching versions
145+
to the graph, but still returns only the first (highest) version for
146+
backward compatibility.
147+
138148
Args:
139149
req: The top-level requirement to resolve.
140150
@@ -147,40 +157,59 @@ def resolve_and_add_top_level(
147157
"""
148158
try:
149159
pbi = self.ctx.package_build_info(req)
150-
source_url, version = self.resolve_version(
160+
results = self.resolve_versions(
151161
req=req,
152162
req_type=RequirementType.TOP_LEVEL,
163+
return_all_versions=self.multiple_versions,
153164
)
154-
logger.info("%s resolves to %s", req, version)
155-
self.ctx.dependency_graph.add_dependency(
156-
parent_name=None,
157-
parent_version=None,
158-
req_type=RequirementType.TOP_LEVEL,
159-
req=req,
160-
req_version=version,
161-
download_url=source_url,
162-
pre_built=pbi.pre_built,
163-
constraint=self.ctx.constraints.get_constraint(req.name),
164-
)
165-
return (source_url, version)
165+
if self.multiple_versions:
166+
logger.info(f"resolved {len(results)} version(s) for {req}")
167+
168+
# Add all resolved versions to the graph
169+
for source_url, version in results:
170+
logger.info("%s resolves to %s", req, version)
171+
self.ctx.dependency_graph.add_dependency(
172+
parent_name=None,
173+
parent_version=None,
174+
req_type=RequirementType.TOP_LEVEL,
175+
req=req,
176+
req_version=version,
177+
download_url=source_url,
178+
pre_built=pbi.pre_built,
179+
constraint=self.ctx.constraints.get_constraint(req.name),
180+
)
181+
182+
# Return first result for backward compatibility
183+
return results[0]
166184
except Exception as err:
167185
if not self.test_mode:
168186
raise
169187
self._record_test_mode_failure(req, None, err, "resolution")
170188
return None
171189

172-
def resolve_version(
190+
def resolve_versions(
173191
self,
174192
req: Requirement,
175193
req_type: RequirementType,
176-
) -> tuple[str, Version]:
177-
"""Resolve the version of a requirement.
194+
return_all_versions: bool = False,
195+
) -> list[tuple[str, Version]]:
196+
"""Resolve version(s) of a requirement.
178197
179-
Returns the source URL and the version of the requirement (highest matching version).
198+
Returns list of (source URL, version) tuples, sorted by version (highest first).
180199
181200
Git URL resolution stays in Bootstrapper because it requires
182201
build orchestration (BuildEnvironment, build dependencies).
183202
Delegates PyPI/graph resolution to BootstrapRequirementResolver.
203+
204+
Args:
205+
req: Package requirement to resolve
206+
req_type: Type of requirement
207+
return_all_versions: If True, return all matching versions.
208+
If False, return only highest version.
209+
210+
Returns:
211+
List of (url, version) tuples. Contains one item when
212+
return_all_versions=False, or all matching versions when True.
184213
"""
185214
if req.url:
186215
if req_type != RequirementType.TOP_LEVEL:
@@ -193,26 +222,23 @@ def resolve_version(
193222
cached_result = self._resolver.get_cached_resolution(req, pre_built=False)
194223
if cached_result is not None:
195224
logger.debug(f"resolved {req} from cache")
196-
# Pick highest version from cached list
197-
return cached_result[0]
225+
return cached_result if return_all_versions else [cached_result[0]]
198226

199227
logger.info("resolving source via URL, ignoring any plugins")
200228
source_url, resolved_version = self._resolve_version_from_git_url(req=req)
201229
# Cache the git URL resolution (always source, not prebuilt)
202230
# Store as list for consistency with cache structure
203-
self._resolver.cache_resolution(
204-
req, pre_built=False, result=[(source_url, resolved_version)]
205-
)
206-
return source_url, resolved_version
231+
result = [(source_url, resolved_version)]
232+
self._resolver.cache_resolution(req, pre_built=False, result=result)
233+
return result # Git URLs always return single version
207234

208235
# Delegate to RequirementResolver
209236
parent_req = self.why[-1][1] if self.why else None
210-
211-
# Returns the highest matching version
212237
return self._resolver.resolve(
213238
req=req,
214239
req_type=req_type,
215240
parent_req=parent_req,
241+
return_all_versions=return_all_versions,
216242
)
217243

218244
def _processing_build_requirement(self, current_req_type: RequirementType) -> bool:
@@ -249,30 +275,71 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
249275
250276
In test mode, catches build exceptions, records package name, and continues.
251277
In normal mode, raises exceptions immediately (fail-fast).
278+
279+
When multiple_versions is enabled, bootstraps all matching versions instead
280+
of just the highest version.
252281
"""
253282
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
254283

255-
# Resolve version first so we have it for error reporting.
284+
# Resolve versions - get all if multiple_versions mode is enabled, else get highest
256285
# In test mode, record resolution failures and continue.
257286
try:
258-
source_url, resolved_version = self.resolve_version(
287+
resolved_versions = self.resolve_versions(
259288
req=req,
260289
req_type=req_type,
290+
return_all_versions=self.multiple_versions,
261291
)
292+
if self.multiple_versions:
293+
logger.info(f"resolved {len(resolved_versions)} version(s) for {req}")
262294
except Exception as err:
263295
if not self.test_mode:
264296
raise
265297
self._record_test_mode_failure(req, None, err, "resolution")
266298
return
267299

300+
# Check if resolution returned no versions
301+
if not resolved_versions:
302+
raise RuntimeError(f"Could not resolve any versions for {req}")
303+
304+
# Bootstrap each resolved version
305+
for source_url, resolved_version in resolved_versions:
306+
self._bootstrap_single_version(req, req_type, source_url, resolved_version)
307+
308+
# In multiple versions mode, report any failures for this requirement
309+
if self.multiple_versions and self._failed_versions:
310+
failed_for_req = [
311+
(name, ver, exc)
312+
for name, ver, exc in self._failed_versions
313+
if name == canonicalize_name(req.name)
314+
]
315+
if failed_for_req:
316+
logger.warning(
317+
f"{req.name}: {len(failed_for_req)} version(s) failed to bootstrap"
318+
)
319+
for name, ver, exc in failed_for_req:
320+
logger.warning(f" - {name}=={ver}: {type(exc).__name__}: {exc}")
321+
322+
def _bootstrap_single_version(
323+
self,
324+
req: Requirement,
325+
req_type: RequirementType,
326+
source_url: str,
327+
resolved_version: Version,
328+
) -> None:
329+
"""Bootstrap a single version of a package.
330+
331+
Extracted from bootstrap() to handle both single and multiple version modes.
332+
"""
268333
# Capture parent before _track_why pushes current package onto the stack
269334
parent: tuple[Requirement, Version] | None = None
270335
if self.why:
271336
_, parent_req, parent_version = self.why[-1]
272337
parent = (parent_req, parent_version)
273338

274339
# Update dependency graph unconditionally (before seen check to capture all edges)
275-
self._add_to_graph(req, req_type, resolved_version, source_url, parent)
340+
# Skip for TOP_LEVEL as they were already added in resolve_and_add_top_level()
341+
if req_type != RequirementType.TOP_LEVEL:
342+
self._add_to_graph(req, req_type, resolved_version, source_url, parent)
276343

277344
# Build sdist-only (no wheel) if flag is set, unless this is a build
278345
# requirement which always needs a full wheel.
@@ -298,11 +365,29 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
298365
req, req_type, source_url, resolved_version, build_sdist_only
299366
)
300367
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-
)
368+
# In test_mode, record failure and continue
369+
if self.test_mode:
370+
self._record_test_mode_failure(
371+
req, str(resolved_version), err, "bootstrap"
372+
)
373+
return
374+
375+
# In multiple_versions mode, record failure and continue to next version
376+
if self.multiple_versions:
377+
pkg_name = canonicalize_name(req.name)
378+
self._failed_versions.append((pkg_name, str(resolved_version), err))
379+
logger.warning(
380+
f"{req.name}=={resolved_version}: failed to bootstrap: {type(err).__name__}: {err}"
381+
)
382+
# Remove failed node from graph since bootstrap didn't complete
383+
self.ctx.dependency_graph.remove_dependency(
384+
pkg_name, resolved_version
385+
)
386+
self.ctx.write_to_graph_to_file()
387+
return
388+
389+
# Otherwise, raise the exception (fail-fast)
390+
raise
306391

307392
def _bootstrap_impl(
308393
self,
@@ -924,12 +1009,13 @@ def _handle_test_mode_failure(
9241009

9251010
try:
9261011
parent_req = self.why[-1][1] if self.why else None
927-
wheel_url, fallback_version = self._resolver.resolve(
1012+
results = self._resolver.resolve(
9281013
req=req,
9291014
req_type=req_type,
9301015
parent_req=parent_req,
9311016
pre_built=True, # Force prebuilt for test mode fallback
9321017
)
1018+
wheel_url, fallback_version = results[0]
9331019

9341020
if fallback_version != resolved_version:
9351021
logger.warning(
@@ -1261,9 +1347,6 @@ def _add_to_graph(
12611347
download_url: str,
12621348
parent: tuple[Requirement, Version] | None,
12631349
) -> None:
1264-
if req_type == RequirementType.TOP_LEVEL:
1265-
return
1266-
12671350
parent_req, parent_version = parent if parent else (None, None)
12681351
pbi = self.ctx.package_build_info(req)
12691352
# Update the dependency graph after we determine that this requirement is

0 commit comments

Comments
 (0)