Skip to content

Commit e634f8b

Browse files
Merge branch 'pantsbuild:main' into main
2 parents 718f22f + cd0aa95 commit e634f8b

File tree

4 files changed

+310
-4
lines changed

4 files changed

+310
-4
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2026 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
from collections.abc import Iterator
4+
5+
from external_tool.github import GithubReleases
6+
7+
8+
class HelmReleases:
9+
"""Fetches Helm releases from GitHub.
10+
11+
Helm binaries are hosted on get.helm.sh, but releases are published on
12+
GitHub (helm/helm). This class uses the GitHub Releases API to discover
13+
available versions.
14+
15+
See https://github.com/helm/helm/issues/5663 for historical hosting discussion.
16+
"""
17+
18+
_GITHUB_URL_TEMPLATE = "https://github.com/helm/helm/releases/download/v{version}/helm-v{version}-{platform}.tar.gz"
19+
20+
def __init__(self, only_latest: bool) -> None:
21+
self.only_latest = only_latest
22+
self._github_releases = GithubReleases(only_latest=only_latest)
23+
24+
def get_releases(self, url_template: str) -> Iterator[str]:
25+
# Ignore the url_template (which points to get.helm.sh) and use GitHub instead
26+
return self._github_releases.get_releases(self._GITHUB_URL_TEMPLATE)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright 2026 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
from external_tool.helm import HelmReleases
4+
5+
6+
def test_helm_releases_only_latest(monkeypatch) -> None:
7+
mock_releases = [
8+
{"tag_name": "v3.14.3"},
9+
{"tag_name": "v3.14.2"},
10+
{"tag_name": "v3.13.0"},
11+
]
12+
13+
def mock_fetch(url: str):
14+
assert "helm/helm" in url
15+
return mock_releases
16+
17+
monkeypatch.setattr("external_tool.github.fetch_releases", mock_fetch)
18+
19+
helm = HelmReleases(only_latest=True)
20+
versions = list(helm.get_releases("https://get.helm.sh/helm-v{version}-{platform}.tar.gz"))
21+
22+
assert versions == ["3.14.3"]
23+
24+
25+
def test_helm_releases_all_versions(monkeypatch) -> None:
26+
mock_releases = [
27+
{"tag_name": "v3.14.3"},
28+
{"tag_name": "v3.14.2"},
29+
{"tag_name": "v3.13.0"},
30+
]
31+
32+
def mock_fetch(url: str):
33+
assert "helm/helm" in url
34+
return mock_releases
35+
36+
monkeypatch.setattr("external_tool.github.fetch_releases", mock_fetch)
37+
38+
helm = HelmReleases(only_latest=False)
39+
versions = list(helm.get_releases("https://get.helm.sh/helm-v{version}-{platform}.tar.gz"))
40+
41+
assert versions == ["3.14.3", "3.14.2", "3.13.0"]
42+
43+
44+
def test_helm_releases_uses_github_api(monkeypatch) -> None:
45+
captured_url = None
46+
47+
def mock_fetch(url: str):
48+
nonlocal captured_url
49+
captured_url = url
50+
return [{"tag_name": "v3.14.3"}]
51+
52+
monkeypatch.setattr("external_tool.github.fetch_releases", mock_fetch)
53+
54+
helm = HelmReleases(only_latest=True)
55+
list(helm.get_releases("https://get.helm.sh/helm-v{version}-{platform}.tar.gz"))
56+
57+
assert captured_url == "https://api.github.com/repos/helm/helm/releases"

build-support/bin/external_tool_upgrade.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@
2727

2828
import requests
2929
from external_tool.github import GithubReleases
30+
from external_tool.helm import HelmReleases
3031
from external_tool.kubectl import KubernetesReleases
3132
from external_tool.python import (
3233
find_modules_with_subclasses,
3334
get_class_variables,
3435
replace_class_variables,
3536
)
37+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
3638
from packaging.version import Version
3739
from tqdm import tqdm
3840

