Source code for repomatic.tooling.registry

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Declarative registry of the external tools `repomatic run` manages.

Each `ToolSpec` entry pins a tool's version and, for binary-distributed tools,
its per-platform download URLs and SHA-256 digests in `CHECKSUMS`; the paired
`VERSIONS` map records the version each checksum set was computed for. The
`ArchiveFormat`, `NativeFormat`, `BinarySpec`, and `NpmSpec` types describe how
each tool is fetched and how its `[tool.X]` section is translated to the tool's
native config format. The `repomatic run` engine in `tooling/runner.py`
consumes this data to install and invoke each tool, and `tooling/binaries.py`
installs the binaries.

```{note}
`sync-tool-versions` and `update-checksums` rewrite this module's `version=`,
`VERSIONS`, and `CHECKSUMS` literals in place by string substitution, so their
formatting must stay stable. The lint, autofix, and docs workflows key their
tool caches on a hash of this file, so only a genuine version or checksum bump
invalidates a cached tool download.
```

```{todo}
Drop the YAML-block fixup in the `mdformat` post-process callback once
[executablebooks/mdformat-myst#49](https://github.com/executablebooks/mdformat-myst/pull/49)
merges upstream. See `_DIRECTIVE_YAML_OPTIONS_RE` for the shape it repairs.
```

```{todo}
Drop the colon-fence fixup in the same callback once `mdformat-myst` ships
colon-fence support, via either
[executablebooks/mdformat-myst#36](https://github.com/executablebooks/mdformat-myst/pull/36)
or
[executablebooks/mdformat-myst#48](https://github.com/executablebooks/mdformat-myst/pull/48).
See `_ESCAPED_COLON_FENCE_RE` for the shape it repairs.
```

