Skip to content

Commit c762db9

Browse files
AlexanderLaninCopilot
andcommitted
drop sh and use py
Co-authored-by: Copilot <copilot@github.com>
1 parent e38d2dc commit c762db9

9 files changed

Lines changed: 264 additions & 378 deletions

File tree

.devcontainer/post_create_command.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ npm install -g @devcontainers/cli
1818
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
1919
REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)"
2020

21-
sudo bash "${REPOSITORY_ROOT}/tools/tool_lockfile_helpers.sh" install shellcheck yamlfmt
21+
sudo python3 "${REPOSITORY_ROOT}/tools/tool_installer.py" install shellcheck yamlfmt
2222

2323
pre-commit install
2424

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ build/
2121

2222
# AI
2323
/.codex
24+
25+
# Python files
26+
*.pyc

src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ apt-get update
4040
apt-get install apt-transport-https -y
4141

4242
# Lockfile-managed Bazel tooling
43-
bash /usr/local/share/score-tools/tool_lockfile_helpers.sh install bazelisk buildifier starpls
43+
python3 /usr/local/share/score-tools/tool_installer.py install bazelisk buildifier starpls
4444

4545
# Bazelisk + Bazel
4646
ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel

src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ set -euo pipefail
1818
# Read tool versions + metadata into environment variables
1919
. /usr/local/share/score-tools/versions.sh /devcontainer/features/bazel/versions.yaml
2020

21-
bazelisk_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version bazelisk)"
22-
buildifier_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version buildifier)"
23-
starpls_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version starpls)"
21+
bazelisk_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version bazelisk)"
22+
buildifier_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version buildifier)"
23+
starpls_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version starpls)"
2424
# Bazel-related tools
2525
## This is the bazel version preinstalled in the devcontainer.
2626
## A solid test would disable the network interface first to prevent a different version from being downloaded,

src/s-core-devcontainer/.devcontainer/s-core-local/install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ apt-get install -y "python${python_version}" python3-pip python3-venv
5858
apt-get install -y flake8 python3-autopep8 black python3-yapf mypy pydocstyle pycodestyle bandit pipenv virtualenv pylint
5959

6060
# Lockfile-managed local developer tools
61-
bash /usr/local/share/score-tools/tool_lockfile_helpers.sh install shellcheck ruff actionlint yamlfmt uv uvx
61+
python3 /usr/local/share/score-tools/tool_installer.py install shellcheck ruff actionlint yamlfmt uv uvx
6262

6363
# GraphViz
6464
# The Ubuntu Noble package of GraphViz

src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ KERNEL=$(uname -s)
2121
# Read tool versions + metadata into environment variables
2222
. /usr/local/share/score-tools/versions.sh /devcontainer/features/s-core-local/versions.yaml
2323

24-
shellcheck_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version shellcheck)"
25-
ruff_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version ruff)"
26-
actionlint_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version actionlint)"
27-
yamlfmt_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version yamlfmt)"
28-
uv_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version uv)"
29-
uvx_lockfile_version="$(bash /usr/local/share/score-tools/tool_lockfile_helpers.sh version uvx)"
24+
shellcheck_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version shellcheck)"
25+
ruff_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version ruff)"
26+
actionlint_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version actionlint)"
27+
yamlfmt_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version yamlfmt)"
28+
uv_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version uv)"
29+
uvx_lockfile_version="$(python3 /usr/local/share/score-tools/tool_installer.py version uvx)"
3030

3131
# pre-commit, it is available via $PATH in login shells, but not in non-login shells
3232
check "validate pre-commit is working and has the correct version" bash -c "pre-commit --version | grep '4.5.1'"

