Skip to content

Commit 478701b

Browse files
Merge pull request #1089 from rd4398/date-range-filtering
feat(bootstrap): add --max-release-age for multiple-versions mode
2 parents 01b9c0d + f280d30 commit 478701b

10 files changed

Lines changed: 474 additions & 5 deletions

e2e/ci_bootstrap_suite.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ run_test "bootstrap_prerelease"
2626
run_test "bootstrap_cache"
2727
run_test "bootstrap_sdist_only"
2828
run_test "bootstrap_multiple_versions"
29+
run_test "bootstrap_max_release_age"
2930

3031
test_section "bootstrap test-mode tests"
3132
run_test "mode_resolution"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Test bootstrap with --max-release-age flag
5+
# Tests that old versions are filtered out by the max release age window
6+
# and that the filter also applies to build dependencies
7+
8+
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
9+
source "$SCRIPTDIR/common.sh"
10+
11+
# certifi PyPI upload timeline (actual upload_time from PyPI JSON API):
12+
#
13+
# certifi 2025.11.12 2025-11-12 (should be filtered — too old)
14+
# certifi 2026.1.4 2026-01-04 (should be filtered — too old)
15+
# certifi 2026.2.25 2026-02-25 (should be included — recent enough)
16+
# certifi 2026.4.22 2026-04-22 (should be included — recent enough)
17+
#
18+
# Compute --max-release-age so certifi 2026.2.25 is inside the window
19+
# but certifi 2026.1.4 is outside. We anchor on certifi 2026.2.25's
20+
# upload date and add a buffer.
21+
MAX_AGE=$(python3 -c "
22+
from datetime import date
23+
# Age of certifi 2026.2.25 (uploaded 2026-02-25) + 10 day buffer
24+
age = (date.today() - date(2026, 2, 25)).days + 10
25+
print(age)
26+
")
27+
28+
echo "Using --max-release-age=$MAX_AGE"
29+
30+
fromager \
31+
--log-file="$OUTDIR/bootstrap.log" \
32+
--error-log-file="$OUTDIR/fromager-errors.log" \
33+
--sdists-repo="$OUTDIR/sdists-repo" \
34+
--wheels-repo="$OUTDIR/wheels-repo" \
35+
--work-dir="$OUTDIR/work-dir" \
36+
bootstrap \
37+
--multiple-versions \
38+
--max-release-age="$MAX_AGE" \
39+
'certifi>=2025.11,<=2026.5'
40+
41+
# Verify that recent versions were built (within age window)
42+
echo ""
43+
echo "Checking for expected versions..."
44+
for version in 2026.2.25 2026.4.22; do
45+
if find "$OUTDIR/wheels-repo/downloads/" -name "certifi-$version-*.whl" | grep -q .; then
46+
echo "✓ Found wheel for certifi $version (within max-release-age window)"
47+
else
48+
echo "✗ Missing wheel for certifi $version"
49+
echo "ERROR: certifi $version should be within the max-release-age window"
50+
echo ""
51+
echo "Found wheels:"
52+
find "$OUTDIR/wheels-repo/downloads/" -name 'certifi-*.whl'
53+
exit 1
54+
fi
55+
done
56+
57+
# Verify that old versions were filtered out
58+
echo ""
59+
echo "Checking that old versions were filtered..."
60+
UNEXPECTED=""
61+
for version in 2025.11.12 2026.1.4; do
62+
if find "$OUTDIR/wheels-repo/downloads/" -name "certifi-$version-*.whl" | grep -q .; then
63+
echo "✗ Found wheel for certifi $version — should have been filtered by max-release-age"
64+
UNEXPECTED="$UNEXPECTED $version"
65+
else
66+
echo "✓ certifi $version correctly filtered out by max-release-age"
67+
fi
68+
done
69+
70+
if [ -n "$UNEXPECTED" ]; then
71+
echo ""
72+
echo "ERROR: --max-release-age should have excluded:$UNEXPECTED"
73+
exit 1
74+
fi
75+
76+
# Verify that max-release-age filtering was applied (check log)
77+
echo ""
78+
echo "Checking log for max-release-age filtering..."
79+
if grep -q "published within.*days" "$OUTDIR/bootstrap.log"; then
80+
echo "✓ Log confirms max-release-age filtering was applied"
81+
else
82+
echo "✗ No max-release-age filtering found in log"
83+
exit 1
84+
fi
85+
86+
# Verify that build dependencies were also resolved within the window
87+
# setuptools is the build dependency for certifi
88+
echo ""
89+
echo "Checking that build dependencies were resolved..."
90+
if find "$OUTDIR/wheels-repo/downloads/" -name "setuptools-*.whl" | grep -q .; then
91+
echo "✓ setuptools was built (build dependency of certifi)"
92+
else
93+
echo "✗ setuptools was not built — build dependency resolution may have failed"
94+
exit 1
95+
fi
96+
97+
echo ""
98+
echo "SUCCESS: --max-release-age correctly filtered old versions and resolved build dependencies"

e2e/test_bootstrap_multiple_versions.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ cat > "$constraints_file" <<EOF
1616
flit-core>=3.9,<3.12
1717
EOF
1818

19+
# Compute --max-release-age dynamically: days since tomli 2.0.0 was uploaded
20+
# to PyPI (2021-12-13) plus a buffer, so the oldest version is always included.
21+
MAX_AGE=$(python3 -c "
22+
from datetime import date
23+
age = (date.today() - date(2021, 12, 13)).days
24+
print(age + 30)
25+
")
26+
1927
# Use tomli with a version range that matches exactly 3 versions (2.0.0, 2.0.1, 2.0.2)
2028
# tomli has no runtime dependencies, making it fast to bootstrap
2129
# It uses flit-core as build backend, and we allow multiple flit-core versions
@@ -31,6 +39,7 @@ fromager \
3139
--constraints-file="$constraints_file" \
3240
bootstrap \
3341
--multiple-versions \
42+
--max-release-age="$MAX_AGE" \
3443
'tomli>=2.0,<=2.0.2'
3544

3645
# Check that wheels were built

src/fromager/bootstrap_requirement_resolver.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ def _resolve(
173173
sdist_server_url=resolver.PYPI_SERVER_URL,
174174
req_type=req_type,
175175
)
176-
return resolver.find_all_matching_from_provider(provider, req)
176+
max_age_cutoff = resolver._compute_max_age_cutoff(self.ctx)
177+
return resolver.find_all_matching_from_provider(
178+
provider, req, max_age_cutoff=max_age_cutoff
179+
)
177180

178181
def get_cached_resolution(
179182
self,

src/fromager/commands/bootstrap.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ def _get_requirements_from_args(
110110
default=False,
111111
help="Bootstrap all matching versions instead of only the highest version",
112112
)
113+
@click.option(
114+
"--max-release-age",
115+
"max_release_age",
116+
type=click.IntRange(min=1),
117+
default=None,
118+
help="Reject package versions published more than this many days ago.",
119+
)
113120
@click.argument("toplevel", nargs=-1)
114121
@click.pass_obj
115122
def bootstrap(
@@ -121,6 +128,7 @@ def bootstrap(
121128
skip_constraints: bool,
122129
test_mode: bool,
123130
multiple_versions: bool,
131+
max_release_age: int | None,
124132
toplevel: list[str],
125133
) -> None:
126134
"""Compute and build the dependencies of a set of requirements recursively
@@ -168,6 +176,13 @@ def bootstrap(
168176
)
169177
skip_constraints = True
170178

179+
if max_release_age is not None:
180+
wkctx.set_max_release_age(max_release_age)
181+
logger.info(
182+
"max release age: rejecting versions older than %d days",
183+
max_release_age,
184+
)
185+
171186
pre_built = wkctx.settings.list_pre_built()
172187
if pre_built:
173188
logger.info("treating %s as pre-built wheels", sorted(pre_built))
@@ -492,6 +507,13 @@ def write_constraints_file(
492507
default=False,
493508
help="Bootstrap all matching versions instead of only the highest version",
494509
)
510+
@click.option(
511+
"--max-release-age",
512+
"max_release_age",
513+
type=click.IntRange(min=1),
514+
default=None,
515+
help="Reject package versions published more than this many days ago.",
516+
)
495517
@click.argument("toplevel", nargs=-1)
496518
@click.pass_obj
497519
@click.pass_context
@@ -506,6 +528,7 @@ def bootstrap_parallel(
506528
force: bool,
507529
max_workers: int | None,
508530
multiple_versions: bool,
531+
max_release_age: int | None,
509532
toplevel: list[str],
510533
) -> None:
511534
"""Bootstrap and build-parallel
@@ -533,6 +556,7 @@ def bootstrap_parallel(
533556
sdist_only=True,
534557
skip_constraints=skip_constraints,
535558
multiple_versions=multiple_versions,
559+
max_release_age=max_release_age,
536560
toplevel=toplevel,
537561
)
538562

src/fromager/context.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def __init__(
6363
settings_dir: pathlib.Path | None = None,
6464
wheel_server_url: str = "",
6565
cooldown: Cooldown | None = None,
66+
max_release_age: datetime.timedelta | None = None,
6667
):
6768
if active_settings is None:
6869
active_settings = packagesettings.Settings(
@@ -113,6 +114,17 @@ def __init__(
113114
self._parallel_builds = False
114115

115116
self.cooldown: Cooldown | None = cooldown
117+
self._max_release_age: datetime.timedelta | None = max_release_age
118+
119+
@property
120+
def max_release_age(self) -> datetime.timedelta | None:
121+
return self._max_release_age
122+
123+
def set_max_release_age(self, days: int) -> None:
124+
"""Set the maximum release age in days."""
125+
if days < 1:
126+
raise ValueError(f"max_release_age must be positive, got {days}")
127+
self._max_release_age = datetime.timedelta(days=days)
116128

117129
def enable_parallel_builds(self) -> None:
118130
self._parallel_builds = True

src/fromager/resolver.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ def resolve(
105105
ignore_platform=ignore_platform,
106106
)
107107
provider.cooldown = resolve_package_cooldown(ctx, req)
108-
results = find_all_matching_from_provider(provider, req)
108+
max_age_cutoff = _compute_max_age_cutoff(ctx)
109+
results = find_all_matching_from_provider(
110+
provider, req, max_age_cutoff=max_age_cutoff
111+
)
109112
return results[0]
110113

111114

@@ -167,6 +170,24 @@ def resolve_package_cooldown(
167170
)
168171

169172

173+
def _compute_max_age_cutoff(
174+
ctx: context.WorkContext,
175+
) -> datetime.datetime | None:
176+
"""Compute the cutoff time for max release age filtering.
177+
178+
Returns the oldest acceptable upload time, or None if disabled.
179+
Uses the cooldown's bootstrap_time for consistency across a single run.
180+
"""
181+
if ctx.max_release_age is None:
182+
return None
183+
bootstrap_time = (
184+
ctx.cooldown.bootstrap_time
185+
if ctx.cooldown is not None
186+
else datetime.datetime.now(datetime.UTC)
187+
)
188+
return bootstrap_time - ctx.max_release_age
189+
190+
170191
def extract_filename_from_url(url: str) -> str:
171192
"""Extract filename from URL and decode it."""
172193
path = urlparse(url).path
@@ -203,13 +224,22 @@ def ending(self, state: typing.Any) -> None:
203224

204225

205226
def find_all_matching_from_provider(
206-
provider: BaseProvider, req: Requirement
227+
provider: BaseProvider,
228+
req: Requirement,
229+
max_age_cutoff: datetime.datetime | None = None,
207230
) -> list[tuple[str, Version]]:
208231
"""Find all matching candidates from provider without full dependency resolution.
209232
210233
This function collects ALL candidates that match the requirement, rather than
211234
performing full dependency resolution to find a single best candidate.
212235
236+
Args:
237+
provider: The provider to query for candidates.
238+
req: The requirement to match.
239+
max_age_cutoff: If set, reject candidates published before this time.
240+
If all candidates are older than the cutoff, all are kept and
241+
a warning is emitted to avoid empty resolution.
242+
213243
Returns list of (url, version) tuples sorted by version (highest first).
214244
215245
IMPORTANT: This bypasses resolvelib's full resolver to collect all matching
@@ -242,10 +272,47 @@ def find_all_matching_from_provider(
242272
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
243273
) from err
244274

275+
# Materialize candidates so we can iterate more than once if filtering
276+
candidates_list = list(candidates)
277+
278+
if max_age_cutoff is not None:
279+
logger.info(
280+
"%s: found %d candidate(s) matching %s",
281+
req.name,
282+
len(candidates_list),
283+
req,
284+
)
285+
max_age_days = (datetime.datetime.now(datetime.UTC) - max_age_cutoff).days
286+
filtered = [
287+
c
288+
for c in candidates_list
289+
if c.upload_time is None or c.upload_time >= max_age_cutoff
290+
]
291+
dropped = len(candidates_list) - len(filtered)
292+
if dropped:
293+
logger.info(
294+
"%s: have %d candidate(s) of %s published within %d days",
295+
req.name,
296+
len(filtered),
297+
req,
298+
max_age_days,
299+
)
300+
if filtered:
301+
candidates_list = filtered
302+
else:
303+
logger.warning(
304+
"%s: all %d candidate(s) of %s are older than %d days, "
305+
"keeping all to avoid empty resolution",
306+
req.name,
307+
len(candidates_list),
308+
req,
309+
max_age_days,
310+
)
311+
245312
# Convert candidates to list of (url, version) tuples
246313
# Candidates are sorted by version (highest first) by BaseProvider.find_matches()
247314
# which calls sorted(candidates, key=attrgetter("version", "build_tag"), reverse=True)
248-
return [(candidate.url, candidate.version) for candidate in candidates]
315+
return [(c.url, c.version) for c in candidates_list]
249316

250317

251318
def get_project_from_pypi(

src/fromager/sources.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ def resolve_source(
181181
)
182182

183183
# Get all matching candidates from provider
184-
results = resolver.find_all_matching_from_provider(provider, req)
184+
max_age_cutoff = resolver._compute_max_age_cutoff(ctx)
185+
results = resolver.find_all_matching_from_provider(
186+
provider, req, max_age_cutoff=max_age_cutoff
187+
)
185188

186189
# Return highest version (first in sorted list)
187190
url, version = results[0]

0 commit comments

Comments
 (0)