@@ -148,6 +150,7 @@ def fetch_version(
148150
class Tool:
149151
default_known_versions: list[str]
150152
default_url_template: str
153+
default_version: str
151154
default_url_platform_mapping: dict[str, str] | None = None
152155

153156

@@ -156,6 +159,22 @@ class Mode(StrEnum):
156159
calculate_sha_and_size = "calculate-sha-and-size"
157160

158161

162+
def filter_versions_by_constraint(
163+
versions: list[ExternalToolVersion],
164+
constraint: str | None,
165+
) -> list[ExternalToolVersion]:
166+
"""Filter versions using packaging.specifiers.
167+
168+
>>> filter_versions_by_constraint([ExternalToolVersion("5.0", "cowsay", "", 0)], ">=4.0,<6.0")
169+
[ExternalToolVersion(version='5.0', platform='cowsay', sha256='', filesize=0, url_override=None)]
170+
"""
171+
if constraint is None:
172+
return versions
173+
174+
specifier = SpecifierSet(constraint)
175+
return [v for v in versions if specifier.contains(v.version.lstrip("v"))]
176+
177+
159178
EXCLUDE_TOOLS = {
160179
"ExternalCCSubsystem", # Doesn't define default_url_template.
161180
"ExternalHelmPlugin", # Is a base class itself.
@@ -202,9 +221,26 @@ def main():
202221
type=Mode,
203222
default="calculate-sha-and-size",
204223
)
224+
parser.add_argument(
225+
"--version-constraint",
226+
default=None,
227+
help="Version constraint to filter versions (e.g., '>2.2.2,<3')",
228+
)
229+
parser.add_argument(
230+
"--max-releases",
231+
type=int,
232+
default=64,
233+
help="Maximum number of releases to fetch when --version-constraint is set (default: 64)",
234+
)
205235

206236
args = parser.parse_args()
207237

238+
if args.version_constraint:
239+
try:
240+
SpecifierSet(args.version_constraint)
241+
except InvalidSpecifier as e:
242+
parser.error(f"Invalid version constraint: {e}")
243+
208244
level = logging.DEBUG if args.verbose else logging.INFO
209245
logging.basicConfig(level=logging.INFO, format="%(message)s")
210246
logging.getLogger(__name__).level = level
@@ -227,12 +263,15 @@ def main():
227263
pool = ThreadPool(processes=args.workers)
228264
platforms = args.platforms.split(",")
229265

266+
# When a version constraint is specified, fetch more releases (up to --max-releases) to filter
267+
only_latest = args.version_constraint is None
268+
230269
mapping: dict[str, Releases | None] = {
231270
"dl.k8s.io": KubernetesReleases(pool=pool, only_latest=True),
232271
"github.com": GithubReleases(only_latest=True),
272+
"get.helm.sh": HelmReleases(only_latest=only_latest),
233273
"releases.hashicorp.com": None, # TODO
234274
"raw.githubusercontent.com": None, # TODO
235-
"get.helm.sh": None, # TODO
236275
"binaries.pantsbuild.org": None, # TODO
237276
}
238277

@@ -254,6 +293,20 @@ def main():
254293
continue
255294

256295
releases = list(releases.get_releases(tool.default_url_template))
296+
297+
# Limit and filter releases by constraint before downloading binaries
298+
if args.version_constraint:
299+
releases = releases[: args.max_releases]
300+
specifier = SpecifierSet(args.version_constraint)
301+
releases = [v for v in releases if specifier.contains(v.lstrip("v"))]
302+
if not releases:
303+
logger.warning(
304+
"No releases for %s match constraint %r, skipping",
305+
class_name,
306+
args.version_constraint,
307+
)
308+
continue
309+
257310
for version in releases:
258311
for platform in platforms:
259312
futures.append(
@@ -285,18 +338,46 @@ def main():
285338
existing_versions = {
286339
ExternalToolVersion.decode(version) for version in tool.default_known_versions
287340
}
288-
fetched_versions = {version.version for version in versions}
341+
342+
path, class_name = group
343+
344+
external_versions = [v.version for v in versions]
345+
if args.version_constraint:
346+
external_versions = filter_versions_by_constraint(
347+
external_versions, args.version_constraint
348+
)
349+
if not external_versions:
350+
logger.warning(
351+
"No fetched versions for %s match constraint %r, skipping",
352+
class_name,
353+
args.version_constraint,
354+
)
355+
continue
356+
357+
fetched_versions = set(external_versions)
289358

290359
known_versions = list(existing_versions | fetched_versions)
291360
known_versions.sort(key=lambda tu: (Version(tu.version), tu.platform), reverse=True)
292361

293-
path, class_name = group
362+
if args.version_constraint:
363+
# Only upgrade if the newest matching version is greater than current default
364+
filtered_versions = filter_versions_by_constraint(
365+
known_versions, args.version_constraint
366+
)
367+
current_default = Version(tool.default_version.lstrip("v"))
368+
newest_matching = Version(filtered_versions[0].version.lstrip("v"))
369+
if newest_matching > current_default:
370+
default_version = filtered_versions[0].version
371+
else:
372+
default_version = tool.default_version
373+
else:
374+
default_version = known_versions[0].version
294375

295376
replace_class_variables(
296377
path,
297378
class_name,
298379
replacements={
299-
"default_version": known_versions[0].version,
380+
"default_version": default_version,
300381
"default_known_versions": [v.encode() for v in known_versions],
301382
},
302383
)

build-support/bin/external_tool_upgrade_test.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,151 @@
33
import doctest
44

55
import external_tool_upgrade
6+
from external_tool_upgrade import ExternalToolVersion, filter_versions_by_constraint
7+
from packaging.version import Version
68

79

810
def test_docs() -> None:
911
failure_count, test_count = doctest.testmod(external_tool_upgrade)
1012
assert test_count > 0
1113
assert failure_count == 0
14+
15+
16+
def test_filter_fetched_versions_with_constraint() -> None:
17+
fetched = [
18+
ExternalToolVersion("5.0", "linux_x86_64", "abc", 100),
19+
ExternalToolVersion("6.0", "linux_x86_64", "def", 200),
20+
ExternalToolVersion("6.1", "linux_x86_64", "ghi", 300),
21+
]
22+
23+
filtered = filter_versions_by_constraint(fetched, ">=6.0")
24+
25+
assert len(filtered) == 2
26+
assert all(isinstance(v, ExternalToolVersion) for v in filtered)
27+
assert {v.version for v in filtered} == {"6.0", "6.1"}
28+
29+
30+
def test_filter_versions_by_constraint_none() -> None:
31+
versions = [
32+
ExternalToolVersion("5.0", "cowsay", "abc", 100),
33+
ExternalToolVersion("6.0", "cowsay", "def", 200),
34+
]
35+
result = filter_versions_by_constraint(versions, None)
36+
assert result == versions
37+
38+
39+
def test_filter_versions_by_constraint_basic() -> None:
40+
versions = [
41+
ExternalToolVersion("3.0", "cowsay", "abc", 100),
42+
ExternalToolVersion("4.0", "cowsay", "def", 200),
43+
ExternalToolVersion("5.0", "cowsay", "ghi", 300),
44+
]
45+
result = filter_versions_by_constraint(versions, ">=4.0,<5.0")
46+
assert len(result) == 1
47+
assert result[0].version == "4.0"
48+
49+
50+
def test_filter_versions_by_constraint_with_v_prefix() -> None:
51+
versions = [
52+
ExternalToolVersion("v5.0", "cowsay", "abc", 100),
53+
ExternalToolVersion("v6.0", "cowsay", "def", 200),
54+
]
55+
result = filter_versions_by_constraint(versions, ">5.0")
56+
assert len(result) == 1
57+
assert result[0].version == "v6.0"
58+
59+
60+
def test_filter_versions_by_constraint_no_matches() -> None:
61+
versions = [
62+
ExternalToolVersion("6.1", "cowsay", "abc", 100),
63+
]
64+
result = filter_versions_by_constraint(versions, ">7.0")
65+
assert result == []
66+
67+
68+
def test_filter_versions_by_constraint_multiple_matches() -> None:
69+
versions = [
70+
ExternalToolVersion("3.0", "cowsay", "abc", 100),
71+
ExternalToolVersion("4.0", "cowsay", "def", 200),
72+
ExternalToolVersion("5.0", "cowsay", "ghi", 250),
73+
ExternalToolVersion("6.0", "cowsay", "jkl", 300),
74+
]
75+
result = filter_versions_by_constraint(versions, ">4.0,<6.0")
76+
assert len(result) == 1
77+
assert result[0].version == "5.0"
78+
79+
80+
def _select_default_version_with_constraint(
81+
known_versions: list[ExternalToolVersion],
82+
current_default: str,
83+
constraint: str,
84+
) -> str:
85+
"""Helper that mirrors the upgrade logic in main().
86+
87+
Returns the new default_version based on the constraint and current default. Only upgrades if
88+
the newest matching version is greater than current default.
89+
"""
90+
filtered = filter_versions_by_constraint(known_versions, constraint)
91+
if not filtered:
92+
return current_default
93+
94+
current = Version(current_default.lstrip("v"))
95+
newest_matching = Version(filtered[0].version.lstrip("v"))
96+
97+
if newest_matching > current:
98+
return filtered[0].version
99+
return current_default
100+
101+
102+
def test_version_constraint_upgrades_when_newer_version_available() -> None:
103+
known_versions = [
104+
ExternalToolVersion("6.1", "cowsay", "abc", 100),
105+
ExternalToolVersion("6.0", "cowsay", "def", 200),
106+
ExternalToolVersion("5.0", "cowsay", "ghi", 300),
107+
ExternalToolVersion("4.0", "cowsay", "jkl", 400),
108+
]
109+
current_default = "5.0"
110+
constraint = ">=6.0,<7"
111+
112+
result = _select_default_version_with_constraint(known_versions, current_default, constraint)
113+
assert result == "6.1"
114+
115+
116+
def test_version_constraint_no_upgrade_when_no_newer_matching_version() -> None:
117+
known_versions = [
118+
ExternalToolVersion("6.1", "cowsay", "abc", 100),
119+
ExternalToolVersion("6.0", "cowsay", "def", 200),
120+
ExternalToolVersion("5.0", "cowsay", "ghi", 300),
121+
ExternalToolVersion("4.0", "cowsay", "jkl", 400),
122+
]
123+
current_default = "6.0"
124+
constraint = ">=3.0,<5.0"
125+
126+
result = _select_default_version_with_constraint(known_versions, current_default, constraint)
127+
assert result == "6.0"
128+
129+
130+
def test_version_constraint_no_upgrade_when_already_at_newest_matching() -> None:
131+
known_versions = [
132+
ExternalToolVersion("6.1", "cowsay", "abc", 100),
133+
ExternalToolVersion("6.0", "cowsay", "def", 200),
134+
ExternalToolVersion("5.0", "cowsay", "ghi", 300),
135+
]
136+
current_default = "6.0"
137+
constraint = ">=5.0,<6.1"
138+
139+
result = _select_default_version_with_constraint(known_versions, current_default, constraint)
140+
assert result == "6.0"
141+
142+
143+
def test_version_constraint_with_v_prefix_upgrades_correctly() -> None:
144+
known_versions = [
145+
ExternalToolVersion("v6.0", "cowsay", "abc", 100),
146+
ExternalToolVersion("v5.0", "cowsay", "def", 200),
147+
ExternalToolVersion("v4.0", "cowsay", "ghi", 300),
148+
]
149+
current_default = "v4.0"
150+
constraint = ">4.0,<6.0"
151+
152+
result = _select_default_version_with_constraint(known_versions, current_default, constraint)
153+
assert result == "v5.0"

0 commit comments

Comments
 (0)