|
| 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