tools/tool_installer.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# *******************************************************************************
2+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
#
4+
# See the NOTICE file(s) distributed with this work for additional
5+
# information regarding copyright ownership.
6+
#
7+
# This program and the accompanying materials are made available under the
8+
# terms of the Apache License Version 2.0 which is available at
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# SPDX-License-Identifier: Apache-2.0
12+
# *******************************************************************************
13+
"""Install pinned tools from the `tools/lockfiles/*.lock.json` catalog.
14+
15+
Dependency-free (stdlib only) so devcontainer feature installers can use it
16+
without extra packages.
17+
18+
Usage:
19+
python3 tool_installer.py install shellcheck yamlfmt
20+
python3 tool_installer.py version shellcheck
21+
"""
22+
23+
# pyright: reportAny=false, reportUnusedCallResult=false, reportExplicitAny=false
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import hashlib
29+
import json
30+
import platform
31+
import shutil
32+
import sys
33+
import tarfile
34+
import tempfile
35+
import urllib.request
36+
import zipfile
37+
from pathlib import Path
38+
from typing import NotRequired, TypedDict
39+
40+
41+
class Binary(TypedDict):
42+
"""A single binary entry from a tool's lockfile definition."""
43+
44+
os: str
45+
cpu: str
46+
kind: str
47+
url: str
48+
sha256: str
49+
type: NotRequired[str]
50+
file: NotRequired[str]
51+
52+
53+
class ToolData(TypedDict):
54+
"""Tool metadata from a lockfile entry."""
55+
56+
version: NotRequired[str]
57+
binaries: list[Binary]
58+
59+
60+
LOCKFILE_ROOT = Path(__file__).resolve().parent / "lockfiles"
61+
62+
63+
def _detect_os() -> str:
64+
"""Map Python's platform string to the lockfile schema's OS names."""
65+
system = platform.system()
66+
if system == "Linux":
67+
return "linux"
68+
if system == "Darwin":
69+
return "macos"
70+
raise SystemExit(f"Unsupported OS: {system}")
71+
72+
73+
def _detect_cpu() -> str:
74+
"""Map Python's machine string to the lockfile schema's CPU names."""
75+
machine = platform.machine().lower()
76+
if machine in {"x86_64", "amd64"}:
77+
return "x86_64"
78+
if machine in {"arm64", "aarch64"}:
79+
return "arm64"
80+
raise SystemExit(f"Unsupported CPU architecture: {machine}")
81+
82+
83+
def _lockfile_path(lockfile: str) -> Path:
84+
"""Resolve a lockfile basename like `ruff` to `ruff.lock.json`."""
85+
return LOCKFILE_ROOT / f"{lockfile}.lock.json"
86+
87+
88+
def _load_tool(lockfile: str, tool: str) -> ToolData:
89+
"""Load one tool entry from a lockfile and fail with a clear message."""
90+
with _lockfile_path(lockfile).open(encoding="utf-8") as handle:
91+
data = json.load(handle)
92+
93+
try:
94+
return data[tool]
95+
except KeyError as exc:
96+
raise SystemExit(
97+
f"Tool '{tool}' not found in lockfile '{lockfile}.lock.json'",
98+
) from exc
99+
100+
101+
def _find_lockfile(tool: str) -> str:
102+
"""Find the lockfile basename that declares a tool."""
103+
for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")):
104+
with path.open(encoding="utf-8") as handle:
105+
data = json.load(handle)
106+
if tool in data:
107+
return path.name.removesuffix(".lock.json")
108+
109+
raise SystemExit(f"Tool '{tool}' not found in lockfile catalog")
110+
111+
112+
def _resolve_lockfile(tool: str, lockfile: str | None = None) -> str:
113+
"""Return the lockfile basename for *tool*, auto-detecting when needed."""
114+
if lockfile is not None:
115+
return lockfile
116+
if _lockfile_path(tool).exists():
117+
return tool
118+
return _find_lockfile(tool)
119+
120+
121+
def _select_binary(tool_data: ToolData, os_name: str, cpu: str) -> Binary:
122+
"""Pick the binary entry matching the requested platform."""
123+
for binary in tool_data["binaries"]:
124+
if binary["os"] == os_name and binary["cpu"] == cpu:
125+
return binary
126+
127+
raise SystemExit(
128+
f"No binary defined for os={os_name!r}, cpu={cpu!r}",
129+
)
130+
131+
132+
def _cmd_version(args: argparse.Namespace) -> int:
133+
"""Print the declared version for one tool."""
134+
args.lockfile = _resolve_lockfile(args.tool, args.lockfile)
135+
tool_data = _load_tool(args.lockfile, args.tool)
136+
version = tool_data.get("version")
137+
if version is None:
138+
raise SystemExit(
139+
f"Tool '{args.tool}' in '{args.lockfile}.lock.json' does not define a version",
140+
)
141+
print(version)
142+
return 0
143+
144+
145+
def _place_binary(source: Path, destination: Path) -> None:
146+
"""Copy a file to its destination with executable permissions."""
147+
destination.parent.mkdir(parents=True, exist_ok=True)
148+
shutil.copy2(source, destination)
149+
destination.chmod(0o755)
150+
151+
152+
def _extract_member(
153+
binary: Binary, archive_path: Path, out_path: Path, tool: str
154+
) -> None:
155+
"""Extract one member from an archive and write it to *out_path*."""
156+
archive_type = binary.get("type", "")
157+
member = binary.get("file")
158+
if member is None:
159+
raise SystemExit(f"Binary entry for {tool} does not define 'file' field")
160+
161+
if archive_type in ("tar.gz", "tgz", "tar.xz", "txz"):
162+
with tarfile.open(archive_path) as tf:
163+
reader = tf.extractfile(member)
164+
if reader is None:
165+
raise SystemExit(f"Cannot extract '{member}' from archive for {tool}")
166+
out_path.write_bytes(reader.read())
167+
elif archive_type == "zip":
168+
with zipfile.ZipFile(archive_path) as zf:
169+
out_path.write_bytes(zf.read(member))
170+
else:
171+
raise SystemExit(f"Unsupported archive type '{archive_type}' for {tool}")
172+
173+
174+
def _cmd_install(args: argparse.Namespace) -> int:
175+
"""Download, verify, and install tools from the lockfile catalog."""
176+
dest_dir = Path(args.destination)
177+
178+
for tool in args.tools:
179+
lockfile = _resolve_lockfile(tool, args.lockfile)
180+
tool_data = _load_tool(lockfile, tool)
181+
binary = _select_binary(tool_data, args.os, args.cpu)
182+
183+
kind = binary["kind"]
184+
url = binary["url"]
185+
expected_sha256 = binary["sha256"]
186+
destination = dest_dir / tool
187+
188+
with tempfile.TemporaryDirectory() as tmp:
189+
tmp = Path(tmp)
190+
download = tmp / "download"
191+
192+
urllib.request.urlretrieve(url, download)
193+
194+
actual = hashlib.sha256(download.read_bytes()).hexdigest()
195+
if actual != expected_sha256:
196+
raise SystemExit(
197+
f"Checksum mismatch for {tool}: "
198+
+ f"expected {expected_sha256}, got {actual}"
199+
)
200+
201+
if kind == "file":
202+
_place_binary(download, destination)
203+
elif kind == "archive":
204+
extracted = tmp / "extracted"
205+
_extract_member(binary, download, extracted, tool)
206+
if extracted.exists():
207+
_place_binary(extracted, destination)
208+
else:
209+
raise SystemExit(f"Unsupported kind '{kind}' for {tool}")
210+
211+
return 0
212+
213+
214+
def _build_parser() -> argparse.ArgumentParser:
215+
parser = argparse.ArgumentParser(
216+
description="Install pinned tools from multitool-compatible lockfiles.",
217+
)
218+
subparsers = parser.add_subparsers(dest="command", required=True)
219+
220+
install_parser = subparsers.add_parser(
221+
"install",
222+
help="Download, verify, and install tools.",
223+
)
224+
install_parser.add_argument("tools", nargs="+")
225+
install_parser.add_argument("--lockfile")
226+
install_parser.add_argument("--destination", default="/usr/local/bin")
227+
install_parser.add_argument("--os", default=_detect_os())
228+
install_parser.add_argument("--cpu", default=_detect_cpu())
229+
install_parser.set_defaults(func=_cmd_install)
230+
231+
version_parser = subparsers.add_parser(
232+
"version",
233+
help="Print the declared version for a tool.",
234+
)
235+
version_parser.add_argument("tool")
236+
version_parser.add_argument("--lockfile")
237+
version_parser.set_defaults(func=_cmd_version)
238+
239+
return parser
240+
241+
242+
def main(argv: list[str] | None = None) -> int:
243+
parser = _build_parser()
244+
args = parser.parse_args(argv)
245+
return args.func(args)
246+
247+
248+
if __name__ == "__main__":
249+
sys.exit(main())

0 commit comments

Comments
 (0)