```{todo}
Once `lychee` ships `0.25.0`, the release expected to carry
[lycheeverse/lychee#2250](https://github.com/lycheeverse/lychee/pull/2250),
run the ``{#id}`` reproduction from
[lycheeverse/lychee#2249](https://github.com/lycheeverse/lychee/issues/2249#issuecomment-5760051417)
through `repomatic run lychee`, and report the result there, as that comment
commits to. The `file://` docs-fragment entry in `[tool.lychee]` stays either
way: `lychee` reads the Markdown source, which holds neither MyST `(target)=`
labels nor the anchors directives generate at build time.
```
"""

from __future__ import annotations

import json
import logging
import re
import sys
from dataclasses import dataclass, replace
from enum import Enum
from inspect import cleandoc
from pathlib import Path, PurePosixPath

import tomlrt
import yaml
from click_extra import ColumnSpec, TableFormat, render_table
from extra_platforms import (
    AARCH64,
    ALL_PLATFORMS,
    LINUX,
    MACOS,
    UNKNOWN_PLATFORM,
    WINDOWS,
    X86_64,
    Architecture,
    Group,
    Platform,
    current_architecture,
    current_platform,
)
from packaging.requirements import Requirement

from ..file_ops import generated_header
from ..github.urls import parse_github_repo
from ..npm import NPM_PACKAGE_URL
from ..pypi import PYPI_PACKAGE_URL
from .nuitka import nuitka_params, prepare_nuitka_build

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Sequence
    from typing import Any, Literal

    from ..metadata.core import Metadata


[docs] class ToolInstallError(RuntimeError): """Raised when a registry tool cannot be installed. The base of every install failure, whatever the backend. The CLI turns it into an error message, so a failed install never ends in a traceback. """
[docs] class BinaryInstallError(ToolInstallError): """Raised when a binary tool cannot be downloaded, verified or extracted. Every failure of the binary install path ends as this error or one of its subclasses, so a caller that degrades on failure catches one type. The `gh` resolver of {mod}`repomatic.github.gh` is such a caller: it falls back to the `gh` on `PATH`. """
[docs] class UnsupportedPlatformError(BinaryInstallError): """Raised when a tool publishes no binary for the running platform. Distinguished from every other install failure (a failed download, a checksum mismatch) because it is a property of the tool's release matrix rather than a fault: nothing about the current run can make the binary exist. Asking for such a tool directly is still fatal, but a caller provisioning it as a *companion* can catch this alone and carry on without it. See `repomatic.tooling.runner._path_tools_env`. """
[docs] class ArchiveFormat(Enum): """Archive format for binary tool downloads.""" RAW = "raw" TAR_GZ = "tar.gz" TAR_XZ = "tar.xz" ZIP = "zip"
[docs] def tarfile_mode(self) -> Literal["r:gz", "r:xz"]: """Return the `tarfile.open` mode string for this format. :raises ValueError: If called on a non-tar format. """ if self is ArchiveFormat.TAR_GZ: return "r:gz" if self is ArchiveFormat.TAR_XZ: return "r:xz" msg = f"{self.value} is not a tar archive" raise ValueError(msg)
[docs] class NativeFormat(Enum): """Target format for `[tool.X]` translation.""" YAML = "yaml" TOML = "toml" JSON = "json" EDITORCONFIG = "editorconfig" # Not a file format: translate the table to CLI flags via # `config_table_to_flags` and pass them on the command line. FLAGS = "flags"
[docs] def serialize(self, data: dict, tool_name: str = "") -> str: """Serialize a config dict to this format's string representation. When *data* is a live `[tool.X]` table parsed from `pyproject.toml` (a `tomlrt.Table`), the TOML branch keeps the user's comments and spacing. `tomlrt.Document` rebuilds the section at the document root, so each `[tool.X.sub]` header becomes `[sub]`. A plain dict carries no trivia, so it is rendered as-is. The other formats (YAML, JSON, editorconfig) cannot carry TOML comments across the format boundary, so they serialize the values only. :param data: Configuration dictionary to serialize. :param tool_name: Tool name for the generated-by header comment. JSON has no comment syntax, so it gets no header. :raises ValueError: For `FLAGS`, which is not a file format. """ if self is NativeFormat.FLAGS: msg = "FLAGS is not a file format; use config_table_to_flags()." raise ValueError(msg) # YAML, TOML and editorconfig all comment with `#`. header = ( generated_header(f"repomatic run {tool_name}") if tool_name and self is not NativeFormat.JSON else "" ) # Only the TOML serializer can consume a live `tomlrt.Table` and keep its # comments; YAML/JSON/editorconfig need plain Python types (their # encoders key on exact types and choke on tomlrt's table and array). if self is not NativeFormat.TOML and isinstance(data, tomlrt.Table): data = data.to_dict() if self is NativeFormat.YAML: return header + yaml.safe_dump( data, default_flow_style=False, sort_keys=False ) if self is NativeFormat.TOML: return header + tomlrt.Document(data).render() if self is NativeFormat.EDITORCONFIG: return self._serialize_editorconfig(data, header) return json.dumps(data, indent=2) + "\n"
@staticmethod def _serialize_editorconfig(data: dict, header: str) -> str: """Serialize a flat dict to editorconfig format. Emits all keys under `[*]` (match-all glob). TOML-style hyphens in key names are converted to underscores (editorconfig convention). """ lines = [f"{header}root = true", "", "[*]"] for key, value in data.items(): ec_key = key.replace("-", "_") if isinstance(value, bool): lines.append(f"{ec_key} = {str(value).lower()}") else: lines.append(f"{ec_key} = {value}") return "\n".join(lines) + "\n"
PlatformKey = tuple[Platform | Group, Architecture] """A `(platform_or_group, architecture)` pair used as binary lookup key. The platform element can be a single {class}`~extra_platforms.Platform` (like `MACOS`) or a {class}`~extra_platforms.Group` (like `LINUX`, which matches any Linux distribution). The architecture is always a concrete {class}`~extra_platforms.Architecture`. Resolution order in {meth}`BinarySpec.resolve_platform`: 1. Exact Platform match (`current_platform() == key_platform`). 2. Group membership (`current_platform() in key_group`), preferring the group with fewest members (most specific). 3. The `LINUX` family, only when `current_platform()` is `UNKNOWN_PLATFORM`, so a distribution extra-platforms cannot name still reaches a family-wide key. """
[docs] class ToolBackend(Enum): """How a registry tool is delivered and executed. Each member carries the display labels the documentation generators render, so backends and their vocabulary live in one place: adding a backend means adding a member here and a branch in {meth}`ToolSpec.backend`, and every consumer (docs tables, version-sync candidate sources) follows. ```{note} Code that *dereferences* a backend's payload still tests the field directly (`spec.binary is not None` narrows the optional for mypy in a way an enum comparison cannot); this enum serves the sites that only need to know *which* backend, not its payload. ``` """ BINARY = ("Binary", "Binary (downloaded from GitHub Releases)") NPM = ("npm", "npm registry, run via `node_modules/.bin`") VENV = ("PyPI (venv)", "PyPI, runs in project virtualenv via `uv run`") UVX = ("PyPI", "PyPI, installed via `uvx`") def __init__(self, short_label: str, long_label: str) -> None: self.short_label = short_label """Cell text for the docs summary table.""" self.long_label = long_label """Installation-method line in the per-tool reference sections.""" @property def enforces_cooldown(self) -> bool: """Whether the install holds back a release younger than the cooldown. The uv backends resolve the pin under `--exclude-newer`, `UV_EXCLUDE_NEWER` or the project's `[tool.uv] exclude-newer`, and npm under `min-release-age`. A binary download only verifies the registry checksum, so `minimum-release-age` binds a binary pin when `sync-tool-versions` selects it, and never again after. """ return self is not ToolBackend.BINARY @property def reads_uv_exemptions(self) -> bool: """Whether the install reads the project's `[tool.uv] exclude-newer-package`. Only the virtualenv backend installs through uv commands that read the project configuration: `uv run --with`, `uv run --no-project --with` and `uv pip install`. An entry there admits a pin still inside the window. `uvx` reads no project configuration, and npm installs into a scratch prefix with no exemption flag. Probed with uv `0.13.1` on 2026-10-20: each of the three commands honored an entry, with and without `UV_EXCLUDE_NEWER` set, and `uvx` ignored it. """ return self is ToolBackend.VENV
[docs] @dataclass(frozen=True) class BinarySpec: """Platform-specific binary download specification. Keys are {data}`PlatformKey` tuples pairing an extra-platforms {class}`~extra_platforms.Platform` or {class}`~extra_platforms.Group` with an {class}`~extra_platforms.Architecture`. This lets callers use broad groups (`LINUX` matches any distro) or specific platforms (`DEBIAN`) with full detection heuristics from extra-platforms. ```{hint} Structural integrity checks (key types, checksum format, URL placeholders, strip_components consistency) are enforced in `test_tool_spec_integrity`. ``` ```{todo} Move those integrity checks to `__post_init__` if the registry ever becomes user-configurable: a test only covers the specs shipped here. ``` """ urls: dict[PlatformKey, str] """Platform key to URL template mapping. URLs use ``{version}`` placeholders.""" checksums: dict[PlatformKey, str] """Platform key to SHA-256 hex digest mapping.""" archive_format: ArchiveFormat | dict[PlatformKey | Platform | Group, ArchiveFormat] """Archive format of the downloaded file. A single {class}`ArchiveFormat` applies to every platform. A dict maps platform specifiers to formats, allowing mixed archives in one spec:: archive_format={ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP} Dict keys follow the same resolution as {meth}`resolve_platform`: exact {data}`PlatformKey` tuple first, then bare {class}`~extra_platforms.Platform` equality, then {class}`~extra_platforms.Group` membership (smallest group wins). """ archive_executable: str | None = None """Path of the executable inside the archive. `None` defaults to the tool name. For `RAW` format, used as the final filename. """ strip_components: int | dict[PlatformKey | Platform | Group, int] = 0 """Number of leading path components to strip when extracting. A single `int` applies to every platform. A dict maps platform specifiers to counts, using the same resolution as {meth}`get_archive_format`, for a project whose archives are not laid out identically across platforms:: strip_components={ALL_PLATFORMS: 1, WINDOWS: 0} `gh` is the motivating case: its Linux and macOS archives nest everything under a `gh_{version}_{platform}_{arch}/` directory, while the Windows zip puts `bin/gh.exe` at the root. The nesting cannot be absorbed by {attr}`archive_executable` instead, since that is one string for all platforms and the directory name carries the version and platform. """
[docs] def resolve_platform(self) -> PlatformKey: """Match the current environment against registered platform keys. Uses `current_platform()` and `current_architecture()` from extra-platforms, inheriting its full detection heuristics, then falls back to the `LINUX` family when those heuristics name no distribution at all. :return: The matching {data}`PlatformKey`. :raises UnsupportedPlatformError: If no key matches the current environment. """ arch = current_architecture() plat = current_platform() # Pass 1: exact Platform match (highest priority). for key_plat, key_arch in self.urls: if key_arch == arch and isinstance(key_plat, Platform) and key_plat == plat: return (key_plat, key_arch) # Pass 2: Group membership (prefer smallest group = most specific). candidates: list[tuple[Group, Architecture]] = [] for key_plat, key_arch in self.urls: if key_arch == arch and isinstance(key_plat, Group) and plat in key_plat: candidates.append((key_plat, key_arch)) if len(candidates) == 1: return candidates[0] if candidates: # Most-specific group: fewest members. return min(candidates, key=lambda c: len(c[0])) # Pass 3: the Linux family, for an unidentified platform only. A distro # extra-platforms has yet to learn resolves to `UNKNOWN_PLATFORM`, which # belongs to no group, so pass 2 misses a family-wide `LINUX` key even # though the binary behind it is distro-generic. Only Linux needs this: # `is_macos()` and `is_windows()` are `sys.platform` tests, so neither # can ever come back unidentified. if plat is UNKNOWN_PLATFORM and sys.platform.startswith("linux"): for key_plat, key_arch in self.urls: if key_arch == arch and key_plat is LINUX: logging.warning( "Unidentified Linux distribution, falling back to " f"{key_plat.name} {arch.name}." ) return (key_plat, key_arch) available = ", ".join( f"{k[0].name} {k[1].name}" for k in sorted(self.urls, key=str) ) msg = f"No binary for {plat.name} {arch.name}. Available: {available}." raise UnsupportedPlatformError(msg)
@staticmethod def _resolve_per_platform( mapping: dict[PlatformKey | Platform | Group, Any], key: PlatformKey, what: str, ) -> Any: """Pick a per-platform mapping's value for the given platform key. Resolves in order: exact {data}`PlatformKey` tuple, bare Platform equality, then Group membership with the smallest group winning, so a narrow `WINDOWS` entry overrides a broad `ALL_PLATFORMS` one. :param mapping: Platform specifier to value mapping. :param key: The platform key to resolve for. :param what: Field name, for the error message. :return: The matching value. :raises ValueError: If no entry matches the platform key. """ # Exact tuple match. if key in mapping: return mapping[key] # Bare Platform or Group match. key_plat = key[0] group_hits: list[tuple[Group, Any]] = [] for map_key, value in mapping.items(): if isinstance(map_key, tuple): continue if map_key == key_plat: return value if isinstance(map_key, Group) and ( isinstance(key_plat, Platform) and key_plat in map_key or isinstance(key_plat, Group) and (key_plat & map_key) ): group_hits.append((map_key, value)) if group_hits: # Most-specific group: fewest members. return min(group_hits, key=lambda h: len(h[0]))[1] msg = f"No {what} for {key[0].name} {key[1].name}" raise ValueError(msg)
[docs] def get_archive_format(self, key: PlatformKey) -> ArchiveFormat: """Return the archive format for the given platform key. When `archive_format` is a single {class}`ArchiveFormat`, returns it directly. When it is a dict, resolves through `_resolve_per_platform`. """ if isinstance(self.archive_format, ArchiveFormat): return self.archive_format fmt: ArchiveFormat = self._resolve_per_platform( self.archive_format, key, "archive format" ) return fmt
[docs] def get_strip_components(self, key: PlatformKey) -> int: """Return the leading path components to strip for a platform key. When {attr}`strip_components` is a plain `int`, returns it directly. When it is a dict, resolves through `_resolve_per_platform`. """ if isinstance(self.strip_components, int): return self.strip_components count: int = self._resolve_per_platform( self.strip_components, key, "strip_components" ) return count
[docs] @staticmethod def platform_cache_key(key: PlatformKey) -> str: """Derive a filesystem-safe cache path segment from a platform key. :return: A string like `linux-aarch64` or `macos-x86_64`. """ return f"{key[0].id}-{key[1].id}"
[docs] def resolve( self, tool_name: str, version: str, key: PlatformKey | None = None ) -> ResolvedBinary: """Pick the download of one platform, at one version. :param tool_name: Registry key of the tool, the default executable name. :param version: Version to fill into the URL template. :param key: Platform to resolve. Defaults to the running platform. :raises UnsupportedPlatformError: When *key* is not given and no binary matches the running platform. """ if key is None: key = self.resolve_platform() return ResolvedBinary( key=key, url=self.urls[key].format(version=version), checksum=self.checksums.get(key), archive_format=self.get_archive_format(key), strip_components=self.get_strip_components(key), executable=self.archive_executable or tool_name, )
[docs] @dataclass(frozen=True, kw_only=True) class ResolvedBinary: """The download of a binary tool for one platform, at one version.""" key: PlatformKey """The platform this download serves.""" url: str """Download URL, with the version filled in.""" checksum: str | None """SHA-256 of the download, or `None` when the registry records none.""" archive_format: ArchiveFormat """Format of the download.""" strip_components: int """Leading path components to drop from archive members.""" executable: str """Path of the executable inside the archive, after stripping. The final filename of a `RAW` download. """ @property def cache_key(self) -> str: """Filesystem-safe name of the platform, like `linux-aarch64`.""" return BinarySpec.platform_cache_key(self.key) @property def archive_name(self) -> str: """Filename of the download, as its URL names it.""" return self.url.rsplit("/", 1)[-1] @property def archive_members(self) -> tuple[str, str]: """Names the executable can carry inside a ZIP archive. A Windows build adds the `.exe` suffix to the executable. """ return self.executable, f"{self.executable}.exe" @property def cached_names(self) -> tuple[str, str]: """Names the cache can hold the extracted executable under. The cache keys an entry on the extracted file's own name: a path inside the archive (`bin/gh`) loses its directories, and a Windows build keeps its `.exe` suffix. """ stem = PurePosixPath(self.executable).name return stem, f"{stem}.exe"
TOOL_LIST_HEADER_DEFS: tuple[ColumnSpec, ...] = ( ColumnSpec("tool", "Tool"), ColumnSpec("version", "Version"), ColumnSpec("config-source", "Config source"), ) """Column definitions for the `repomatic run --list` table. Lives beside the registry it renders; the CLI derives its `--sort-by` choices from it. """ NPM_MIN_VERSION_FOR_COOLDOWN = "11.10.0" """First npm release honoring `min-release-age`, the cooldown gate for npm tools. Older npm silently ignores the `--min-release-age` flag, so `_install_npm` warns when it cannot enforce the cooldown. This is a fixed floor (the release that introduced the option), distinct from the pinned {data}`NPM_BOOTSTRAP_TOOL`, which `sync-tool-versions` keeps on the latest npm past the cooldown. """ NPM_BOOTSTRAP_TOOL = "npm" """Registry name of the npm that installs every npm-backed tool. The npm on `PATH` is whatever the machine carries: unpinned, and too old to honor the cooldown on many images. It only installs this pinned npm, which then installs the tool. The npm package bundles its own dependencies, so that first install resolves nothing an old npm could leave outside the cooldown. """
[docs] @dataclass(frozen=True) class NpmSpec: """npm-registry backend marker for a {class}`ToolSpec`. Presence (`ToolSpec.npm is not None`) selects the npm backend, the way a {class}`BinarySpec` selects the download backend. The package name, executable, and version all derive from the `ToolSpec` fields, so no per-tool npm config is needed today; the class exists as a typed discriminator and a home for future npm-specific options. ```{note} npm tools need Node.js and npm on `PATH` at run time: the one backend that depends on a runtime repomatic neither bundles nor provisions (binary tools are self-contained; the uv backends use uv). Integrity is npm's own per-tarball verification on install, so unlike {class}`BinarySpec` there is no repomatic-pinned checksum; the `minimum-release-age` cooldown (npm's `min-release-age`, npm 11.10.0+) gates the transitive tree instead. Older npm ignores the gate, so the runner warns rather than silently skipping it. ``` """
[docs] @dataclass(frozen=True) class ToolSpec: """Specification for an external tool managed by repomatic. ```{hint} Structural integrity checks (name format, version format, flag conventions, field consistency) are enforced in `test_tool_spec_integrity`. ``` ```{todo} Move those integrity checks to `__post_init__` if the registry ever becomes user-configurable: a test only covers the specs shipped here. ``` ```{hint} CLI parser quirks for `config_after_subcommand` Tools that use subcommands (`tool <subcmd> [flags] [files]`) may require `config_flag` to appear after the subcommand name, depending on the CLI parser framework: - **clap** (Rust): global flags accepted before or after the subcommand. No special handling needed. Used by: ruff, labelmaker. - **cobra** (Go): root-level flags inherited by all subcommands, accepted in both positions. No special handling needed. Used by: gitleaks. - **click** (Python): global flags accepted before or after the subcommand. No special handling needed. Used by: bump-my-version. - **bpaf** (Rust): `#[bpaf(external)]` fields are scoped inside the subcommand variant, so `tool <subcmd> --flag` works but `tool --flag <subcmd>` does not. Set `config_after_subcommand=True`. Used by: biome. ``` """ name: str """Tool identity: CLI name for `repomatic run <name>`, default PyPI package name, and default executable name. """ display_name: str | None = None """Human-readable name with proper casing for documentation (like `'Biome'`, `'Gitleaks'`). `None` defaults to `name`. """ version: str = "" """Pinned version (e.g., `'1.38.0'`).""" package: str | None = None """Install target passed to `uvx`/`uv run` (and pip). `None` defaults to `name`. Only set when it differs from the tool name, and may carry an install extra (Nuitka's `nuitka[onefile]`); for PyPI lookups query the bare project name through {attr}`pypi_name`, which strips the extra. """ module: str | None = None """Python module name for `-m module` invocation, e.g. `'nuitka'`. When set, the tool is invoked as `python -m <module>` instead of the console script. Requires `needs_venv=True`. Use when the tool's script entry point is not reliably found across platforms (for example, Nuitka installs only a `.cmd` wrapper on Windows, which `uv run -- nuitka` cannot locate). """ native_config_files: tuple[str, ...] = () """Config filenames the tool auto-discovers, checked in order. Paths relative to repo root (e.g., `'zizmor.yaml'`, `'.github/actionlint.yaml'`). Empty for tools with no config file. """ config_flag: str | None = None """CLI flag to pass a config file path (e.g., `'--config'`, `'--config-file'`). `None` if the tool only reads from fixed paths. """ native_format: NativeFormat = NativeFormat.YAML """Target format for `[tool.X]` translation. `NativeFormat.FLAGS` translates the table to CLI flags (via `config_table_to_flags`) instead of a config file, for tools that expose their config keys as long options but read no config file themselves. It is mutually exclusive with `reads_pyproject`, `config_flag`, and `native_config_files`. """ default_config: str | None = None """Filename in `repomatic/data/` for bundled defaults, stored in `native_format`. `None` if no bundled default exists. """ reads_pyproject: bool = False """Whether the tool natively reads `[tool.X]` from `pyproject.toml`. When `True` and `[tool.X]` exists in `pyproject.toml`, repomatic skips Level 2 translation (the tool reads it directly). Resolution still falls through to Level 3 (bundled default) and Level 4 (bare) when no config is found. """ pyproject_table: str | None = None """Name of the tool's table under `[tool]` in `pyproject.toml`, when it differs from {attr}`name`. `None` defaults to the tool name. bump-my-version reads `[tool.bumpversion]`: the table name predates the rename of the project. """ default_flags: tuple[str, ...] = () """Flags always passed to the tool (e.g., `('--strict',)`).""" ci_flags: tuple[str, ...] = () """Flags added only when `$GITHUB_ACTIONS` is set (e.g., output format).""" default_args: tuple[str, ...] = () """Arguments used when the caller passes none of their own. Together with {attr}`default_paths`, this makes a bare `repomatic run <tool>` the invocation CI performs, so nobody has to reconstruct it from a workflow step. It applies **only** when `extra_args` is empty: any explicit argument means the caller is driving, and nothing is injected on top of it. That all-or-nothing rule is what keeps a subcommand safe to put here. biome's defaults open with `format`, and splicing them into a caller's `check .` would build `biome format … check .`; because an explicit argument suppresses them entirely, that command cannot be built. """ default_paths: str | None = None """Name of the {class}`~repomatic.file_inventory.FileInventory` attribute supplying this tool's targets, when the caller passes no arguments. ```{caution} An empty inventory means the tool is **skipped**, not invoked with no path. The distinction is the whole point: a formatter handed zero paths does not no-op, it walks the entire tree in write mode. Replaying a workflow's `xargs` pipe on a repo with no matching file did exactly that once, rewriting 3,000+ files, and it is the reason this resolves the list in-process rather than leaving it to a shell. ``` """ per_file: bool = False """Invoke the tool once per target rather than once for all of them. Mirrors `xargs -n1`, for a tool whose per-file behaviour differs from its batch behaviour. Only meaningful alongside {attr}`default_paths`. """ with_packages: tuple[str, ...] = () """Extra packages installed alongside the tool (e.g., mdformat plugins). Passed as `--with <pkg>` to uvx. """ path_tools: tuple[str, ...] = () """Other registry tools whose executable must be on `PATH` while this runs. For a plugin that shells out to a second binary rather than importing it: `mdformat-shfmt` formats fenced shell blocks by invoking `shfmt` from `PATH`, so `mdformat` declares `path_tools=("shfmt",)`. Each name is installed through the same registry path as a direct `repomatic run`, so the companion arrives at the pinned version, checksum verified, from the shared cache. The alternative, letting the environment supply it, is what this field exists to prevent: a system package manager hands over whatever its archive holds, unpinned and outside the cooldown, and the same tool then behaves differently depending on which job invoked it. Names must resolve in {data}`TOOL_REGISTRY` and carry a `binary` spec; `test_tool_spec_integrity` enforces both. """ needs_venv: bool = False """If `True`, use `uv run` (project venv) instead of `uvx` (isolated). Required when the tool imports project code (mypy, pytest). The project venv materializes from the frozen `uv.lock`; in a repository without one the runner degrades to an isolated, cooldown-gated environment (`uv run --no-project`), see `_build_install_args` in `tooling/runner.py`. """ install_into_venv: bool = False """If `True`, install the tool into the project venv before it runs. Requires `needs_venv=True`. Without it, a `needs_venv` tool is layered over the project venv with `uv run --with`, and uv keeps that layer in a directory of its cache that it names at random. ```{note} Nuitka turns its own location into the `-I` flags of every C compilation, and the compile cache keys on those flags. Under `uv run --with`, each layer that uv builds again makes the next build miss every file. The project venv sits at the same path on every runner, so a tool installed there keeps its cache. `docs/nuitka.md` holds the measurements. ``` The install stays in the venv after the run. `uv sync` removes it. In a repository without a `uv.lock` the field has no effect, because the tool then runs isolated from the project. """ computed_params: Callable[[Metadata], list[str]] | None = None """Callable that receives a `Metadata` instance and returns extra CLI args derived from project metadata (e.g., mypy's `--python-version` from `requires-python`). `None` if no computed params. """ prepare: Callable[[Metadata], dict[str, str]] | None = None """Callable readying the machine for a run, before the tool is installed. Receives a `Metadata` instance, does what the run needs beyond its arguments, and returns the environment variables to set for the tool on top of the inherited ones. Called once per `repomatic run`, never by a documentation or a check that only reads the spec. `None` if the tool needs no preparation. Nuitka is the one user: see {func}`~repomatic.tooling.nuitka.prepare_nuitka_build`. """ config_after_subcommand: bool = False """Insert `config_flag` after the first token of `extra_args`. Needed for tools whose CLI parser (e.g., bpaf) scopes global options inside the subcommand, so `tool subcommand --config-path X` is valid but `tool --config-path X subcommand` is not. When `True`, `config_args` are spliced after the first element of `extra_args` (the subcommand name). """ post_process: Callable[[Sequence[str]], None] | None = None """Callback invoked on `extra_args` after the tool exits successfully. `run_tool` adds each file whose content changed below a directory argument during the run, so a callback that reads its arguments as files covers a tree too. A callback must skip a file it cannot read: another process can be the one that changed it. Intended for temporary workarounds that fix known upstream formatting bugs in-place. Each callback carries its own todo admonition naming the upstream release that retires it. ```{note} The callback runs only after a successful **write-mode** exit (return code 0) and rewrites files on disk, so it cannot apply in check/dry-run mode, which writes nothing. Pair it with `check_flags` so `run_tool` warns when a check invocation would silently bypass it. See {meth}`check_bypasses_post_process`. ``` """ output_flag: str | None = None """Flag whose argument names the tool's report destination, when the tool refuses to create missing parent directories itself. `run_tool` pre-creates the parent directory of the path following this flag (both `--flag path` and `--flag=path` forms), so a workflow can point the tool into a scratch subdirectory without a separate `mkdir` step. lychee is the motivating case: a report path in a scratch subdirectory, which lychee errors out on rather than creating it. """ problem_matcher: str | None = None """Filename in `repomatic/data/` of a GitHub Actions problem matcher. When the tool runs in GitHub Actions, `run_tool` registers the matcher before the tool starts and removes it once the tool exits, so each finding the tool prints becomes an annotation on the file and the line it names. Bundled rather than fetched from the upstream repository at run time: the matcher then travels with the pinned release, and no workflow step has to download it. """ check_flags: tuple[str, ...] = () """Flags that put the tool in check/dry-run mode, writing no files. ```{warning} Check mode bypasses `post_process`: that fixup rewrites files on disk, but check mode writes nothing. So when a tool defines both a `post_process` and `check_flags`, its check-mode exit status is unreliable. `run_tool` detects the pairing via {meth}`check_bypasses_post_process` and warns. Verify formatting by running the write path, not the check flag: {func}`repomatic.tooling.runner.verify_via_write_path` does exactly that against throwaway copies, so the answer is authoritative and the working tree is still never written to. ``` """ rewrite_exit_code: int | None = None """Exit code the tool returns when it rewrote at least one file. Formatters that signal "I reformatted something" with a non-zero status force every caller to tolerate that code, which is what lets a crash pass for a success: pyproject-fmt exits `1` both when it reformats a file and when it dies on a `PanicException`, and the autofix job cannot tell the two apart from the status alone. Declaring the code here gives `run_tool` the second signal it needs: the files themselves. A run exiting with this code after rewriting a target reports `0`, the success the caller asked for, so no caller has to tolerate the code. A run exiting with it and leaving every target byte-identical contradicts what the code claims, so it is reported as a failure instead of being waved through. See {data}`repomatic.tooling.runner.TOOL_CRASH_EXIT_CODE`. `None` for tools with no such convention, which is most of them: a formatter that exits `0` whether or not it wrote anything needs no disambiguation. """ binary: BinarySpec | None = None """Platform-specific binary download spec. When set, the tool is downloaded as a binary instead of installed via `uvx` or `uv run`. """ npm: NpmSpec | None = None """npm-registry backend marker. When set, the tool is installed from npm and run via its `node_modules/.bin` executable, instead of a binary download or a uv install. Mutually exclusive with `binary` and `needs_venv`. """ source_url: str | None = None """GitHub repository or project homepage URL.""" tag_pattern: str | None = None """Regex extracting the version from a GitHub release tag. Used by `sync-tool-versions` for binary tools whose tags do not follow the common `vX.Y.Z` scheme. The pattern must define a `version` named group (e.g. `r"^lychee-v(?P<version>.+)$"` for lychee, `r"^@biomejs/biome@ (?P<version>.+)$"` for biome). When `None`, the version is the tag with a leading `v` stripped. """ config_docs_url: str | None = None """URL to the tool's configuration reference.""" cli_docs_url: str | None = None """URL to the tool's CLI usage documentation.""" docs_notes: str = "" """Hand-written Markdown appended to the tool's section in `tool-runner.md`. Free-form usage notes the registry cannot derive: a `**Try it:**` shell session, a minimal `[tool.X]` example, caveats. Rendered live by {func}`tool_reference` after the generated metadata lines, so the prose stays next to the spec it documents. """ @property def backend(self) -> ToolBackend: """Delivery mechanism, derived from which spec fields are set. `binary` and `npm` win over `needs_venv`; `test_tool_spec_integrity` keeps the three mutually exclusive so the order never actually decides. """ if self.binary is not None: return ToolBackend.BINARY if self.npm is not None: return ToolBackend.NPM if self.needs_venv: return ToolBackend.VENV return ToolBackend.UVX @property def label(self) -> str: """Name to show in documentation: {attr}`display_name`, else the name.""" return self.display_name or self.name @property def install_target(self) -> str: """Package the backend installs: {attr}`package`, else the tool name.""" return self.package or self.name @property def pyproject_key(self) -> str: """Key of the tool's table under `[tool]` in `pyproject.toml`.""" return self.pyproject_table or self.name @property def pypi_name(self) -> str: """Bare PyPI project name for version and metadata lookups. {attr}`package` doubles as the install target, so it may carry an install extra (Nuitka's `nuitka[onefile]`) that `_build_install_args` needs at install time. The PyPI JSON API is keyed by the bare project name, though, and 404s on a bracketed extra, so `sync-tool-versions` and the held-back PR links query this stripped name (`nuitka`) instead. """ return Requirement(self.install_target).name @property def pypi_pin(self) -> str: """Exact PyPI requirement that installs the pinned {attr}`version`. The install extra of {attr}`package` stays, as in `nuitka[onefile]==4.3`: this is the string `uvx --from`, `uv run --with` and `uv pip install` take. """ return f"{self.install_target}=={self.version}" @property def with_package_pins(self) -> tuple[tuple[str, str], ...]: """`(package, version)` of each {attr}`with_packages` entry. The package name keeps the spelling of the entry, which is what a rewrite of the pin in this module has to match. `test_tool_spec_integrity` holds every entry to the `package==version` form. """ pins = [] for entry in self.with_packages: requirement = Requirement(entry) (specifier,) = requirement.specifier pins.append((requirement.name, specifier.version)) return tuple(pins) @property def datasource_url(self) -> str: """Human-facing URL for the tool's version datasource. npmjs for npm tools, the GitHub `source_url` when set, else the PyPI project page. Used by `sync-tool-versions` for the diff-table and held-back links. """ if self.npm is not None: return NPM_PACKAGE_URL.format(package=self.install_target) if self.source_url: return self.source_url return PYPI_PACKAGE_URL.format(package=self.pypi_name) @property def writes_config_in_cwd(self) -> bool: """Whether a generated config reaches the tool as a working-directory file. A tool with no {attr}`config_flag` gets its translated or bundled config written to its first native config path, for the length of one run. """ return self.config_flag is None and bool(self.native_config_files) @property def tracks_rewrites(self) -> bool: """Whether a run has to compare its targets before and after the tool. The files say what the tool rewrote: {attr}`rewrite_exit_code` checks a rewrite the exit code claims, and {attr}`post_process` fixes the files that changed below a directory argument. """ return self.rewrite_exit_code is not None or self.post_process is not None
[docs] def find_native_config(self, root: Path | None = None) -> Path | None: """Return the first of {attr}`native_config_files` that exists. :param root: Directory the relative config paths resolve against. Defaults to the working directory. """ base = Path() if root is None else root for config_file in self.native_config_files: path = base / config_file if path.exists(): return path return None
[docs] def resolve_binary(self, key: PlatformKey | None = None) -> ResolvedBinary: """Pick the download of this tool's binary, at its pinned version. :param key: Platform to resolve. Defaults to the running platform. :raises ValueError: When the tool ships no binary. :raises UnsupportedPlatformError: When *key* is not given and no binary matches the running platform. """ if self.binary is None: msg = f"{self.name} ships no binary." raise ValueError(msg) return self.binary.resolve(self.name, self.version, key)
[docs] def with_overrides( self, *, version: str | None = None, checksum: str | None = None ) -> ToolSpec: """Return a copy pinned to another version or another checksum. :param version: Version to install instead of the pinned one. :param checksum: SHA-256 to expect from the download of the running platform. Ignored for a tool that ships no binary. :raises UnsupportedPlatformError: When *checksum* is given and no binary matches the running platform. """ spec = replace(self, version=version) if version else self if checksum and spec.binary is not None: key = spec.binary.resolve_platform() checksums = {**spec.binary.checksums, key: checksum} spec = replace(spec, binary=replace(spec.binary, checksums=checksums)) return spec
[docs] def splice_config_args( self, config_args: Sequence[str], args: Sequence[str] ) -> list[str]: """Combine config and caller arguments in the order the tool expects. Config arguments come first, unless {attr}`config_after_subcommand` puts them after the subcommand that opens *args*. """ if self.config_after_subcommand and config_args and args: return [args[0], *config_args, *args[1:]] return [*config_args, *args]
[docs] def output_paths(self, args: Sequence[str]) -> list[Path]: """Return the report destinations {attr}`output_flag` names in *args*. Reads both the `--flag path` and the `--flag=path` forms. """ if not self.output_flag: return [] prefix = f"{self.output_flag}=" paths = [] for index, arg in enumerate(args): if arg == self.output_flag and index + 1 < len(args): paths.append(Path(args[index + 1])) elif arg.startswith(prefix): paths.append(Path(arg.removeprefix(prefix))) return paths
[docs] def check_bypasses_post_process(self, extra_args: Sequence[str]) -> bool: """Return `True` when a check-mode flag will skip `post_process`. Check/dry-run flags (`check_flags`) make the tool exit without writing files, so the `post_process` fixup never runs and the exit status cannot be trusted: it may flag drift the write path would reconcile, or miss drift the write path would introduce. `run_tool` warns on this. Returns `False` for tools with no `post_process`, where check mode is authoritative. """ return bool(self.post_process) and any( flag in extra_args for flag in self.check_flags )
# --------------------------------------------------------------------------- # Post-process callbacks # --------------------------------------------------------------------------- _DIRECTIVE_YAML_OPTIONS_RE = re.compile( r"^((?:`{3,}|:{3,})\{[^}]+\}[^\n]*\n)" r"---\n" r"((?:[^\n]+\n)+?)" r"---\n", re.MULTILINE, ) """Match YAML-block directive options immediately after a MyST fence opening. ```{note} Workaround for [executablebooks/mdformat-myst#21](https://github.com/executablebooks/mdformat-myst/issues/21) where `mdformat-myst` unconditionally converts `:key: value` directive options to YAML blocks (`---` / `key: value` / `---`). ``` Retiring this pattern is tracked in the module docstring, where `autodoc` publishes it onto the todo list; a private name renders nowhere. """ def _yaml_block_to_field_list(match: re.Match[str]) -> str: """Convert a single YAML-block directive option to field-list syntax.""" directive_line = match.group(1) yaml_lines = match.group(2) # Prepend ":" to each non-empty line: `key: value` → `:key: value`. field_lines = re.sub(r"^(?=\S)", ":", yaml_lines, flags=re.MULTILINE) return directive_line + field_lines _ESCAPED_COLON_FENCE_RE = re.compile( r"^\\(:{3,})\\\{[^}]+\}.*" # Opener: escaped ::: run, escaped {name}, title. r"(?:\n(?:.*\n)*?)?" # Inner option and body lines. r"^\\\1[ \t]*$", # Closer: the matching escaped ::: run. re.MULTILINE, ) r"""Match a colon-fence directive whose delimiters mdformat has escaped. ```{note} Workaround for [executablebooks/mdformat-myst#13](https://github.com/executablebooks/mdformat-myst/issues/13): `mdformat-myst` does not treat ``:::{name}`` colon fences as directives, so `mdformat-deflist` escapes their leading colons and `mdformat-myst` escapes the opening brace, leaving an uneditable ``\:::\{name}`` / ``\:option:`` / ``\:::`` block. ``` Retiring this pattern is tracked in the module docstring, where `autodoc` publishes it onto the todo list; a private name renders nowhere. """ def _unescape_colon_fence(match: re.Match[str]) -> str: """Strip mdformat's backslash escaping from a colon-fence directive block. Handles nested fences: every fence and option line in the matched block is un-escaped, not just the outermost opener. """ block = match.group(0) # Drop the leading-colon escape on every fence and option line. block = re.sub(r"^\\:", ":", block, flags=re.MULTILINE) # Drop the brace escape on directive openers: :::\{name} -> :::{name}. return re.sub(r"^(:{3,})\\\{", r"\1{", block, flags=re.MULTILINE) def _fix_myst_directives(extra_args: Sequence[str]) -> None: """Undo mdformat's MyST-hostile directive rewrites, in place. Two fixups run on every file in *extra_args* that exists on disk: YAML-block directive options are restored to field-list syntax (see {data}`_DIRECTIVE_YAML_OPTIONS_RE`), and escaped colon-fence directives are un-escaped (see {data}`_ESCAPED_COLON_FENCE_RE`). Files without matching patterns are left untouched, and so is a file that is not UTF-8 text. """ for arg in extra_args: path = Path(arg) if not path.is_file(): continue try: content = path.read_text(encoding="UTF-8") except UnicodeDecodeError: # mdformat writes UTF-8, so this file is not one of its own. The # runner also hands over whatever else changed below a directory. continue fixed = _DIRECTIVE_YAML_OPTIONS_RE.sub(_yaml_block_to_field_list, content) fixed = _ESCAPED_COLON_FENCE_RE.sub(_unescape_colon_fence, fixed) if fixed != content: path.write_text(fixed, encoding="UTF-8") logging.debug(f"Fixed MyST directives in {path}") # --------------------------------------------------------------------------- # Binary checksums # --------------------------------------------------------------------------- CHECKSUMS: dict[str, dict[PlatformKey, str]] = { "actionlint": { ( LINUX, AARCH64, ): "325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6", ( LINUX, X86_64, ): "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8", ( MACOS, AARCH64, ): "aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f", ( MACOS, X86_64, ): "5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644", ( WINDOWS, AARCH64, ): "cadcf7ea4efe3a68728893813643cebe1185e5b1d4be5b96245f65c9a4d5ea41", ( WINDOWS, X86_64, ): "6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9", }, "biome": { ( LINUX, AARCH64, ): "a56bcd73ddbfdb0f57a82bb67b7ea698eed4027aff602b95aa18fafe919d59e0", ( LINUX, X86_64, ): "5d867a0b2ccea1755b7e508b01d836a8c64e31a1e4d11ec24b43b6b4eec8b3b6", ( MACOS, AARCH64, ): "3636796d8c78bfcfe0d45946dc93e21e12d27a49eba7401e18af1ea854d25503", ( MACOS, X86_64, ): "86fc618563d19f373ac24eb5b8f1e41bd753a506d5df1f198c152ec365141490", ( WINDOWS, AARCH64, ): "6244a2bb366c76ad869b94f999ee60ce41b94112a11dc23c4f05fd1a7053256a", ( WINDOWS, X86_64, ): "f2beadee753b78fe76081158123538be886a31b5e67a34863e42535f3f491926", }, "gh": { ( LINUX, AARCH64, ): "7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484", ( LINUX, X86_64, ): "bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386", ( MACOS, AARCH64, ): "da922c20d1792e5b2cbf375593d7a658acf034c12c84e007e71c76ef959c337e", ( MACOS, X86_64, ): "b245f24eb2bf5f75b426b4c26da3651a107f8d5b6f4fddfbfccc5679041378b3", ( WINDOWS, AARCH64, ): "5dcf12aa8525eabd0c46ec414f323ab6cf65229fc2cd46543cc705001bbaf223", ( WINDOWS, X86_64, ): "ae64e556ecc240b200f7eba60d550e4bb60d78e860e69dd88c449405b86067f4", }, "gitleaks": { ( LINUX, AARCH64, ): "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080", ( LINUX, X86_64, ): "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb", ( MACOS, AARCH64, ): "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5", ( MACOS, X86_64, ): "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709", ( WINDOWS, AARCH64, ): "b95f5e4f5c425cedca7ee203d9afd29597e692c4924a12ed42f970537c72cc0f", ( WINDOWS, X86_64, ): "d29144deff3a68aa93ced33dddf84b7fdc26070add4aa0f4513094c8332afc4e", }, "labelmaker": { ( LINUX, AARCH64, ): "4685e142da55150904d16624fe1052161de5dba1a859cddef19ab41833c37728", ( LINUX, X86_64, ): "d76f8e64f9671884dac1758fe54a28a6680c5d9bf0ffd593a2c68ba558cc49a2", ( MACOS, AARCH64, ): "a52a4e102f0760ce1632da5fdaee2b0debe0e6ddea577b88a94a60172fe85751", ( MACOS, X86_64, ): "dc8374d6a9bec4ebf143fb42e3024aeffabe8585bb9bd6f134cfaf0693be7688", ( WINDOWS, X86_64, ): "939195930f9d5fd2b15a5cf43497019a52083e6c6713807d3379de49395c2e10", }, "lychee": { ( LINUX, AARCH64, ): "91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c", ( LINUX, X86_64, ): "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a", ( MACOS, AARCH64, ): "c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977", ( WINDOWS, X86_64, ): "32975d1493ee1a975d6bb41e4fb56fe419cb442ded628bb772ba2e614acfacad", }, "oxipng": { ( LINUX, AARCH64, ): "f51bb7c9836202da52a8181549641940662aa87ec2e3ae68115d822d893015b3", ( LINUX, X86_64, ): "46e3c4beb9aae57290ad809dd3374b07153579d3322a3778c53900633618b7c6", ( MACOS, AARCH64, ): "7039fcfc78e8aa1ed2b57d848057a0296f082e92b3e1807ac65402d10d926764", ( MACOS, X86_64, ): "111883bbe42b25e01cb1ca41f39f8094e4830b173f0e42be342ecc4ca48f3131", ( WINDOWS, X86_64, ): "7e940f83ee46874b73f53031f96a15834cb70b220af27391fb06fe7b4dd798e1", }, "shellcheck": { ( LINUX, AARCH64, ): "12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588", ( LINUX, X86_64, ): "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198", ( MACOS, AARCH64, ): "56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79", ( MACOS, X86_64, ): "3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6", ( WINDOWS, X86_64, ): "8a4e35ab0b331c85d73567b12f2a444df187f483e5079ceffa6bda1faa2e740e", }, "shfmt": { ( LINUX, AARCH64, ): "5f2db09dae91fca848f7adbdd014632e921a383863a2ad7e0450ad3aba0c6489", ( LINUX, X86_64, ): "76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf", ( MACOS, AARCH64, ): "b7c872db63553ccffc7253aba3ed7d4885a27d83f1ba567b1138c6315a5847e5", ( MACOS, X86_64, ): "d33eee0da0f92835b3562e9767a05cee7e4eaeef47daa03bfd09da17b4b590a6", ( WINDOWS, X86_64, ): "13629ce28442ca80b6b5a819f7574ab39e1c28c6e26734ca816c9714e04851df", }, "typos": { ( LINUX, AARCH64, ): "2ac5e37b3236b3c07dec5cdf1527acf143aec4674a6089b0bf424d021b35e099", ( LINUX, X86_64, ): "93c301fd4120ff076e2cc2a6d6a61cab4a4bdb81899287c0938b452e56d1f454", ( MACOS, AARCH64, ): "316d50a32c3726a80ab39c6d12141b70080498973c5e8a9b068b12cde78b10c1", ( MACOS, X86_64, ): "f3451079971633a7f769fb958fe06b0d64b5f1dc46df0854aa1ddb9a6f4031d6", ( WINDOWS, X86_64, ): "518f759fba43205f17a717656054c1d25bc883455a6296989e9d021c6edb7c59", }, } """Tool name to platform-keyed SHA-256 hex digest mapping. Recomputed in place by `repomatic update-checksums` and `sync-tool-versions`. Kept as a flat sidecar dict (rather than inline in each `BinarySpec`) so the checksum recompute can replace a hash by exact string match without re-parsing the registry, and so `VERSIONS` can anchor the offline staleness test. """ VERSIONS: dict[str, str] = { "actionlint": "1.7.12", "biome": "2.5.15", "gh": "2.102.0", "gitleaks": "8.30.1", "labelmaker": "0.6.4", "lychee": "0.24.2", "oxipng": "10.2.1", "shellcheck": "0.11.0", "shfmt": "3.14.1", "typos": "1.51.1", } """Tool name to the version each checksum set was computed for. `test_tool_spec_integrity` asserts this equals the matching `ToolSpec.version`, so a bump whose checksums were never refreshed (a stale `CHECKSUMS` entry) fails CI offline, without downloading anything. """ # --------------------------------------------------------------------------- # Tool registry # --------------------------------------------------------------------------- TOOL_REGISTRY: dict[str, ToolSpec] = { "actionlint": ToolSpec( name="actionlint", version="1.7.12", native_config_files=(".github/actionlint.yaml", ".github/actionlint.yml"), config_flag="--config-file", native_format=NativeFormat.YAML, default_config="actionlint.yaml", default_flags=("-color",), # actionlint lints the `run:` scripts with the shellcheck it finds on # PATH, and skips them when there is none. path_tools=("shellcheck",), # A copy of `.github/actionlint-matcher.json` from the actionlint # repository, at the tag of the pinned version. problem_matcher="actionlint-matcher.json", source_url="https://github.com/rhysd/actionlint", config_docs_url="https://github.com/rhysd/actionlint/blob/main/docs/config.md", cli_docs_url="https://github.com/rhysd/actionlint/blob/main/docs/usage.md", binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_linux_arm64.tar.gz", ( LINUX, X86_64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_linux_amd64.tar.gz", ( MACOS, AARCH64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_darwin_arm64.tar.gz", ( MACOS, X86_64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_darwin_amd64.tar.gz", ( WINDOWS, AARCH64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_windows_arm64.zip", ( WINDOWS, X86_64, ): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_windows_amd64.zip", }, checksums=CHECKSUMS["actionlint"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP, }, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run actionlint ``` **Minimal `[tool.actionlint]`:** ```toml [tool.actionlint.self-hosted-runner] labels = ["my-linux-runner"] ``` With no arguments actionlint lints every workflow under `.github/workflows`. The `[tool.actionlint]` section is bridged to a temporary YAML config: declaring self-hosted runner labels stops custom `runs-on:` values being flagged as unknown. The pinned ShellCheck goes on `PATH` for the run, which is what lints the shell scripts of the `run:` steps. In GitHub Actions, repomatic also registers actionlint's problem matcher, so each finding annotates the line it names. """), ), "autopep8": ToolSpec( name="autopep8", default_paths="python_files", version="2.3.2", source_url="https://github.com/hhatto/autopep8", cli_docs_url="https://pypi.org/project/autopep8/", reads_pyproject=True, default_flags=( "--recursive", "--in-place", "--max-line-length", "88", "--select", "E501", ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run autopep8 -- . ``` autopep8 takes its configuration from CLI flags only. repomatic passes `--recursive --in-place --max-line-length 88 --select E501` by default; append more flags after `--`. """), ), "awesome-lint": ToolSpec( name="awesome-lint", version="2.3.0", npm=NpmSpec(), cli_docs_url="https://github.com/sindresorhus/awesome-lint#usage", ), "biome": ToolSpec( name="biome", default_args=( "format", "--write", "--no-errors-on-unmatched", # JSONC files: biome auto-detects the well-known ones, these # cover the rest. "--json-parse-allow-comments=true", "--json-parse-allow-trailing-commas=true", ), default_paths="json_files", display_name="Biome", version="2.5.15", source_url="https://github.com/biomejs/biome", tag_pattern=r"^@biomejs/biome@(?P<version>.+)$", config_docs_url="https://biomejs.dev/reference/configuration/", cli_docs_url="https://biomejs.dev/reference/cli/", native_config_files=( "biome.json", "biome.jsonc", ".biome.json", ".biome.jsonc", ), config_flag="--config-path", config_after_subcommand=True, native_format=NativeFormat.JSON, binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-linux-arm64", ( LINUX, X86_64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-linux-x64", ( MACOS, AARCH64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-darwin-arm64", ( MACOS, X86_64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-darwin-x64", ( WINDOWS, AARCH64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-win32-arm64.exe", ( WINDOWS, X86_64, ): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-win32-x64.exe", }, checksums=CHECKSUMS["biome"], archive_format=ArchiveFormat.RAW, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run biome -- check . ``` **Minimal `[tool.biome]`:** ```toml [tool.biome.formatter] indentStyle = "space" ``` `biome check` reports formatting and lint issues; add `--write` after `--` to apply fixes. The `[tool.biome]` section is bridged to a temporary `biome.json`, so keys keep Biome's camelCase spelling. """), ), "bump-my-version": ToolSpec( name="bump-my-version", version="1.6.0", reads_pyproject=True, pyproject_table="bumpversion", source_url="https://github.com/callowayproject/bump-my-version", config_docs_url="https://callowayproject.github.io/bump-my-version/reference/configuration/", cli_docs_url="https://callowayproject.github.io/bump-my-version/reference/cli/", native_config_files=(".bumpversion.toml",), config_flag="--config-file", native_format=NativeFormat.TOML, docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run bump-my-version -- show-bump ``` **Minimal `[tool.bumpversion]`:** ```toml [tool.bumpversion] current_version = "1.2.3" ``` The configuration table is `[tool.bumpversion]`, not `[tool.bump-my-version]`: the section name predates the project's rename. `show-bump` previews the next versions without writing; `repomatic run bump-my-version -- bump minor` performs the bump. """), ), "gh": ToolSpec( name="gh", display_name="GitHub CLI", version="2.102.0", source_url="https://github.com/cli/cli", cli_docs_url="https://cli.github.com/manual/", binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_linux_arm64.tar.gz", ( LINUX, X86_64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_linux_amd64.tar.gz", ( MACOS, AARCH64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_macOS_arm64.zip", ( MACOS, X86_64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_macOS_amd64.zip", ( WINDOWS, AARCH64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_windows_arm64.zip", ( WINDOWS, X86_64, ): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_windows_amd64.zip", }, checksums=CHECKSUMS["gh"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, MACOS: ArchiveFormat.ZIP, WINDOWS: ArchiveFormat.ZIP, }, archive_executable="bin/gh", # Linux and macOS archives nest everything under a # `gh_{version}_{platform}_{arch}/` directory; the Windows zip puts # `bin/gh.exe` at the root. strip_components={ALL_PLATFORMS: 1, WINDOWS: 0}, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run gh -- --version ``` Pinned so the release lane gets the same `gh` everywhere. The manylinux container the Linux binaries compile in ships no `gh`, and the runner images that do ship one leave its version to the image. `gh` reads no project configuration: it authenticates from `GH_TOKEN` in the environment. """), ), "gitleaks": ToolSpec( name="gitleaks", # The history of the repository, as the `lint-secrets` job scans it. default_args=("git", "."), display_name="Gitleaks", version="8.30.1", source_url="https://github.com/gitleaks/gitleaks", config_docs_url="https://github.com/gitleaks/gitleaks#configuration", cli_docs_url="https://github.com/gitleaks/gitleaks#usage", native_config_files=(".gitleaks.toml",), config_flag="--config", native_format=NativeFormat.TOML, binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_linux_arm64.tar.gz", ( LINUX, X86_64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_linux_x64.tar.gz", ( MACOS, AARCH64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_darwin_arm64.tar.gz", ( MACOS, X86_64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_darwin_x64.tar.gz", ( WINDOWS, AARCH64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_windows_arm64.zip", ( WINDOWS, X86_64, ): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_windows_x64.zip", }, checksums=CHECKSUMS["gitleaks"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP, }, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run gitleaks ``` **Minimal `[tool.gitleaks]`:** ```toml [tool.gitleaks.extend] useDefault = true [tool.gitleaks.allowlist] paths = ['''\.env\.sample$'''] ``` With no arguments, gitleaks scans the git history of the repository (`gitleaks git .`), as the `lint-secrets` job does. `repomatic run gitleaks -- dir .` scans the working tree instead. The `[tool.gitleaks]` section is bridged to a temporary `.gitleaks.toml`: keep `extend.useDefault = true`, or a custom config silently replaces the built-in rule set. """), ), "labelmaker": ToolSpec( name="labelmaker", version="0.6.4", source_url="https://github.com/jwodder/labelmaker", cli_docs_url="https://github.com/jwodder/labelmaker", binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-aarch64-unknown-linux-gnu.tar.xz", ( LINUX, X86_64, ): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-unknown-linux-gnu.tar.xz", ( MACOS, AARCH64, ): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-aarch64-apple-darwin.tar.xz", ( MACOS, X86_64, ): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-apple-darwin.tar.xz", ( WINDOWS, X86_64, ): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-pc-windows-msvc.zip", }, checksums=CHECKSUMS["labelmaker"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_XZ, WINDOWS: ArchiveFormat.ZIP, }, strip_components=1, ), docs_notes=cleandoc(r""" labelmaker syncs a repository's issue and PR labels from a label-definition file, so unlike the linters it needs a target repository and a `GITHUB_TOKEN`, not a path in the working tree. There is no `[tool.labelmaker]` section: the label file is the configuration. See the [upstream usage docs](https://github.com/jwodder/labelmaker) for its flags and file schema. """), ), "lychee": ToolSpec( name="lychee", # The crawl the `check-broken-links` job runs through `repomatic # broken-links`, which adds the flags of its markdown report. default_args=( "--hidden", "--suggest", "--no-progress", "--include-fragments", "--exclude-all-private", ), default_paths="doc_files", display_name="Lychee", version="0.24.2", source_url="https://github.com/lycheeverse/lychee", tag_pattern=r"^lychee-v(?P<version>.+)$", config_docs_url="https://lychee.cli.rs/guides/config/", cli_docs_url="https://lychee.cli.rs/guides/cli/", native_config_files=("lychee.toml",), config_flag="--config", output_flag="--output", native_format=NativeFormat.TOML, # Since v0.24.0 (https://github.com/lycheeverse/lychee/issues/1930, # https://github.com/lycheeverse/lychee/pull/2104), lychee natively reads # [tool.lychee] from pyproject.toml, so repomatic skips the translation bridge. reads_pyproject=True, binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-aarch64-unknown-linux-gnu.tar.gz", ( LINUX, X86_64, ): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-x86_64-unknown-linux-gnu.tar.gz", ( MACOS, AARCH64, ): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-aarch64-apple-darwin.tar.gz", ( WINDOWS, X86_64, ): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-x86_64-pc-windows-msvc.zip", }, checksums=CHECKSUMS["lychee"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP, }, strip_components=1, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run lychee ``` **Minimal `[tool.lychee]`:** ```toml [tool.lychee] max_redirects = 5 ``` With no arguments, lychee checks the links of every documentation file, as the `check-broken-links` job does through `repomatic broken-links`. Pass paths after `--` to check other files. Since v0.24 it reads `[tool.lychee]` from `pyproject.toml` natively, so repomatic does not translate it. """), ), "mdformat": ToolSpec( name="mdformat", default_paths="markdown_files", per_file=True, version="1.0.0", source_url="https://github.com/hukkin/mdformat", config_docs_url="https://mdformat.readthedocs.io/en/stable/users/configuration_file.html", cli_docs_url="https://mdformat.readthedocs.io/en/stable/users/installation_and_usage.html", native_config_files=(".mdformat.toml",), native_format=NativeFormat.TOML, default_config="mdformat.toml", reads_pyproject=True, default_flags=("--strict-front-matter",), # No Python-formatting plugin here: ruff formats fenced Python blocks in # Markdown natively, and the `format-python` job already runs it over doc # files. A `mdformat-ruff` plugin would only cover the `python` info # string (ruff also handles `py`, `py3`, `python3`, `pyi` and `pycon`) and # would pin a second, independently-drifting ruff version in this # environment, letting the two jobs fight over the same code blocks. with_packages=( "mdformat_admon==2.1.1", "mdformat-config==0.2.1", "mdformat_deflist==0.1.4", "mdformat_footnote==0.1.3", "mdformat-front-matters==2.0.0", "mdformat-gfm==1.0.0", "mdformat_gfm_alerts==2.1.0", "mdformat_myst==0.3.0", "mdformat-pelican==1.0.0", "mdformat_pyproject==0.1.1", "mdformat-recover-urls==0.0.2", "mdformat-shfmt==0.2.0", "mdformat_simple_breaks==0.1.0", "mdformat-toc==0.5.0", "mdformat-web==0.2.0", ), # mdformat-shfmt shells out to `shfmt` rather than importing it, so the # binary has to be on PATH. Routed through the registry to get the same # pinned, checksum-verified build `repomatic run shfmt` uses. path_tools=("shfmt",), post_process=_fix_myst_directives, check_flags=("--check",), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run mdformat -- . ``` **Minimal `[tool.mdformat]`:** ```toml [tool.mdformat] wrap = "no" ``` mdformat rewrites Markdown in place. repomatic bundles a plugin set (GFM, MyST, front-matter, and others) and a baseline `mdformat.toml`; `[tool.mdformat]` in your `pyproject.toml` overrides it. """), ), "mypy": ToolSpec( name="mypy", default_paths="python_files", version="2.4.0", source_url="https://github.com/python/mypy", config_docs_url="https://mypy.readthedocs.io/en/stable/config_file.html", cli_docs_url="https://mypy.readthedocs.io/en/stable/command_line.html", # mypy also auto-discovers standalone `mypy.ini`/`.mypy.ini`, but those use # INI syntax, which `NativeFormat` cannot represent — so `native_config_files` # is intentionally left empty. `reads_pyproject=True` (for `[tool.mypy]`) plus # `config_flag` cover how repomatic resolves mypy's configuration. config_flag="--config-file", reads_pyproject=True, needs_venv=True, default_flags=("--color-output",), computed_params=lambda m: m.mypy_params or [], docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run mypy -- . ``` **Minimal `[tool.mypy]`:** ```toml [tool.mypy] strict = true ``` mypy runs inside the project virtualenv (via `uv run`) so it can import your dependencies. repomatic derives `--python-version` from `requires-python`, so the check matches your lowest supported interpreter. In a repository without a `uv.lock` there is no project virtualenv to freeze, so mypy runs in an isolated environment instead and only resolves the standard library: fine for standalone scripts, but dependency imports then report `import-not-found`. `uv run` provisions only the default dependency groups, so a module that imports a dep declared solely in a non-default group (`docs`, `typing`, …) sees it as missing and mypy reports `import-not-found`. Either move the stub/dependency somewhere mypy resolves, or silence it with an override: ```toml [[tool.mypy.overrides]] module = "the_docs_only_package.*" ignore_missing_imports = true ``` """), ), "npm": ToolSpec( name="npm", version="12.2.0", npm=NpmSpec(), source_url="https://github.com/npm/cli", cli_docs_url="https://docs.npmjs.com/cli/", docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run npm -- --version ``` The npm every npm-backed tool installs through, like awesome-lint. The npm on `PATH` only installs this pinned one into the scratch prefix of the run, which then installs the tool: an npm older than 11.10.0 ignores the `minimum-release-age` cooldown, and the one a runner image carries is not pinned at all. Node.js still comes from `PATH`. """), ), "nuitka": ToolSpec( name="nuitka", display_name="Nuitka", version="4.3.1", package="nuitka[onefile]", source_url="https://github.com/Nuitka/Nuitka", config_docs_url="https://nuitka.net/doc/user-manual.html", cli_docs_url="https://nuitka.net/doc/user-manual.html", needs_venv=True, # Nuitka's install path ends in its compile cache keys: see # ToolSpec.install_into_venv. install_into_venv=True, module="nuitka", native_format=NativeFormat.FLAGS, # Nuitka looks up its newest release on PyPI and warns when the running # one is older. The pin above trails that release by the cooldown # window on purpose, so the check is turned off. default_flags=( "--mode=onefile", "--assume-yes-for-downloads", "--update-check=never", ), computed_params=nuitka_params, prepare=prepare_nuitka_build, docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run nuitka -- --project-name=papaya \ --main-entry-point=papaya=papaya.__main__:main ``` `--main-entry-point` takes a `[project.scripts]` entry, written `name=module:function`, and compiles the binary that console script would start. It needs `--project-name`, which names the build folders. **Minimal `[tool.nuitka]`:** ```toml [tool.nuitka] onefile = true output-dir = "build" ``` repomatic reads every key from `[tool.nuitka]` and forwards it as a CLI flag: `true` becomes a bare `--flag`, a string or number becomes `--key=value`, and a list repeats the flag once per item. Nuitka `4.2` reads the section itself, but only under `--project` ([Nuitka#3909](https://github.com/Nuitka/Nuitka/issues/3909)). That mode takes its entry points from the project configuration, not from the command line, so repomatic keeps its own bridge. Binaries skip `tkinter` by default, via the `nuitka.nofollow-imports` setting of [`[tool.repomatic]`](configuration.md): set it to `[]` to bundle Tcl/Tk in a GUI project. In a project with a `uv.lock`, repomatic installs Nuitka into the project virtualenv before the build, and Nuitka stays there until the next `uv sync`. The compile cache depends on that fixed path: see [Compile caching](nuitka.md#compile-caching). """), ), "oxipng": ToolSpec( name="oxipng", display_name="Oxipng", version="10.2.1", source_url="https://github.com/shssoichiro/oxipng", cli_docs_url="https://github.com/shssoichiro/oxipng#usage", binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-aarch64-unknown-linux-gnu.tar.gz", ( LINUX, X86_64, ): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-unknown-linux-gnu.tar.gz", ( MACOS, AARCH64, ): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-aarch64-apple-darwin.tar.gz", ( MACOS, X86_64, ): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-apple-darwin.tar.gz", ( WINDOWS, X86_64, ): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-pc-windows-msvc.zip", }, checksums=CHECKSUMS["oxipng"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP, }, # Every archive nests its payload under `oxipng-{version}-{triple}/`, # with the executable directly inside rather than in a `bin/`. strip_components=1, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run oxipng -- --opt 4 --strip safe image.png ``` Lossless PNG optimizer. `repomatic format-images` reaches it through {func}`repomatic.tooling.binaries.ensure_binary`, so the pinned, checksum-verified build is used instead of whatever the runner image or the distro archive supplies. """), ), "pyproject-fmt": ToolSpec( name="pyproject-fmt", default_paths="pyproject_files", version="2.30.3", source_url="https://github.com/tox-dev/pyproject-fmt", config_docs_url="https://pyproject-fmt.readthedocs.io/en/latest/", cli_docs_url="https://pyproject-fmt.readthedocs.io/en/latest/", native_config_files=("pyproject-fmt.toml",), config_flag="--config", native_format=NativeFormat.TOML, reads_pyproject=True, rewrite_exit_code=1, docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run pyproject-fmt -- pyproject.toml ``` **Minimal `[tool.pyproject-fmt]`:** ```toml [tool.pyproject-fmt] indent = 4 ``` pyproject-fmt normalizes and reorders `pyproject.toml` in place. It reads its own `[tool.pyproject-fmt]` section natively. """), ), "ruff": ToolSpec( name="ruff", display_name="Ruff", version="0.17.0", source_url="https://github.com/astral-sh/ruff", config_docs_url="https://docs.astral.sh/ruff/configuration/", cli_docs_url="https://docs.astral.sh/ruff/configuration/#command-line-interface", native_config_files=(".ruff.toml", "ruff.toml"), config_flag="--config", native_format=NativeFormat.TOML, default_config="ruff.toml", reads_pyproject=True, # ruff applies `exclude` and `extend-exclude` only while it walks a # directory: a path named on the command line bypasses both. Its # `--force-exclude` flag exists only after the subcommand, so the same # setting goes in through the global `--config`, as an inline override. default_flags=("--config=force-exclude=true",), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run ruff -- check . ``` **Minimal `[tool.ruff]`:** ```toml [tool.ruff] line-length = 100 ``` `ruff check .` lints; `ruff format .` reformats. Both read `[tool.ruff]` natively. With no project config, repomatic falls back to its bundled `ruff.toml` baseline. The default `--config=force-exclude=true` applies `exclude` and `extend-exclude` to a path named on the command line too, as in `repomatic run ruff -- check {path}`. ruff alone honours those lists only while it walks a directory, and lints or reformats an excluded file handed to it by name. To process such a file once, pass `--no-force-exclude` after the subcommand. """), ), "shellcheck": ToolSpec( name="shellcheck", display_name="ShellCheck", version="0.11.0", source_url="https://github.com/koalaman/shellcheck", config_docs_url="https://github.com/koalaman/shellcheck/wiki/Directive", cli_docs_url="https://github.com/koalaman/shellcheck/blob/master/shellcheck.1.md", binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.linux.aarch64.tar.xz", ( LINUX, X86_64, ): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.linux.x86_64.tar.xz", ( MACOS, AARCH64, ): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.darwin.aarch64.tar.xz", ( MACOS, X86_64, ): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.darwin.x86_64.tar.xz", ( WINDOWS, X86_64, ): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.zip", }, checksums=CHECKSUMS["shellcheck"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_XZ, WINDOWS: ArchiveFormat.ZIP, }, # The tarballs nest the executable under a `shellcheck-v{version}/` # directory; the Windows zip puts `shellcheck.exe` at the root. strip_components={ALL_PLATFORMS: 1, WINDOWS: 0}, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run shellcheck -- script.sh ``` ShellCheck lints shell scripts. repomatic puts it on `PATH` for `repomatic run actionlint`, which lints the `run:` scripts of the workflows with it. It reads its own `.shellcheckrc` and the `# shellcheck` directives of each script, so there is no `[tool.shellcheck]` section. """), ), "shfmt": ToolSpec( name="shfmt", default_paths="shfmt_files", version="3.14.1", source_url="https://github.com/mvdan/sh", config_docs_url="https://github.com/mvdan/sh/blob/master/cmd/shfmt/shfmt.1.scd", cli_docs_url="https://github.com/mvdan/sh#shfmt", native_config_files=(".editorconfig",), native_format=NativeFormat.EDITORCONFIG, default_flags=("--write",), binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_linux_arm64", ( LINUX, X86_64, ): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_linux_amd64", ( MACOS, AARCH64, ): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_darwin_arm64", ( MACOS, X86_64, ): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_darwin_amd64", ( WINDOWS, X86_64, ): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_windows_amd64.exe", }, checksums=CHECKSUMS["shfmt"], archive_format=ArchiveFormat.RAW, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run shfmt -- . ``` shfmt formats shell scripts in place. It has no `[tool.shfmt]` section: indentation and style come from `.editorconfig` (`indent_size`, `shell_variant`, and the `shfmt`-specific keys). """), ), "typos": ToolSpec( name="typos", version="1.51.1", source_url="https://github.com/crate-ci/typos", config_docs_url="https://github.com/crate-ci/typos/blob/master/docs/reference.md", cli_docs_url="https://github.com/crate-ci/typos/blob/master/docs/reference.md", native_config_files=("typos.toml", "_typos.toml", ".typos.toml"), config_flag="--config", native_format=NativeFormat.TOML, reads_pyproject=True, # typos applies `files.extend-exclude` only while it walks a directory: a # path named on the command line bypasses the list, and `--write-changes` # then rewrites a file the repository excluded. `--force-exclude` holds # the list for those paths too. default_flags=("--force-exclude", "--write-changes"), binary=BinarySpec( urls={ ( LINUX, AARCH64, ): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-aarch64-unknown-linux-musl.tar.gz", ( LINUX, X86_64, ): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-unknown-linux-musl.tar.gz", ( MACOS, AARCH64, ): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-aarch64-apple-darwin.tar.gz", ( MACOS, X86_64, ): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-apple-darwin.tar.gz", ( WINDOWS, X86_64, ): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-pc-windows-msvc.zip", }, checksums=CHECKSUMS["typos"], archive_format={ ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP, }, ), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run typos -- . ``` **Minimal `[tool.typos]`:** ```toml [tool.typos.files] extend-exclude = ["*.lock"] ``` typos scans the tree and, with repomatic's default `--write-changes`, fixes what it finds. It reads `[tool.typos]` natively; use `[tool.typos.default.extend-words]` to map project-specific terms to their intended spelling. The default `--force-exclude` applies `extend-exclude` to a path named on the command line too, as in `repomatic run typos -- {path}`. typos alone honours that list only while it walks a directory, and rewrites an excluded file handed to it by name. Because the `fix-typos` workflow job ships whatever typos rewrites as an unattended pull request, guard content where a "correction" is a corruption with `[tool.typos.default.extend-ignore-re]` patterns. The two known traps are encoded hashes, whose random letter runs typos happily respells (a Guix `(base32 "...")` source hash losing its value to an `an`-to-`and` fix), and intentional-typo examples that docs or tests exercise on purpose: ```toml [tool.typos.default] extend-ignore-re = [ 'base32 "[0-9a-z]{52}"', "\\{query\\}", ] ``` """), ), "yamllint": ToolSpec( name="yamllint", default_args=(".",), version="1.38.0", source_url="https://github.com/adrienverge/yamllint", config_docs_url="https://yamllint.readthedocs.io/en/stable/configuration.html", cli_docs_url="https://yamllint.readthedocs.io/en/stable/quickstart.html", native_config_files=( ".yamllint", ".yamllint.yaml", ".yamllint.yml", ), config_flag="--config-file", default_config="yamllint.yaml", default_flags=("--strict",), ci_flags=("--format", "github"), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run yamllint -- . ``` **Minimal `[tool.yamllint]`:** ```toml [tool.yamllint.rules.line-length] max = 120 ``` yamllint has no native `pyproject.toml` support, so repomatic bridges `[tool.yamllint]` to a temporary YAML config passed via `--config-file`. With no project config it uses repomatic's strict bundled `yamllint.yaml`. """), ), "zizmor": ToolSpec( name="zizmor", default_args=(".",), version="1.30.1", source_url="https://github.com/zizmorcore/zizmor", config_docs_url="https://docs.zizmor.sh/configuration/", cli_docs_url="https://docs.zizmor.sh/usage/", native_config_files=( ".github/zizmor.yml", ".github/zizmor.yaml", "zizmor.yml", "zizmor.yaml", ), config_flag="--config", default_config="zizmor.yaml", default_flags=("--offline",), ci_flags=("--format", "github"), docs_notes=cleandoc(r""" **Try it:** ```shell-session $ repomatic run zizmor -- . ``` zizmor audits GitHub Actions workflows for security issues, offline by default. repomatic bridges `[tool.zizmor]` to a temporary YAML config (passed via `--config`); with none, it uses the bundled `zizmor.yaml`. See the [configuration reference](https://docs.zizmor.sh/configuration/) for available keys. """), ), } """Tool name to the specification driving its install, config and invocation. The single source of truth for `repomatic run`: every command, workflow job and documentation renderer reads the roster from here rather than hard-coding a tool name, a version or a flag. ```{todo} Give the `mdformat` entry a `config_flag="--config"` once mdformat accepts an explicit config path, so it stops relying on discovery from the working directory: [hukkin/mdformat#432](https://github.com/hukkin/mdformat/issues/432) and [hukkin/mdformat#562](https://github.com/hukkin/mdformat/issues/562). ``` """
[docs] def lookup_tool(name: str) -> ToolSpec: """Return the registry entry of the tool called *name*. :raises ValueError: When no tool goes by *name*. """ spec = TOOL_REGISTRY.get(name) if spec is None: msg = f"Unknown tool: {name!r}. Available tools: {', '.join(TOOL_REGISTRY)}" raise ValueError(msg) return spec
# --------------------------------------------------------------------------- # Documentation renderers # --------------------------------------------------------------------------- # # Each renderer turns the registry above into a Markdown fragment consumed by a # `{python:render}` block in `docs/tool-runner.md`, so the page documents the # live registry on every Sphinx build. Nothing they emit is checked in: adding # a tool to the registry is all it takes for the page to cover it. They live # beside the registry rather than in a module of their own because they read # nothing else, and a spec field gains its rendering in the same file it is # declared in. def _tool_badges(spec: ToolSpec) -> str: """Render the Stars and Last release badges of a tool's section. Stars need a GitHub repository. The release badge follows the registry the tool installs from: the GitHub release date for a binary, the npm version for an npm tool, and the PyPI version for the uv backends. Badges keep their default shields.io labels, so they describe themselves inline. """ badges = [] parsed = parse_github_repo(spec.source_url or "") repo = "/".join(parsed) if parsed else None if repo: badges.append( f"![Stars](https://img.shields.io/github/stars/{repo}?style=flat-square)" ) if spec.backend is ToolBackend.BINARY: if repo: badges.append( f"![Last release](https://img.shields.io/github/release-date/{repo}?style=flat-square)" ) elif spec.backend is ToolBackend.NPM: badges.append( f"![Last release](https://img.shields.io/npm/v/{spec.install_target}?style=flat-square)" ) else: badges.append( f"![Last release](https://img.shields.io/pypi/v/{spec.pypi_name}?style=flat-square)" ) return " ".join(badges)
[docs] def tool_summary() -> str: """Render the summary table of all managed tools.""" rows: list[list[str]] = [] for key in sorted(TOOL_REGISTRY): spec = TOOL_REGISTRY[key] name_link = f"[{spec.label}]({spec.datasource_url})" # Config discovery column. parts: list[str] = [] if spec.native_config_files: parts.extend(f"`{f}`" for f in spec.native_config_files) if spec.reads_pyproject or spec.native_format is NativeFormat.FLAGS: parts.append(f"`[tool.{spec.pyproject_key}]` in `pyproject.toml`") config_str = ", ".join(parts) if parts else "CLI flags only" rows.append([ name_link, f"`{spec.version}`", spec.backend.short_label, config_str, ]) return render_table( rows, headers=["Tool", "Version", "Type", "Config discovery"], table_format=TableFormat.GITHUB, colalign=("left", "left", "left", "left"), )
[docs] def tool_reference() -> str: """Render the per-tool detail sections. The metadata of each section (version, install, config, flags, links) is generated from the registry; the trailing free-form prose comes from the spec's own `docs_notes` field, so hand-written examples and caveats live next to the spec they document. """ lines: list[str] = [] for key in sorted(TOOL_REGISTRY): spec = TOOL_REGISTRY[key] table = f"`[tool.{spec.pyproject_key}]`" lines.extend((f"### [{spec.label}]({spec.datasource_url})", "")) badges = _tool_badges(spec) if badges: lines.extend((badges, "")) lines.extend(( f"**Installed version:** `{spec.version}`", "", f"**Installation method:** {spec.backend.long_label}", "", )) if spec.native_config_files: files_str = ", ".join(f"`{f}`" for f in spec.native_config_files) if spec.reads_pyproject: files_str += f" and {table} in `pyproject.toml` (native)" lines.extend((f"**Config files:** {files_str}", "")) elif spec.reads_pyproject: lines.extend((f"**Config:** {table} in `pyproject.toml` (native)", "")) elif spec.native_format is NativeFormat.FLAGS: lines.extend(( f"**Config:** {table} in `pyproject.toml` (translated to CLI flags)", "", )) else: lines.extend(("**Config:** CLI flags only", "")) if spec.config_flag and not spec.reads_pyproject: lines.extend(( ( f"**{table} bridge:** repomatic translates to" f" {spec.native_format.name} and passes via `{spec.config_flag}`." ), "", )) if spec.default_flags: flags_str = " ".join(f"`{f}`" for f in spec.default_flags) lines.extend((f"**Default flags:** {flags_str}", "")) if spec.ci_flags: ci_str = " ".join(f"`{f}`" for f in spec.ci_flags) lines.extend((f"**CI flags:** {ci_str}", "")) if spec.default_config: data_url = ( "https://github.com/kdeldycke/repomatic/blob/main/repomatic/data/" + spec.default_config ) lines.extend(( f"**Bundled default:** [`{spec.default_config}`]({data_url})", "", )) if spec.with_packages: lines.extend(("**Plugins:**", "")) lines.extend(f"- `{package}`" for package, _ in spec.with_package_pins) lines.append("") doc_links = [] if spec.source_url: doc_links.append(f"[Source]({spec.source_url})") if spec.config_docs_url: doc_links.append(f"[Config reference]({spec.config_docs_url})") if spec.cli_docs_url: doc_links.append(f"[CLI usage]({spec.cli_docs_url})") if doc_links: lines.extend((" | ".join(doc_links), "")) if spec.docs_notes: lines.extend((spec.docs_notes, "")) return "\n".join(lines)