From 6e8909d6395e04069eb8cd339183758f5191c49a Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 23:47:47 +0200 Subject: [PATCH] feat: add pinned Python CLI delivery Introduce a shared Python tool catalog, a shell-based Bazel runner backed by pinned uvx, and container installer support backed by pinned uv. Document and test the delivery contracts without requiring Python on Bazel hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yaml | 8 ++ .pre-commit-config.yaml | 4 +- MODULE.bazel | 4 + tools/BUILD.bazel | 26 +++- tools/README.md | 4 +- tools/internal/README.md | 74 +++++++++-- tools/internal/devcontainer/install.py | 168 ++++++++++++++++++++++++- tools/internal/run_python_tool.sh | 49 ++++++++ tools/internal/tool-delivery.svg | 17 +-- tools/lockfiles/python_tools.bzl | 30 +++++ tools/python_tool.bzl | 53 ++++++++ tools/run-tool | 5 + tools/tests/python_tool_runner_test.sh | 102 +++++++++++++++ 13 files changed, 523 insertions(+), 21 deletions(-) create mode 100755 tools/internal/run_python_tool.sh create mode 100644 tools/lockfiles/python_tools.bzl create mode 100644 tools/python_tool.bzl create mode 100755 tools/tests/python_tool_runner_test.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a378415..2b6472e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,6 +63,14 @@ jobs: # Check pre-commit run --show-diff-on-failure --color=always --all-files || exit 1 + # A fake uv executable makes the launcher contract deterministic: + # arguments, cwd, and environment are checked without network I/O. + bazelisk test //tools:python_tool_runner_test --test_output=errors + + # The real target covers the integration boundary between + # rules_multitool, Bazel runfiles, uvx, and the Python package. + bazelisk run //tools:pre-commit -- --version + # Create builder for multi-arch builds ./scripts/create_builder.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aeea13c..fa8745b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,7 +72,9 @@ repos: entry: python3 tools/internal/sync_readme.py language: system pass_filenames: false - files: ^tools/(README\.md|internal/(sync_readme\.py|devcontainer/install\.py)|lockfiles/.*\.lock\.json)$ + # Both catalog formats feed the generated table; watching both keeps + # user-facing versions synchronized with their sources of truth. + files: ^tools/(README\.md|internal/(sync_readme\.py|devcontainer/install\.py)|lockfiles/(.*\.lock\.json|python_tools\.bzl))$ - repo: https://github.com/eclipse-score/tooling rev: 31ff8eee214e4e97ef8f5cb46e443273515b63ec hooks: diff --git a/MODULE.bazel b/MODULE.bazel index 3d7d0fb..e11b42e 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -15,6 +15,10 @@ module(name = "score_devcontainer") bazel_dep(name = "rules_multitool", version = "1.11.1") +# rules_shell provides a stable sh_binary and sh_test API across the supported +# Bazel versions. +bazel_dep(name = "rules_shell", version = "0.4.1") + multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool") multitool.hub(lockfile = "//tools:lockfiles/actionlint.lock.json") multitool.hub(lockfile = "//tools:lockfiles/apm.lock.json") diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index ac4087b..dd5b49a 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -11,11 +11,19 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@rules_shell//shell:sh_test.bzl", "sh_test") load(":multitool.bzl", "multitool_aliases") +load(":python_tool.bzl", "python_tool") package(default_visibility = ["//visibility:public"]) -exports_files(glob(["lockfiles/*.lock.json"])) +# Native lockfiles are consumed by rules_multitool; the Starlark catalog is +# loaded directly by python_tool.bzl. Export both because they are public +# metadata shipped with the score_devcontainer module. +exports_files(glob([ + "lockfiles/*.bzl", + "lockfiles/*.lock.json", +])) multitool_aliases("actionlint") @@ -38,3 +46,19 @@ multitool_aliases("yamlfmt") multitool_aliases("apm") multitool_aliases("opencode") + +# Python distributions are resolved through pinned uvx because they do not +# provide the platform-specific release binaries consumed by rules_multitool. +python_tool("pre-commit") + +# This contract test keeps Bazel's shell launcher and the container installer +# aligned with their shared catalog. +sh_test( + name = "python_tool_runner_test", + srcs = ["tests/python_tool_runner_test.sh"], + data = [ + "internal/devcontainer/install.py", + "internal/run_python_tool.sh", + "lockfiles/python_tools.bzl", + ], +) diff --git a/tools/README.md b/tools/README.md index b50bea5..0642aff 100644 --- a/tools/README.md +++ b/tools/README.md @@ -44,6 +44,7 @@ access while Bazel downloads and caches the executable. | `bazelisk` | `1.27.0` | Run the Bazel version selected by a repository | | `buildifier` | `8.2.1` | Format and lint Bazel files | | `opencode` | `1.18.15` | Run the OpenCode CLI | +| `pre-commit` | `4.5.1` | Run repository pre-commit hooks | | `ruff` | `0.11.13` | Check and format Python code | | `shellcheck` | `0.10.0` | Check shell scripts | | `starpls` | `0.1.22` | Provide language-server support for Starlark | @@ -76,7 +77,8 @@ image; otherwise the command set or versions can differ. Outside the container, a `bazel` executable must be on `PATH`. Install Bazel or Bazelisk as `bazel` before the first host-side invocation; it bootstraps -access to the catalog. +access to the catalog. The Bazel targets bring their own pinned tool runtime; +Python is not required on the host to run Python-based commands. Implementation and maintenance details are in [internal/README.md](internal/README.md). diff --git a/tools/internal/README.md b/tools/internal/README.md index 45b8cf1..e1cb356 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -28,16 +28,16 @@ One catalog is delivered through two execution paths: | Component | Responsibility | | --- | --- | -| `tools/lockfiles/*.lock.json` | Define versions, supported platforms, download locations, checksums, and archive layouts. | -| `MODULE.bazel` | Makes each lockfile available to `rules_multitool`. | -| `tools/BUILD.bazel` | Exposes the public Bazel aliases for each command. | +| `tools/lockfiles/*.lock.json` and `tools/lockfiles/python_tools.bzl` | Define versions and delivery metadata. | +| `MODULE.bazel` | Makes native tool lockfiles available to `rules_multitool`. | +| `tools/BUILD.bazel` | Exposes public Bazel targets for each command. | | Feature installers | Install the commands exposed on the DevContainer's `PATH`. | | `tools/run-tool` | Uses the command on `PATH` in a container when available; otherwise uses the public Bazel alias. | | `tools/README.md` | Documents every command and its version from the catalog. | -Adding a lockfile alone does not make a command available through either -delivery path. Add its Bazel alias and its applicable DevContainer installer -as well. The runner is an interface selector, not a registry of commands. +A command is available when its catalog metadata, Bazel target, and applicable +DevContainer installer are registered together. The runner selects between +those delivery paths; the catalogs remain the command registries. ## Architecture @@ -53,9 +53,22 @@ Native tools with upstream release artifacts use The `multitool_aliases` helper exposes the `rules_multitool` `cwd` target as the public command. That wrapper restores `BUILD_WORKING_DIRECTORY` before -starting a tool, so -configuration discovery and repository-relative paths behave like a direct -invocation. +starting a tool, so configuration discovery and repository-relative paths +behave like a direct invocation. + +Python packages do not provide the platform-specific, checksum-addressed +release binaries expected by `rules_multitool`. Their public Bazel targets +therefore use the `uvx` binary from `rules_multitool` to create an isolated +environment for the exact catalogued package version. A shell launcher restores +the caller's working directory before invoking `uvx`; using shell here is +intentional so Python-based tools do not require a system Python installation +on the host. + +The DevContainer takes the other delivery path: it installs each Python tool +once with the catalogued `uv` binary and exposes the resulting entrypoint on +`PATH`. Python is already part of the image before feature tools are installed, +so the installer can use its standard library without adding a container or +host dependency. The maintained runner source is [`run-tool`](../run-tool). It remains under `tools/` in this implementation repository; consumer repositories copy it to @@ -72,6 +85,20 @@ supported platforms, download URLs, checksums, and archive layouts. [`devcontainer/install.py`](devcontainer/install.py) consumes them while building the DevContainer. +Python command-line tool metadata lives in +[`tools/lockfiles/python_tools.bzl`](../lockfiles/python_tools.bzl). The file is +a data-only Starlark dictionary because Bazel must read the pin during analysis, +before the command can run. Its restricted literal form is also valid Python +syntax. The privileged DevContainer installer parses it with +`ast.literal_eval`, preserving a data-only privilege boundary. This shared +format keeps the package, console entrypoint, version, and description in one +source of truth while Bazel execution remains independent of host Python. + +The catalogs have distinct ownership: `devcontainer-lock.json` records external +DevContainer features, `python_tools.bzl` records Python package releases, and +`uv.lock.json` records the installer runtime. Each version is therefore owned +by the component that resolves it. + The published `score_devcontainer` Bazel module and DevContainer image share a release version. Consumers using both must pin the same release. @@ -106,6 +133,19 @@ changes together: 7. If this repository's own DevContainer needs the command, add it to [`.devcontainer/post_create_command.sh`](../../.devcontainer/post_create_command.sh). +For a Python command-line tool, add an entry to +`tools/lockfiles/python_tools.bzl`, add `python_tool("")` to +`tools/BUILD.bazel`, and install it through `install.py install-python` in +each applicable DevContainer installer. Keep the catalog as a single literal +`PYTHON_TOOLS` assignment: this is what makes it safe for the installer and +directly loadable by Bazel. The `package` selects the distribution passed to +`uv`, while `entrypoint` names the console command because those names need not +be identical. + +Bazel invokes Python tools on demand with the pinned `uvx`; DevContainers +install the same package with the pinned `uv`. Add launcher and installer +coverage to `python_tool_runner_test` when either delivery contract changes. + Keep related commands such as `uv` and `uvx` in one lockfile when upstream publishes them together. The installer can locate a command in a differently named lockfile, but an explicit `--lockfile` remains available for ambiguous @@ -133,6 +173,12 @@ lockfiles remain aligned. Publish the Bazel module and the DevContainer image with the same release version so consumer repositories can pin one version for both delivery paths. +`python_tool_runner_test` uses a fake uv executable to verify arguments, +environment variables, and working-directory behavior without network access. +CI additionally runs the real `//tools:pre-commit` target as a smoke test +because only that target exercises `rules_multitool`, Bazel runfiles, and uvx +together. + ## Bazel target conventions `multitool_aliases` exposes two native-tool targets: @@ -142,6 +188,16 @@ both delivery paths. - `_binary` is the raw executable for use as a tool dependency in other Bazel rules. +Python tools expose only the runnable `` target. They are intended for +interactive and CI use, not as executable dependencies in Bazel actions. The +target embeds the catalog metadata as arguments to a shell launcher and carries +only the pinned `uvx` executable in its runfiles, which keeps execution +independent of host Python and avoids runtime catalog path resolution. + +`rules_shell` supplies the `sh_binary` and `sh_test` APIs consistently across +the supported Bazel generations, so one BUILD definition serves every +supported consumer. + ## Rationale and boundaries The runner supports mixed workflows without exposing two user interfaces. A diff --git a/tools/internal/devcontainer/install.py b/tools/internal/devcontainer/install.py index a518df9..d6fb574 100755 --- a/tools/internal/devcontainer/install.py +++ b/tools/internal/devcontainer/install.py @@ -13,11 +13,13 @@ # ******************************************************************************* """Install pinned tools from the shared `tools/lockfiles` catalog. -Dependency-free (stdlib only) so devcontainer feature installers can use it -without extra packages. +Dependency-free (stdlib only) so feature installers can use the Python already +present in the image. Bazel executes Python tools through a shell launcher and +the pinned uvx runtime, keeping this installer container-specific. Usage: install.py install shellcheck yamlfmt + install.py install-python pre-commit install.py version shellcheck """ @@ -26,10 +28,13 @@ from __future__ import annotations import argparse +import ast import hashlib import json +import os import platform import shutil +import subprocess import sys import tarfile import tempfile @@ -62,6 +67,18 @@ class ToolData(TypedDict): LOCKFILE_ROOT = Path(__file__).resolve().parents[2] / "lockfiles" +PYTHON_TOOL_CATALOG = LOCKFILE_ROOT / "python_tools.bzl" + + +class PythonTool(TypedDict): + """Metadata shared by the container installer and Bazel launcher.""" + + # The distribution and console entrypoint can have different names. + package: str + version: str + entrypoint: str + # User-facing text is kept beside the pin so generated docs cannot drift. + description: str def _iter_catalog() -> Iterator[tuple[str, str, ToolData]]: @@ -78,6 +95,79 @@ def _iter_catalog() -> Iterator[tuple[str, str, ToolData]]: yield tool, path.name, definition +def _parse_python_tool_catalog() -> object: + """Return the literal assigned to ``PYTHON_TOOLS``. + + The feature installer runs as root, so importing or executing a repository + file would cross a privilege boundary. Parsing one assignment and accepting + only literal data lets Bazel and the installer share a catalog safely. + """ + try: + module = ast.parse( + PYTHON_TOOL_CATALOG.read_text(encoding="utf-8"), + filename=str(PYTHON_TOOL_CATALOG), + ) + except SyntaxError as exc: + raise SystemExit(f"Malformed Python tool catalog: {exc}") from exc + + # Exactly one statement preserves the catalog's data-only contract. + if len(module.body) != 1: + raise SystemExit( + "Python tool catalog must contain only a PYTHON_TOOLS assignment" + ) + + assignment = module.body[0] + if not isinstance(assignment, ast.Assign) or len(assignment.targets) != 1: + raise SystemExit( + "Python tool catalog must contain only a PYTHON_TOOLS assignment" + ) + + target = assignment.targets[0] + if not isinstance(target, ast.Name) or target.id != "PYTHON_TOOLS": + raise SystemExit("Python tool catalog must assign its data to PYTHON_TOOLS") + + try: + return ast.literal_eval(assignment.value) + except (TypeError, ValueError) as exc: + raise SystemExit("Python tool catalog must contain only literal data") from exc + + +def _load_python_tools() -> dict[str, PythonTool]: + """Validate the shared catalog and return typed Python tool metadata.""" + catalog = _parse_python_tool_catalog() + if not isinstance(catalog, dict): + raise SystemExit("PYTHON_TOOLS must be a dictionary") + + tools: dict[str, PythonTool] = {} + for command, metadata in catalog.items(): + if ( + not isinstance(command, str) + or not command + or not isinstance(metadata, dict) + ): + raise SystemExit(f"Malformed Python tool catalog entry for '{command}'") + + package = metadata.get("package") + version = metadata.get("version") + entrypoint = metadata.get("entrypoint") + description = metadata.get("description") + # Central validation gives Bazel, installation, and documentation the + # same required-field contract. + if not all( + isinstance(value, str) and value + for value in (package, version, entrypoint, description) + ): + raise SystemExit(f"Malformed Python tool catalog entry for '{command}'") + + tools[command] = { + "package": package, + "version": version, + "entrypoint": entrypoint, + "description": description, + } + return tools + + def load_catalog_versions() -> dict[str, str]: """Return every tool version declared by the lockfile catalog.""" versions: dict[str, str] = {} @@ -92,6 +182,11 @@ def load_catalog_versions() -> dict[str, str]: raise SystemExit(f"Tool '{tool}' is defined by multiple lockfiles") versions[tool] = version + for tool, definition in _load_python_tools().items(): + if tool in versions: + raise SystemExit(f"Tool '{tool}' is defined by multiple catalogs") + versions[tool] = definition["version"] + return versions @@ -116,6 +211,11 @@ def load_catalog_descriptions() -> dict[str, str]: raise SystemExit(f"Tool '{tool}' is defined by multiple lockfiles") descriptions[tool] = description + for tool, definition in _load_python_tools().items(): + if tool in descriptions: + raise SystemExit(f"Tool '{tool}' is defined by multiple catalogs") + descriptions[tool] = definition["description"] + return descriptions @@ -211,6 +311,46 @@ def _cmd_version(args: argparse.Namespace) -> int: return 0 +def _cmd_install_python(args: argparse.Namespace) -> int: + """Install catalogued Python CLIs into the DevContainer with pinned uv.""" + # Resolve uv from PATH by default so feature scripts use the binary already + # installed from uv.lock.json, while tests can inject a controlled binary. + uv = shutil.which(args.uv) + if uv is None: + raise SystemExit( + f"Could not install Python tools: '{args.uv}' was not found on PATH" + ) + + # Fixed system paths make feature installs independent of the root account's + # uv defaults and expose the entrypoints to every container user. + environment = os.environ.copy() + if args.bin_dir is not None: + environment["UV_TOOL_BIN_DIR"] = args.bin_dir + if args.tool_dir is not None: + environment["UV_TOOL_DIR"] = args.tool_dir + + # Resolve every requested name before changing the filesystem. A typo in a + # later argument must not leave the container with a partially applied set. + catalog = _load_python_tools() + requirements: list[str] = [] + for tool in args.tools: + try: + tool_data = catalog[tool] + except KeyError as exc: + raise SystemExit(f"Tool '{tool}' not found in Python tool catalog") from exc + requirements.append(f"{tool_data['package']}=={tool_data['version']}") + + for requirement in requirements: + # --force reapplies the catalog pin when a persistent layer already + # contains another version, making container rebuilds deterministic. + subprocess.run( + [uv, "tool", "install", "--force", requirement], + check=True, + env=environment, + ) + return 0 + + def _place_binary(source: Path, destination: Path) -> None: """Copy a file to its destination with executable permissions.""" destination.parent.mkdir(parents=True, exist_ok=True) @@ -335,6 +475,30 @@ def _build_parser() -> argparse.ArgumentParser: install_parser.add_argument("--cpu", default=_detect_cpu()) install_parser.set_defaults(func=_cmd_install) + install_python_parser = subparsers.add_parser( + "install-python", + help="Install Python command-line tools declared in python_tools.bzl.", + ) + install_python_parser.add_argument( + "tools", + nargs="+", + help="Catalog command names to install.", + ) + install_python_parser.add_argument( + "--uv", + default="uv", + help="uv executable installed from the native tool lockfile.", + ) + install_python_parser.add_argument( + "--bin-dir", + help="Directory in which uv exposes console entrypoints.", + ) + install_python_parser.add_argument( + "--tool-dir", + help="Directory in which uv stores isolated tool environments.", + ) + install_python_parser.set_defaults(func=_cmd_install_python) + version_parser = subparsers.add_parser( "version", help="Print the declared version for a tool.", diff --git a/tools/internal/run_python_tool.sh b/tools/internal/run_python_tool.sh new file mode 100755 index 0000000..7b0433b --- /dev/null +++ b/tools/internal/run_python_tool.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Bazel supplies the pinned uvx binary and catalog metadata as fixed arguments. +# Keeping this launcher in shell avoids making Python a host prerequisite. +# Strict mode prevents missing catalog arguments or failed directory changes +# from continuing as a misleading tool invocation. +set -euo pipefail + +# This is an internal contract check: the first three arguments are generated +# by python_tool.bzl, while every remaining argument belongs to the user. +if [[ "$#" -lt 3 ]]; then + echo "Usage: $0 [args...]" >&2 + exit 2 +fi + +uvx="$1" +requirement="$2" +entrypoint="$3" +shift 3 + +# $(location) can be relative to Bazel's execution root. Make it stable before +# changing to the user's workspace, where CLI tools expect to discover config. +if [[ "${uvx}" != /* ]]; then + uvx="${PWD}/${uvx}" +fi + +# `bazel run` sets BUILD_WORKING_DIRECTORY to the caller's cwd. The workspace +# value is a conservative fallback for wrappers that only expose the root. +working_directory="${BUILD_WORKING_DIRECTORY:-${BUILD_WORKSPACE_DIRECTORY:-}}" +if [[ -n "${working_directory}" ]]; then + cd "${working_directory}" +fi + +# Replace the launcher process so uvx forwards signals and its exit status +# directly to Bazel, pre-commit, and interactive callers. +exec "${uvx}" --from "${requirement}" "${entrypoint}" "$@" diff --git a/tools/internal/tool-delivery.svg b/tools/internal/tool-delivery.svg index 5296920..b03371b 100644 --- a/tools/internal/tool-delivery.svg +++ b/tools/internal/tool-delivery.svg @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 --> Pinned command-line tool delivery - A shared lockfile catalog feeds DevContainer installation scripts and Bazel rules_multitool targets, giving both execution paths the same tool versions and checksums. + A shared catalog feeds DevContainer installers and Bazel targets. Native tools use release lockfiles, while Python tools use uv for persistent container installation and uvx for on-demand Bazel execution. @@ -56,20 +56,23 @@ SPDX-License-Identifier: Apache-2.0 - Shared tool lockfiles - versions, URLs, checksums, platforms + Shared tool catalog + native lockfiles: artifacts and checksums + Python catalog: packages and entrypoints DevContainer - feature installers download tools + native downloads + persistent + Python installation with uv Bazel - rules_multitool exposes targets + native targets + on-demand + Python execution with uvx - same metadata - same metadata + installer reads catalog + rules load catalog diff --git a/tools/lockfiles/python_tools.bzl b/tools/lockfiles/python_tools.bzl new file mode 100644 index 0000000..216a52b --- /dev/null +++ b/tools/lockfiles/python_tools.bzl @@ -0,0 +1,30 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# A literal Starlark dictionary lets Bazel consume the pins during analysis +# while ast.literal_eval preserves a data-only boundary for the root-running +# DevContainer installer. +PYTHON_TOOLS = { + # The key is the public command and Bazel target name. + "pre-commit": { + # `package` is resolved by uv; `entrypoint` is the executable exposed to + # users. They are separate because Python distributions may name them + # differently. + "package": "pre-commit", + "version": "4.5.1", + "entrypoint": "pre-commit", + # Documentation is generated from the catalog so version and purpose + # are reviewed together. + "description": "Run repository pre-commit hooks", + }, +} diff --git a/tools/python_tool.bzl b/tools/python_tool.bzl new file mode 100644 index 0000000..0ff4c56 --- /dev/null +++ b/tools/python_tool.bzl @@ -0,0 +1,53 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Expose pinned Python CLIs without requiring Python on the Bazel host. + +The catalog is loaded during analysis so the runtime launcher only needs the +shell and the pinned uvx executable supplied by rules_multitool. +""" + +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load(":lockfiles/python_tools.bzl", "PYTHON_TOOLS") + +def python_tool(name): + """Creates a runnable target from the shared Python tool catalog.""" + + # Fail during analysis so a misspelled target cannot degrade into an + # unpinned uvx invocation at runtime. + if name not in PYTHON_TOOLS: + fail("Python tool '{}' is not defined in python_tools.bzl".format(name)) + + tool = PYTHON_TOOLS[name] + + # These fields cross the Starlark-to-shell boundary. Validate them here to + # produce a catalog error rather than an opaque launcher failure. + for field in ("package", "version", "entrypoint"): + value = tool.get(field) + if type(value) != "string" or not value: + fail("Python tool '{}' requires a non-empty '{}'".format(name, field)) + + sh_binary( + name = name, + srcs = ["internal/run_python_tool.sh"], + # Rule arguments are prepended to user arguments. The launcher receives + # the runfiles-resolved uvx binary followed by immutable catalog data. + args = [ + "$(location :uvx_binary)", + "{}=={}".format(tool["package"], tool["version"]), + tool["entrypoint"], + ], + # uvx is data rather than a host PATH lookup so rules_multitool controls + # its version and platform selection. + data = [":uvx_binary"], + ) diff --git a/tools/run-tool b/tools/run-tool index 894c5d6..bc6d800 100755 --- a/tools/run-tool +++ b/tools/run-tool @@ -27,10 +27,15 @@ fi tool_name="$1" shift +# A host PATH may contain an arbitrary, unpinned version, so PATH execution is +# deliberately limited to containers built from this catalog. If a container +# lacks a command, falling through to Bazel still gives it the pinned version. if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && command -v "${tool_name}" >/dev/null 2>&1; then exec "${tool_name}" "$@" elif command -v bazel >/dev/null 2>&1; then + # Consumer repositories expose this module as @score_devcontainer; `--` + # prevents tool flags from being interpreted as Bazel flags. exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" else echo "Could not run '${tool_name}': no container command or Bazel executable is available." >&2 diff --git a/tools/tests/python_tool_runner_test.sh b/tools/tests/python_tool_runner_test.sh new file mode 100755 index 0000000..123b61b --- /dev/null +++ b/tools/tests/python_tool_runner_test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +set -euo pipefail + +# The runfiles tree is the stable dependency root inside Bazel's test sandbox. +runfiles_root="${TEST_SRCDIR}/${TEST_WORKSPACE}" +installer="${runfiles_root}/tools/internal/devcontainer/install.py" +runner="${runfiles_root}/tools/internal/run_python_tool.sh" + +fake_uvx="${TEST_TMPDIR}/uvx" +output="${TEST_TMPDIR}/uvx.args" +working_directory="${TEST_TMPDIR}/working-directory" +mkdir -p "${working_directory}" + +# A unified diff makes runner-to-uv contract failures directly actionable. +assert_lines() { + local actual_file="$1" + shift + + local expected_file="${TEST_TMPDIR}/expected-lines" + printf '%s\n' "$@" > "${expected_file}" + diff -u "${expected_file}" "${actual_file}" +} + +# One fake executable models both `uvx` execution and `uv tool install`, keeping +# checks of process boundaries, arguments, cwd, and environment network-free. +cat > "${fake_uvx}" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +{ + printf '%s\n' "${PWD}" + printf '%s\n' "$@" +} > "${OUTPUT_FILE}" +if [[ -n "${ENV_OUTPUT:-}" ]]; then + printf '%s\n%s\n' "${UV_TOOL_BIN_DIR:-}" "${UV_TOOL_DIR:-}" > "${ENV_OUTPUT}" +fi +EOF +chmod +x "${fake_uvx}" + +# The explicit assertion verifies the requested release. Subsequent checks use +# the parsed value to keep every expected uv argument aligned with the catalog. +catalog_version="$(python3 "${installer}" version pre-commit)" +[[ "${catalog_version}" = "4.5.1" ]] + +# The Bazel launcher receives an execroot-relative uvx path, then changes to the +# caller's workspace. This catches regressions where that path breaks after cd. +pushd "${TEST_TMPDIR}" > /dev/null +OUTPUT_FILE="${output}" \ + BUILD_WORKING_DIRECTORY="${working_directory}" \ + "${runner}" ./uvx "pre-commit==${catalog_version}" pre-commit run --all-files +popd > /dev/null + +assert_lines "${output}" \ + "${working_directory}" \ + "--from" \ + "pre-commit==${catalog_version}" \ + "pre-commit" \ + "run" \ + "--all-files" + +# Explicit uv directories keep root-owned feature installs independent of +# whichever home directory uv would otherwise infer during image creation. +environment_output="${TEST_TMPDIR}/uvx.env" +OUTPUT_FILE="${output}" ENV_OUTPUT="${environment_output}" \ + python3 "${installer}" install-python pre-commit \ + --uv "${fake_uvx}" --bin-dir /test/bin --tool-dir /test/tools + +assert_lines "${output}" \ + "${PWD}" \ + "tool" \ + "install" \ + "--force" \ + "pre-commit==${catalog_version}" +assert_lines "${environment_output}" "/test/bin" "/test/tools" + +# Validate all names before invoking uv. Including a valid name first proves an +# error cannot leave behind a partially installed tool set. +rm -f "${output}" +if OUTPUT_FILE="${output}" python3 "${installer}" install-python \ + pre-commit missing-tool \ + --uv "${fake_uvx}" 2> "${TEST_TMPDIR}/unknown.err"; then + echo "Unknown Python tool unexpectedly succeeded" >&2 + exit 1 +fi +grep -qF "Tool 'missing-tool' not found in Python tool catalog" "${TEST_TMPDIR}/unknown.err" +if [[ -e "${output}" ]]; then + echo "uv was invoked before all requested tools were validated" >&2 + exit 1 +fi