From e2e2beecff581d552b14d48502f298c2d7cef928 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 2 Nov 2026 18:12:01 +0100 Subject: [PATCH] refactor(tools): share catalog reader between installer and docs --- .github/workflows/ci.yaml | 4 + .pre-commit-config.yaml | 5 +- tools/BUILD.bazel | 1 + tools/internal/README.md | 26 ++- tools/internal/catalog.py | 235 +++++++++++++++++++++++++ tools/internal/devcontainer/install.py | 234 ++---------------------- tools/internal/sync_readme.py | 4 +- tools/tests/catalog_test.py | 205 +++++++++++++++++++++ 8 files changed, 485 insertions(+), 229 deletions(-) create mode 100644 tools/internal/catalog.py create mode 100644 tools/tests/catalog_test.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68e3d418..8abcda09 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,10 @@ jobs: # Check pre-commit run --show-diff-on-failure --color=always --all-files || exit 1 + # Metadata and README generation must also work without importing + # the container installer or installing a Python package. + python3 -m unittest discover -s tools/tests -p '*_test.py' -v + # 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa8745b2..c56e0800 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,9 +72,8 @@ repos: entry: python3 tools/internal/sync_readme.py language: system pass_filenames: false - # 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))$ + # Watch the shared reader as well as both catalog formats. + files: ^tools/(README\.md|internal/(catalog\.py|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/tools/BUILD.bazel b/tools/BUILD.bazel index 16f32b2a..77238405 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -57,6 +57,7 @@ sh_test( name = "python_tool_runner_test", srcs = ["tests/python_tool_runner_test.sh"], data = [ + "internal/catalog.py", "internal/devcontainer/install.py", "internal/run_python_tool.sh", "lockfiles/python_tools.bzl", diff --git a/tools/internal/README.md b/tools/internal/README.md index 99227d56..cfae24e4 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -81,18 +81,21 @@ its pre-commit configuration. Consumer documentation only presents the copied Native tool metadata lives in [`tools/lockfiles/*.lock.json`](../lockfiles). Each lockfile records versions, supported platforms, download URLs, checksums, and archive layouts. -`rules_multitool` consumes the files for Bazel; -[`devcontainer/install.py`](devcontainer/install.py) consumes them while -building the DevContainer. +`rules_multitool` consumes the files for Bazel. The standard-library-only +[`catalog.py`](catalog.py) reads metadata for +[`devcontainer/install.py`](devcontainer/install.py) and README generation. +The reader has no installation side effects; download, platform selection, +and installation remain in the container installer. 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. During DevContainer setup, `install.py` runs as root so it can install -tools in system locations such as `/usr/local/bin`. It parses the catalog with -`ast.literal_eval` instead of executing it as Python, so catalog changes can -provide only literal data and cannot run code with the installer's privileges. +tools in system locations such as `/usr/local/bin`. The shared reader parses +the catalog with `ast.literal_eval` instead of executing it as Python, so +catalog changes can provide only literal data and cannot run code with the +installer's privileges. This shared format keeps the package, console entrypoint, version, and description in one source of truth while Bazel execution remains independent of host Python. @@ -182,6 +185,17 @@ 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. +Run the shared reader and README regression tests with the image's Python +(no additional packages or installation needed): + +```console +$ python3 -m unittest discover -s tools/tests -p '*_test.py' +``` + +The scripts locate the shared internal reader relative to their own files, so they also work +from another working directory and in the image's copied `tools` directory. +Ordinary imports from the repository use `tools.internal.catalog`. + ## Bazel target conventions `multitool_aliases` exposes two native-tool targets: diff --git a/tools/internal/catalog.py b/tools/internal/catalog.py new file mode 100644 index 00000000..69d07aec --- /dev/null +++ b/tools/internal/catalog.py @@ -0,0 +1,235 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Read pinned native and Python tool metadata without executing catalog code.""" + +# pyright: reportAny=false, reportUnusedCallResult=false, reportExplicitAny=false + +from __future__ import annotations + +import ast +import json +from collections.abc import Iterator +from pathlib import Path +from typing import NotRequired, TypedDict + + +class Binary(TypedDict): + """A single binary entry from a tool's lockfile definition.""" + + os: str + cpu: str + kind: str + url: str + sha256: str + type: NotRequired[str] + file: NotRequired[str] + dir: NotRequired[str] + + +class ToolData(TypedDict): + """Tool metadata from a lockfile entry.""" + + version: NotRequired[str] + description: NotRequired[str] + binaries: list[Binary] + + +LOCKFILE_ROOT = Path(__file__).resolve().parents[1] / "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]]: + """Yield (tool, lockfile filename, definition) for every catalog entry.""" + for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")): + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + + for tool, definition in data.items(): + if tool.startswith("$"): + continue + if not isinstance(definition, dict): + raise SystemExit(f"Unexpected entry '{tool}' in '{path.name}'") + 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] = {} + + for tool, filename, definition in _iter_catalog(): + version = definition.get("version") + if not isinstance(version, str): + raise SystemExit( + f"Tool '{tool}' in '{filename}' does not define a string version" + ) + if tool in versions: + 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 + + +def load_catalog_descriptions() -> dict[str, str]: + """Return every tool description declared by the lockfile catalog. + + Tools without a description are omitted so callers can report a + friendly list of undocumented tools instead of failing on the first + one encountered. + """ + descriptions: dict[str, str] = {} + + for tool, filename, definition in _iter_catalog(): + description = definition.get("description") + if description is None: + continue + if not isinstance(description, str): + raise SystemExit( + f"Tool '{tool}' in '{filename}' has a non-string description" + ) + if tool in descriptions: + 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 + + +def _lockfile_path(lockfile: str) -> Path: + """Resolve a lockfile basename like `ruff` to `ruff.lock.json`.""" + return LOCKFILE_ROOT / f"{lockfile}.lock.json" + + +def load_tool(lockfile: str, tool: str) -> ToolData: + """Load one tool entry from a lockfile and fail with a clear message.""" + with _lockfile_path(lockfile).open(encoding="utf-8") as handle: + data = json.load(handle) + + try: + return data[tool] + except KeyError as exc: + raise SystemExit( + f"Tool '{tool}' not found in lockfile '{lockfile}.lock.json'", + ) from exc + + +def _find_lockfile(tool: str) -> str: + """Find the lockfile basename that declares a tool.""" + for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")): + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + if tool in data: + return path.name.removesuffix(".lock.json") + + raise SystemExit(f"Tool '{tool}' not found in lockfile catalog") + + +def resolve_lockfile(tool: str, lockfile: str | None = None) -> str: + """Return the lockfile basename for *tool*, auto-detecting when needed.""" + if lockfile is not None: + return lockfile + if _lockfile_path(tool).exists(): + return tool + return _find_lockfile(tool) diff --git a/tools/internal/devcontainer/install.py b/tools/internal/devcontainer/install.py index d6fb5746..ee3e2a28 100755 --- a/tools/internal/devcontainer/install.py +++ b/tools/internal/devcontainer/install.py @@ -28,9 +28,7 @@ from __future__ import annotations import argparse -import ast import hashlib -import json import os import platform import shutil @@ -41,182 +39,18 @@ import urllib.request import zipfile from pathlib import Path -from typing import NotRequired, TypedDict -from collections.abc import Iterator +# Resolve from this file, not cwd, for direct execution and copied tools trees. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -class Binary(TypedDict): - """A single binary entry from a tool's lockfile definition.""" - - os: str - cpu: str - kind: str - url: str - sha256: str - type: NotRequired[str] - file: NotRequired[str] - dir: NotRequired[str] - - -class ToolData(TypedDict): - """Tool metadata from a lockfile entry.""" - - version: NotRequired[str] - description: NotRequired[str] - binaries: list[Binary] - - -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]]: - """Yield (tool, lockfile filename, definition) for every catalog entry.""" - for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")): - with path.open(encoding="utf-8") as handle: - data = json.load(handle) - - for tool, definition in data.items(): - if tool.startswith("$"): - continue - if not isinstance(definition, dict): - raise SystemExit(f"Unexpected entry '{tool}' in '{path.name}'") - 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] = {} - - for tool, filename, definition in _iter_catalog(): - version = definition.get("version") - if not isinstance(version, str): - raise SystemExit( - f"Tool '{tool}' in '{filename}' does not define a string version" - ) - if tool in versions: - 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 - - -def load_catalog_descriptions() -> dict[str, str]: - """Return every tool description declared by the lockfile catalog. - - Tools without a description are omitted so callers can report a - friendly list of undocumented tools instead of failing on the first - one encountered. - """ - descriptions: dict[str, str] = {} - - for tool, filename, definition in _iter_catalog(): - description = definition.get("description") - if description is None: - continue - if not isinstance(description, str): - raise SystemExit( - f"Tool '{tool}' in '{filename}' has a non-string description" - ) - if tool in descriptions: - 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 +from catalog import ( + Binary, + ToolData, + load_catalog_versions, + load_python_tools, + load_tool, + resolve_lockfile, +) def _detect_os() -> str: @@ -239,44 +73,6 @@ def _detect_cpu() -> str: raise SystemExit(f"Unsupported CPU architecture: {machine}") -def _lockfile_path(lockfile: str) -> Path: - """Resolve a lockfile basename like `ruff` to `ruff.lock.json`.""" - return LOCKFILE_ROOT / f"{lockfile}.lock.json" - - -def _load_tool(lockfile: str, tool: str) -> ToolData: - """Load one tool entry from a lockfile and fail with a clear message.""" - with _lockfile_path(lockfile).open(encoding="utf-8") as handle: - data = json.load(handle) - - try: - return data[tool] - except KeyError as exc: - raise SystemExit( - f"Tool '{tool}' not found in lockfile '{lockfile}.lock.json'", - ) from exc - - -def _find_lockfile(tool: str) -> str: - """Find the lockfile basename that declares a tool.""" - for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")): - with path.open(encoding="utf-8") as handle: - data = json.load(handle) - if tool in data: - return path.name.removesuffix(".lock.json") - - raise SystemExit(f"Tool '{tool}' not found in lockfile catalog") - - -def _resolve_lockfile(tool: str, lockfile: str | None = None) -> str: - """Return the lockfile basename for *tool*, auto-detecting when needed.""" - if lockfile is not None: - return lockfile - if _lockfile_path(tool).exists(): - return tool - return _find_lockfile(tool) - - def _select_binary(tool_data: ToolData, os_name: str, cpu: str) -> Binary: """Pick the binary entry matching the requested platform.""" for binary in tool_data["binaries"]: @@ -300,8 +96,8 @@ def _cmd_version(args: argparse.Namespace) -> int: ) from exc return 0 - args.lockfile = _resolve_lockfile(args.tool, args.lockfile) - tool_data = _load_tool(args.lockfile, args.tool) + args.lockfile = resolve_lockfile(args.tool, args.lockfile) + tool_data = load_tool(args.lockfile, args.tool) version = tool_data.get("version") if version is None: raise SystemExit( @@ -331,7 +127,7 @@ def _cmd_install_python(args: argparse.Namespace) -> int: # 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() + catalog = load_python_tools() requirements: list[str] = [] for tool in args.tools: try: @@ -414,8 +210,8 @@ def _cmd_install(args: argparse.Namespace) -> int: dest_dir = Path(args.destination) for tool in args.tools: - lockfile = _resolve_lockfile(tool, args.lockfile) - tool_data = _load_tool(lockfile, tool) + lockfile = resolve_lockfile(tool, args.lockfile) + tool_data = load_tool(lockfile, tool) binary = _select_binary(tool_data, args.os, args.cpu) kind = binary["kind"] diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py index 13854743..3f7aae77 100755 --- a/tools/internal/sync_readme.py +++ b/tools/internal/sync_readme.py @@ -19,7 +19,9 @@ import sys from pathlib import Path -from devcontainer.install import load_catalog_descriptions, load_catalog_versions +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from catalog import load_catalog_descriptions, load_catalog_versions TOOLS_DIR = Path(__file__).resolve().parent.parent diff --git a/tools/tests/catalog_test.py b/tools/tests/catalog_test.py new file mode 100644 index 00000000..66310650 --- /dev/null +++ b/tools/tests/catalog_test.py @@ -0,0 +1,205 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Regression coverage for the shared catalog and its script consumers.""" + +from __future__ import annotations + +import ast +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.internal import catalog + + +TOOLS_DIR = Path(__file__).resolve().parents[1] + + +class CatalogTest(unittest.TestCase): + def test_metadata_matches_definitions(self) -> None: + versions = {} + descriptions = {} + for path in sorted((TOOLS_DIR / "lockfiles").glob("*.lock.json")): + for name, metadata in json.loads(path.read_text(encoding="utf-8")).items(): + if name.startswith("$"): + continue + with self.subTest(tool=name): + self.assertEqual( + catalog.load_tool(path.name.removesuffix(".lock.json"), name), + metadata, + ) + versions[name] = metadata["version"] + if "description" in metadata: + descriptions[name] = metadata["description"] + + source = (TOOLS_DIR / "lockfiles/python_tools.bzl").read_text(encoding="utf-8") + python_tools = ast.literal_eval(ast.parse(source).body[0].value) + self.assertEqual(catalog.load_python_tools(), python_tools) + self.assertEqual(set(python_tools), {"pre-commit"}) + versions.update( + (name, metadata["version"]) for name, metadata in python_tools.items() + ) + descriptions.update( + (name, metadata["description"]) for name, metadata in python_tools.items() + ) + self.assertEqual(catalog.load_catalog_versions(), versions) + self.assertEqual(catalog.load_catalog_descriptions(), descriptions) + self.assertEqual(catalog.resolve_lockfile("uvx"), "uv") + self.assertEqual(catalog.resolve_lockfile("uvx", "explicit"), "explicit") + + def test_rejects_malformed_and_executable_python_catalogs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "python_tools.bzl" + marker = root / "executed" + payload = f"__import__('pathlib').Path({str(marker)!r}).touch()" + cases = [ + ("PYTHON_TOOLS = {", "Malformed Python tool catalog"), + ("OTHER = {}", "must assign its data to PYTHON_TOOLS"), + ("PYTHON_TOOLS = []", "PYTHON_TOOLS must be a dictionary"), + ( + "PYTHON_TOOLS = {'bad': {'version': '1'}}", + "Malformed Python tool catalog entry", + ), + (f"PYTHON_TOOLS = {payload}", "must contain only literal data"), + ( + f"PYTHON_TOOLS = {{}}\n{payload}", + "must contain only a PYTHON_TOOLS assignment", + ), + ] + with patch.object(catalog, "PYTHON_TOOL_CATALOG", path): + for source, message in cases: + with self.subTest(source=source): + path.write_text(source, encoding="utf-8") + with self.assertRaisesRegex(SystemExit, message): + catalog.load_python_tools() + self.assertFalse(marker.exists()) + + def test_duplicate_definitions_retain_rejection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + python_path = root / "python_tools.bzl" + metadata = { + "version": "1", + "description": "Example tool", + "binaries": [], + } + native_path = root / "one.lock.json" + native_path.write_text(json.dumps({"example": metadata}), encoding="utf-8") + duplicate_path = root / "two.lock.json" + python_metadata = { + "package": "example", + "version": "1", + "entrypoint": "example", + "description": "Example tool", + } + with ( + patch.object(catalog, "LOCKFILE_ROOT", root), + patch.object(catalog, "PYTHON_TOOL_CATALOG", python_path), + ): + for duplicate in ("lockfiles", "catalogs"): + if duplicate == "lockfiles": + duplicate_path.write_bytes(native_path.read_bytes()) + python_path.write_text("PYTHON_TOOLS = {}", encoding="utf-8") + else: + duplicate_path.unlink() + python_path.write_text( + f"PYTHON_TOOLS = {{'example': {python_metadata!r}}}", + encoding="utf-8", + ) + for query in ( + catalog.load_catalog_versions, + catalog.load_catalog_descriptions, + ): + with self.subTest(duplicate=duplicate, query=query.__name__): + with self.assertRaisesRegex( + SystemExit, + f"Tool 'example' is defined by multiple {duplicate}", + ): + query() + + def test_copied_scripts_work_without_installer_import_for_readme(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + copied = root / "tools" + shutil.copytree( + TOOLS_DIR, copied, ignore=shutil.ignore_patterns("__pycache__") + ) + readme = copied / "README.md" + expected = readme.read_bytes() + # Force regeneration, rather than only testing the no-change path. + readme.write_text( + expected.decode("utf-8").replace( + "| `pre-commit` |", "| `stale-command` |" + ), + encoding="utf-8", + ) + blocker = """ +import importlib.abc +import runpy +import sys + +class BlockInstaller(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if "devcontainer" in fullname.split("."): + raise AssertionError("README generation imported the container installer") + +sys.meta_path.insert(0, BlockInstaller()) +script = sys.argv[1] +sys.argv = [script] +runpy.run_path(script, run_name="__main__") +""" + subprocess.run( + [ + sys.executable, + "-I", + "-B", + "-c", + blocker, + str(copied / "internal/sync_readme.py"), + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(readme.read_bytes(), expected) + result = subprocess.run( + [ + sys.executable, + "-I", + "-B", + str(copied / "internal/devcontainer/install.py"), + "version", + "pre-commit", + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + self.assertEqual( + result.stdout.strip(), + catalog.load_python_tools()["pre-commit"]["version"], + ) + + +if __name__ == "__main__": + unittest.main()