From da186a62a6da030a3c64a932e9e83ec1bfcc9f7f Mon Sep 17 00:00:00 2001 From: Connor Mason Date: Tue, 14 Jul 2026 02:43:38 -0700 Subject: [PATCH 1/2] pull in custom check/format hooks --- .pre-commit-config.yaml | 38 ++- scripts/hooks/README.md | 395 ++++++++++++++++++++++++++++ scripts/hooks/check-json5.py | 342 ++++++++++++++++++++++++ scripts/hooks/check-pkl.py | 434 +++++++++++++++++++++++++++++++ scripts/hooks/format-pkl.py | 408 +++++++++++++++++++++++++++++ scripts/hooks/sort-json-array.py | 369 ++++++++++++++++++++++++++ 6 files changed, 1985 insertions(+), 1 deletion(-) create mode 100644 scripts/hooks/README.md create mode 100755 scripts/hooks/check-json5.py create mode 100755 scripts/hooks/check-pkl.py create mode 100755 scripts/hooks/format-pkl.py create mode 100755 scripts/hooks/sort-json-array.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3010567..3150440 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -233,6 +233,28 @@ repos: priority: 10 args: [--pytest-test-first] + - repo: local + hooks: + + # Validate JSON5 file syntax (PEP 723 script; pyjson5 resolved at runtime) + - id: check-json5 + name: "[check] json5 syntax" + stages: [pre-commit, manual] + priority: 10 + entry: scripts/hooks/check-json5.py + language: python + files: .*\.json5$ + + # Validate Pkl file syntax via `pkl eval` (no-op if `pkl` is not installed) + - id: check-pkl + name: "[check] pkl syntax" + stages: [pre-commit, manual] + priority: 10 + entry: scripts/hooks/check-pkl.py + language: python + files: .*\.pkl$ + types: [file] + # ======================================================================================== # Text/whitespace fixers # @@ -279,6 +301,7 @@ repos: # - Priority 35 shell scripts shfmt # json (.claude) pretty-format-json # toml taplo + # pkl format-pkl # markdown mdformat # python ruff --fix-only # @@ -290,8 +313,8 @@ repos: # Language-agnostic auto-sorting between two markers - id: keep-sorted - stages: [pre-commit] name: "[format] sorting (keep-sorted)" + stages: [pre-commit] priority: 30 - repo: https://github.com/scop/pre-commit-shfmt @@ -331,6 +354,19 @@ repos: stages: [pre-commit] priority: 35 + - repo: local + hooks: + + # Pkl file auto-formatting (no-op if `pkl` is not installed); not run in CI + - id: format-pkl + name: "[format] pkl (pkl format)" + stages: [pre-commit] + priority: 35 + entry: scripts/hooks/format-pkl.py + language: python + files: .*\.pkl$ + types: [file] + - repo: https://github.com/hukkin/mdformat rev: 1.0.0 hooks: diff --git a/scripts/hooks/README.md b/scripts/hooks/README.md new file mode 100644 index 0000000..1d340f3 --- /dev/null +++ b/scripts/hooks/README.md @@ -0,0 +1,395 @@ +[Project Root](../../README.md) > [Scripts](../README.md) > **Hook Scripts** + +--- + +# Hook Scripts + +Custom pre-commit hook scripts for checks and transforms not covered by standard hooks. All four scripts follow the same +interface contract as `pre-commit`: they accept a list of filenames as positional arguments and exit `0` on success or +non-zero on failure. They are wired into [`.pre-commit-config.yaml`](../../.pre-commit-config.yaml) as `local` repo +hooks and run via `prek` / `make pre`. Each is a self-contained PEP 723 script, so `uv run` resolves any dependencies in +isolation. + + + +- [Scripts](#scripts) +- [Pre-Commit Wiring](#pre-commit-wiring) +- [`check-json5.py`](#check-json5py) + - [Synopsis](#synopsis) + - [Options](#options) + - [Behavior](#behavior) + - [Exit Codes](#exit-codes) + - [Pre-Commit Integration](#pre-commit-integration) +- [`check-pkl.py`](#check-pklpy) + - [Synopsis](#synopsis-1) + - [Options](#options-1) + - [Behavior](#behavior-1) + - [Exit Codes](#exit-codes-1) + - [Pre-Commit Integration](#pre-commit-integration-1) +- [`format-pkl.py`](#format-pklpy) + - [Synopsis](#synopsis-2) + - [Options](#options-2) + - [Behavior](#behavior-2) + - [Exit Codes](#exit-codes-2) + - [Pre-Commit Integration](#pre-commit-integration-2) +- [`sort-json-array.py`](#sort-json-arraypy) + - [Synopsis](#synopsis-3) + - [Options](#options-3) + - [Behavior](#behavior-3) + - [Exit Codes](#exit-codes-3) + - [Pre-Commit Integration](#pre-commit-integration-3) + + + +## Scripts + +| Script | What it does | Hook id | Priority | +| ------------------------------------------ | ------------------------------------------------------ | ----------------- | ----------------- | +| [`check-json5.py`](#check-json5py) | Validate JSON5 file syntax via `pyjson5` | `check-json5` | 10 (check phase) | +| [`check-pkl.py`](#check-pklpy) | Validate Pkl files by running `pkl eval --format yaml` | `check-pkl` | 10 (check phase) | +| [`format-pkl.py`](#format-pklpy) | Auto-format Pkl files via `pkl format` | `format-pkl` | 35 (format phase) | +| [`sort-json-array.py`](#sort-json-arraypy) | Sort a top-level JSON array of objects by a key | `sort-json-array` | 30 (sort phase) | + +> [!NOTE] +> this repository does not currently contain any `.json5`, `.pkl`, or `keybindings.json` files, so all four hooks are +> staged for future use. Each is scoped so it matches nothing today and fires automatically the moment a matching file +> lands. + +## Pre-Commit Wiring + +All four hooks are declared as `repo: local` entries in [`.pre-commit-config.yaml`](../../.pre-commit-config.yaml), +slotted into the repository's priority scheme. The relevant stanzas are: + +```yaml + - repo: local + hooks: + - id: check-json5 + name: '[check] json5 syntax' + priority: 10 + entry: scripts/hooks/check-json5.py + language: python + files: .*\.json5$ + + - id: check-pkl + name: '[check] pkl syntax' + priority: 10 + entry: scripts/hooks/check-pkl.py + language: python + files: .*\.pkl$ + types: [file] + + - repo: local + hooks: + - id: sort-json-array + name: '[format] sorting (keybindings.json)' + priority: 30 + stages: [pre-commit] + entry: scripts/hooks/sort-json-array.py + language: python + files: .*keybindings\.json$ + args: [--key, command] + + - repo: local + hooks: + - id: format-pkl + name: '[format] pkl (pkl format)' + priority: 35 + stages: [pre-commit] # skipped in CI + entry: scripts/hooks/format-pkl.py + language: python + files: .*\.pkl$ + types: [file] +``` + +**Priority scheme** used across the whole config: + +| Priority band | Phase | Notes | +| ------------- | ----------------- | ------------------------------------------------------------------------- | +| 0–6 | Dependency sync | `uv-lock`, `uv-sync`, `uv-export`, `sync-with-uv`, `sync-pre-commit-deps` | +| 10 | Read-only checks | Run in parallel; `check-json5`, `check-pkl` live here | +| 20–23 | Whitespace fixers | `fix-byte-order-marker`, `end-of-file-fixer`, `trailing-whitespace`, etc. | +| 30–35 | Formatters | `keep-sorted`, `sort-json-array` (30); `shfmt`, `format-pkl`, etc. (35) | +| 40 | Linters | `ruff`, `mypy`, `yamllint`, `shellcheck`, `codespell` | + +The `format-pkl` hook carries `stages: [pre-commit]` so it is skipped when the config is run in CI (`manual` stage). +`check-pkl` and `check-json5` run in all stages. `sort-json-array` runs at priority 30 (before the priority-35 +`pretty-format-json` formatter) so a reordered array is normalized once and surfaces in the diff to re-stage rather than +landing silently. + +Both Pkl hooks are tolerant of environments where `pkl` is not installed: by default they print a warning and exit `0`. +Pass `--require-pkl` to make the missing binary a hard failure. + +--- + +## `check-json5.py` + +### Synopsis + +Validates that every supplied `.json5` file parses successfully via `pyjson5`. Declared as a PEP 723 inline-script with +`pyjson5` as a dependency so `uv run` can execute it in isolation. + +``` +python3 scripts/hooks/check-json5.py [OPTIONS] [FILENAMES...] +``` + +### Options + +| Flag | Description | Default | +| ----------------- | ------------------------------------------------- | ------- | +| `FILENAMES` | One or more `.json5` files to validate | — | +| `-v`, `--verbose` | Print a per-file `INFO` line for every valid file | off | +| `-h`, `--help` | Show help and exit | — | + +### Behavior + +For each filename: + +1. Opens the file and calls `pyjson5.load()`. +2. On `Json5DecoderException` — prints `ERROR ` followed by the exception class and message (indented), + records failure. +3. On any other exception — prints `ERROR ` followed by `Unable to decode JSON5: `. +4. On success with `--verbose` — prints `INFO ` followed by `Valid JSON5`. + +All output is ANSI-colored. The script exits non-zero if any file fails. + +### Exit Codes + +| Code | Meaning | +| ---- | -------------------------------------- | +| `0` | All files valid (or no files supplied) | +| `1` | One or more files failed to parse | + +### Pre-Commit Integration + +```yaml + - id: check-json5 + entry: scripts/hooks/check-json5.py + language: python + files: .*\.json5$ +``` + +pre-commit passes all staged `.json5` files as positional arguments. The `language: python` runtime means pre-commit +manages the `pyjson5` dependency automatically using the inline PEP 723 metadata. + +--- + +## `check-pkl.py` + +### Synopsis + +Validates Pkl configuration files by running `pkl eval --format yaml` against each file. If `pkl` is not found on `PATH` +the script warns and exits `0` (non-blocking) unless `--require-pkl` is set. + +``` +python3 scripts/hooks/check-pkl.py [OPTIONS] [FILENAMES...] +``` + +### Options + +#### Positional + +| Argument | Description | +| ----------- | ------------------------------------ | +| `FILENAMES` | One or more `.pkl` files to validate | + +#### Pkl executable options + +| Flag | Description | Default | +| ------------------- | ------------------------------------------------------------------ | ---------------------------- | +| `--require-pkl` | Exit non-zero if `pkl` binary not found (default: warn and exit 0) | off | +| `--executable PATH` | Path to `pkl` binary | `pkl` (resolved from `PATH`) | + +#### Output options + +| Flag | Description | Default | +| ----------------------------- | ------------------------------------------------------- | -------- | +| `-v`, `--verbose` | Print per-file `INFO` line for valid files | off | +| `--dump-yaml` | Print the YAML output generated by `pkl eval` to stdout | off | +| `--color never\|auto\|always` | ANSI color control (forwarded to `pkl eval --color`) | `always` | + +#### Execution options + +| Flag | Description | Default | +| --------------------------------- | --------------------------------------------------------------- | ------- | +| `-t SECONDS`, `--timeout SECONDS` | Per-file evaluation timeout (forwarded to `pkl eval --timeout`) | none | + +#### Package options + +| Flag | Description | Default | +| ------------ | ------------------------------------------------------------- | ------- | +| `--no-cache` | Disable Pkl package cache (passes `--no-cache` to `pkl eval`) | off | + +#### Other + +| Flag | Description | +| -------------- | ------------------ | +| `-h`, `--help` | Show help and exit | + +### Behavior + +1. Checks `shutil.which(args.executable)`. If not found: warn and return `0`, or error and return `1` if `--require-pkl` + is set. +2. Builds the base command: `pkl eval --format yaml [--color ...] [--timeout ...] [--no-cache]`. +3. For each file, runs the command via `subprocess.run(check=True, capture_output=True)`. +4. On `CalledProcessError` — prints `ERROR ` with the stderr content (strips the redundant `–– Pkl Error ––` + header that `pkl` emits). +5. On success with `--verbose` — prints `INFO ` followed by `Valid Pkl`. +6. With `--dump-yaml` — prints the evaluated YAML output below the per-file status line. + +### Exit Codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | All files valid, or `pkl` not installed (without `--require-pkl`), or no files supplied | +| `1` | One or more files failed validation, or `pkl` not found with `--require-pkl` | + +### Pre-Commit Integration + +```yaml + - id: check-pkl + entry: scripts/hooks/check-pkl.py + language: python + files: .*\.pkl$ + types: [file] +``` + +--- + +## `format-pkl.py` + +### Synopsis + +Auto-formats Pkl files in-place using `pkl format`. Detects files that need formatting (exit code `11` from +`pkl format --diff-name-only`) and rewrites them with `--write`. Reports `MODIFIED` or `UNMODIFIED` per file. If `pkl` +is not on `PATH`, warns and exits `0` unless `--require-pkl` is set. + +``` +python3 scripts/hooks/format-pkl.py [OPTIONS] [FILENAMES...] +``` + +### Options + +#### Positional + +| Argument | Description | +| ----------- | ---------------------------------- | +| `FILENAMES` | One or more `.pkl` files to format | + +#### Pkl executable options + +| Flag | Description | Default | +| ------------------- | --------------------------------------- | ---------------------------- | +| `--require-pkl` | Exit non-zero if `pkl` binary not found | off | +| `--executable PATH` | Path to `pkl` binary | `pkl` (resolved from `PATH`) | + +#### Grammar options + +| Flag | Description | Default | +| ------------------------ | ---------------------------------------------------------------- | ------- | +| `--grammar-version 1\|2` | Pkl grammar compatibility version (`1` = 0.25–0.29, `2` = 0.30+) | `2` | + +#### Output options + +| Flag | Description | Default | +| ----------------- | -------------------------------------------------------- | ------- | +| `-v`, `--verbose` | Print `UNMODIFIED` line for files that needed no changes | off | +| `-h`, `--help` | Show help and exit | — | + +### Behavior + +1. Checks `shutil.which(args.executable)`. If not found: warn and return `0`, or error and return `1` if + `--require-pkl`. +2. Builds the base command: `pkl format --diff-name-only [--grammar-version N]`. +3. For each file: + - Runs `pkl format --diff-name-only `. Exit `0` means already formatted; exit `11` means the file needs + reformatting. + - On exit `11` — re-runs with `--write` to reformat in-place, prints `MODIFIED `. + - On any other non-zero exit — prints `ERROR ` with stderr (suppressing the generic + `An error occurred during formatting.` line), and records the exit code. +4. With `--verbose`, prints `UNMODIFIED ` for files that required no changes. + +The exit code of the hook is the maximum non-zero return code seen across all files, or `0` if all succeeded. + +### Exit Codes + +| Code | Meaning | +| -------- | ----------------------------------------------------------------------------------------------------- | +| `0` | All files formatted or already correct, or `pkl` not installed (without `--require-pkl`), or no files | +| `1` | `pkl` not found with `--require-pkl`, or an unexpected exception occurred | +| `N` (>0) | Maximum `pkl format` exit code across all failing files | + +### Pre-Commit Integration + +```yaml + - id: format-pkl + entry: scripts/hooks/format-pkl.py + language: python + files: .*\.pkl$ + types: [file] + stages: [pre-commit] # not run in CI +``` + +The `stages: [pre-commit]` restriction means this hook only runs during interactive `git commit` (and `make pre`). CI +pipelines run `check-pkl` (read-only) instead of attempting in-place formatting. + +--- + +## `sort-json-array.py` + +### Synopsis + +Sorts a top-level JSON array of objects in-place by the value of a chosen object key. Pure-stdlib PEP 723 script (no +dependencies). The file is rewritten only when the sorted result differs from the original. + +``` +python3 scripts/hooks/sort-json-array.py [OPTIONS] [FILENAMES...] +``` + +### Options + +| Flag | Description | Default | +| ----------------- | ----------------------------------------------------- | --------- | +| `FILENAMES` | One or more JSON files (top-level array of objects) | — | +| `-k`, `--key` | Object key whose value each array entry is sorted by | `command` | +| `--indent` | Number of spaces to indent the rewritten JSON by | `4` | +| `-v`, `--verbose` | Print a per-file `INFO` line for already-sorted files | off | +| `-h`, `--help` | Show help and exit | — | + +### Behavior + +For each filename: + +1. Reads and parses the file as JSON. +2. On a decode error, or if the top level is not an array of objects — prints `ERROR ` with the reason, + records failure. +3. Stably sorts the array by `entry.get(key, '')` (entries missing the key sort first; equal keys keep their original + relative order). +4. Re-serializes and, if the result differs, rewrites the file in-place and prints `INFO ` / + `Sorted array by ''`. +5. If already sorted — with `--verbose`, prints `INFO ` / `Already sorted by ''`. + +### Exit Codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------- | +| `0` | Every file already sorted (or no files supplied) | +| `1` | One or more files were rewritten, failed to parse, or are not an array of objects | + +### Pre-Commit Integration + +```yaml + - id: sort-json-array + entry: scripts/hooks/sort-json-array.py + language: python + args: [--key, command] + files: .*keybindings\.json$ +``` + +Scoped to any `keybindings.json`, this keeps such a file's array ordered by each entry's `command` value. It runs at +priority 30 (before the priority-35 `pretty-format-json` formatter), so a reordered file is normalized once and surfaces +in the diff to re-stage rather than landing silently. The default `--key command` also makes the hook usable as-is for +other keybindings-style JSON. + +--- + +> **See also**: [`scripts/env/`](../env/README.md) for the Python environment utilities used during development. diff --git a/scripts/hooks/check-json5.py b/scripts/hooks/check-json5.py new file mode 100755 index 0000000..b4ae37b --- /dev/null +++ b/scripts/hooks/check-json5.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to validate JSON5 file syntax. +""" +# /// script +# requires-python = ">= 3.9" +# dependencies = [ +# "pyjson5", +# ] +# /// +from __future__ import annotations + +import argparse +import functools +import re +import sys +import textwrap +from collections.abc import Sequence +from pathlib import Path +from typing import Any +from typing import Union +from typing import cast + +try: + import pyjson5 +except (ModuleNotFoundError, NameError, ImportError): + print('`pyjson5` package not installed. Please install `pyjson5` before running hook') + sys.exit(1) + + +# ========================== +# Constants/defaults/globals +# ========================== + + +VERBOSE: bool = False + + +# =============== +# Types/protocols +# =============== + + +PathLike = Union[str, Path] +StyleColor = Union[int, tuple[int, int, int], str] + + +# ================== +# Formatting/logging +# ================== + + +ANSI_COLOR: dict[str, int] = { + 'black': 30, 'bright_black': 90, + 'red': 31, 'bright_red': 91, + 'green': 32, 'bright_green': 92, + 'yellow': 33, 'bright_yellow': 93, + 'blue': 34, 'bright_blue': 94, + 'magenta': 35, 'bright_magenta': 95, + 'cyan': 36, 'bright_cyan': 96, + 'white': 37, 'bright_white': 97, + 'reset': 39, +} +ANSI_RESET_ALL = '\033[0m' + + +def _interpret_color(_color: StyleColor, offset: int = 0) -> str: + """ + Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string. + + :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple + :param offset: offset added to the base code (0 for foreground, 10 for background) + :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``) + """ + if isinstance(_color, int): + return f'{38 + offset};5;{_color:d}' + if isinstance(_color, (tuple, list)): + r, g, b = _color + return f'{38 + offset};2;{r:d};{g:d};{b:d}' + _color = cast('str', _color) + return str(ANSI_COLOR[_color] + offset) + + +def style( + text: Any = '', + *, + fg: StyleColor | None = None, + bg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """ + Style text with ANSI escape codes. + + :param text: the string to style with ansi codes + :param fg: foreground color + :param bg: background color + :param bold: enable or disable bold mode + :param dim: enable or disable dim mode + :param underline: enable or disable underline + :param overline: enable or disable overline + :param italic: enable or disable italic + :param blink: enable or disable blinking + :param reverse: enable or disable inverse rendering + :param strikethrough: enable or disable striking through text + :param reset: add a reset-all code at the end of the string + :return: styled text + """ + if not isinstance(text, str): + text = str(text) + + bits: list[str] = [] + if fg: + try: + bits.append(f'\033[{_interpret_color(fg)}m') + except KeyError: + raise TypeError(f'Unknown color {fg!r}') from None + if bg: + try: + bits.append(f'\033[{_interpret_color(bg, 10)}m') + except KeyError: + raise TypeError(f'Unknown color {bg!r}') from None + if bold is not None: + bits.append(f'\033[{1 if bold else 22}m') + if dim is not None: + bits.append(f'\033[{2 if dim else 22}m') + if underline is not None: + bits.append(f'\033[{4 if underline else 24}m') + if overline is not None: + bits.append(f'\033[{53 if overline else 55}m') + if italic is not None: + bits.append(f'\033[{3 if italic else 23}m') + if blink is not None: + bits.append(f'\033[{5 if blink else 25}m') + if reverse is not None: + bits.append(f'\033[{7 if reverse else 27}m') + if strikethrough is not None: + bits.append(f'\033[{9 if strikethrough else 29}m') + + bits.append(text) + if reset: + bits.append(ANSI_RESET_ALL) + return ''.join(bits) + + +def unstyle(text: str) -> str: + """ + Remove ANSI styling information from a string. + + :param text: the text to remove style information from + :return: string with ANSI styling characters removed + """ + return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text) + + +def folduser(path: PathLike) -> str: + """ + Replace the user's home directory with ``~`` in a path string. + + :param path: path + :return: folded path string + """ + return str(path).replace(str(Path.home()), '~') + + +ERROR = style('ERROR', fg='bright_red', bold=True) +INFO = style('INFO', fg='bright_blue', bold=True) +WARNING = style('WARNING', fg='bright_yellow', bold=True) + + +def printf( + text: str = '', + *, + fg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + indent: int = 0, + verbose: bool = False, +) -> None: + """ + Print styled text with optional indent and verbose gating. + + :param text: text to print + :param fg: foreground color + :param bold: bold mode + :param dim: dim mode + :param indent: number of spaces to indent output by + :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr + """ + if verbose and not VERBOSE: + return + if verbose: + fg = fg or 'bright_white' + dim = True if dim is None else dim + file = sys.stderr + else: + file = None + + print( + style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim), + file=file, + ) + + +# ====================== +# Command-line arguments +# ====================== + + +class Args(argparse.Namespace): + """ + :class:`argparse.Namespace` annotated with args supported by this script. + """ + filenames: Sequence[str] + verbose: bool + + +def parse_args(argv: Sequence[str] | None = None) -> Args: + """ + Build argument parser and parse command-line args. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: :class:`Args` (typed :class:`argparse.Namespace` subclass) + """ + global VERBOSE + + parser = argparse.ArgumentParser( + description='Validate that JSON5 files have valid syntax (parsable)', + formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50), + add_help=False, + ) + + def add_positional_args() -> None: + """ + Register the positional arguments group on the parser. + """ + positional_args = parser.add_argument_group('Positional arguments') + positional_args.add_argument( + 'filenames', + nargs='*', + help='.json5 files to check', + ) + add_positional_args() + + def add_output_opts() -> None: + """ + Register the output options group on the parser. + """ + output_opts = parser.add_argument_group('Output options') + output_opts.add_argument( + '-v', + '--verbose', + action='store_true', + help='Output validation result for each analyzed file to console', + ) + add_output_opts() + + def add_other_opts() -> None: + """ + Register the miscellaneous options group on the parser. + """ + other_opts = parser.add_argument_group('Other options') + other_opts.add_argument( + '-h', + '--help', + action='help', + default=argparse.SUPPRESS, + help='Show this help message and exit', + ) + add_other_opts() + + args = parser.parse_args(argv, namespace=Args()) + VERBOSE = args.verbose + return args + + +# ================= +# Core script logic +# ================= + + +def main(argv: Sequence[str] | None = None) -> int: + """ + Validate that JSON5 files have valid syntax (parsable). + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: 0 on success, non-zero on failure + """ + args = parse_args(argv) + if not args.filenames: + printf('No filenames to check', fg='yellow') + return 0 + + retval: int = 0 + for filename in args.filenames: + path = Path(filename) + try: + raw = path.read_bytes() + except Exception as open_exc: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'Unable load file: {open_exc!s}', fg='bright_red', indent=8) + retval = 1 + continue + + try: + text = raw.decode('utf-8') + except UnicodeDecodeError as decode_exc: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'File is not valid UTF-8: {decode_exc!s}', fg='bright_red', indent=8) + retval = 1 + continue + + try: + pyjson5.loads(text) + except pyjson5.Json5DecoderException as json5_exc: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'{json5_exc.__class__.__name__}: {json5_exc.message}', fg='bright_red', indent=8) + retval = 1 + continue + except Exception as json5_exc: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'Unable to decode JSON5: {json5_exc!s}', fg='bright_red', indent=8) + retval = 1 + continue + + else: + if args.verbose: + printf(f'{INFO} {style(filename, fg="bright_white")}') + printf(style('Valid JSON5', fg='green'), indent=8) + + return retval + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/hooks/check-pkl.py b/scripts/hooks/check-pkl.py new file mode 100755 index 0000000..e8a7bea --- /dev/null +++ b/scripts/hooks/check-pkl.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to validate Pkl configuration files via ``pkl eval``. + +If ``pkl`` is not found on PATH, prints a warning and exits successfully (exit code 0) so that the hook does not +block commits in environments where ``pkl`` is not installed. Otherwise, runs ``pkl eval --format yaml`` on all +provided files to validate syntax and schema correctness. +""" +from __future__ import annotations + +import argparse +import functools +import re +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Union +from typing import cast +from typing import get_args + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# ========================== +# Constants/defaults/globals +# ========================== + + +PKL_INSTALLATION_DOCS_URL: str = 'https://pkl-lang.org/main/current/pkl-cli/index.html#installation' + +VERBOSE: bool = False + + +# =============== +# Types/protocols +# =============== + + +PathLike = Union[str, Path] +StyleColor = Union[int, tuple[int, int, int], str] + + +# ================== +# Formatting/logging +# ================== + + +def style( + text: Any, + *, + fg: StyleColor | None = None, + bg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """ + Style text with ANSI escape codes. + + :param text: the string to style with ansi codes + :param fg: foreground color + :param bg: background color + :param bold: enable or disable bold mode + :param dim: enable or disable dim mode + :param underline: enable or disable underline + :param overline: enable or disable overline + :param italic: enable or disable italic + :param blink: enable or disable blinking + :param reverse: enable or disable inverse rendering + :param strikethrough: enable or disable striking through text + :param reset: add a reset-all code at the end of the string + :return: styled text + """ + _ansi_colors: dict[str, int] = { + 'black': 30, 'bright_black': 90, + 'red': 31, 'bright_red': 91, + 'green': 32, 'bright_green': 92, + 'yellow': 33, 'bright_yellow': 93, + 'blue': 34, 'bright_blue': 94, + 'magenta': 35, 'bright_magenta': 95, + 'cyan': 36, 'bright_cyan': 96, + 'white': 37, 'bright_white': 97, + 'reset': 39, + } + _ansi_reset_all = '\033[0m' + + def _interpret_color(_color: StyleColor, offset: int = 0) -> str: + """ + Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string. + + :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple + :param offset: offset added to the base code (0 for foreground, 10 for background) + :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``) + """ + if isinstance(_color, int): + return f'{38 + offset};5;{_color:d}' + if isinstance(_color, (tuple, list)): + r, g, b = _color + return f'{38 + offset};2;{r:d};{g:d};{b:d}' + _color = cast('str', _color) + return str(_ansi_colors[_color] + offset) + + if not isinstance(text, str): + text = str(text) + + bits: list[str] = [] + if fg: + try: + bits.append(f'\033[{_interpret_color(fg)}m') + except KeyError: + raise TypeError(f'Unknown color {fg!r}') from None + if bg: + try: + bits.append(f'\033[{_interpret_color(bg, 10)}m') + except KeyError: + raise TypeError(f'Unknown color {bg!r}') from None + if bold is not None: + bits.append(f'\033[{1 if bold else 22}m') + if dim is not None: + bits.append(f'\033[{2 if dim else 22}m') + if underline is not None: + bits.append(f'\033[{4 if underline else 24}m') + if overline is not None: + bits.append(f'\033[{53 if overline else 55}m') + if italic is not None: + bits.append(f'\033[{3 if italic else 23}m') + if blink is not None: + bits.append(f'\033[{5 if blink else 25}m') + if reverse is not None: + bits.append(f'\033[{7 if reverse else 27}m') + if strikethrough is not None: + bits.append(f'\033[{9 if strikethrough else 29}m') + + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return ''.join(bits) + + +def unstyle(text: str) -> str: + """ + Remove ANSI styling information from a string. + + :param text: the text to remove style information from + :return: string with ANSI styling characters removed + """ + return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text) + + +def folduser(path: PathLike) -> str: + """ + Replace the user's home directory with ``~`` in a path string. + + :param path: path + :return: folded path string + """ + return str(path).replace(str(Path.home()), '~') + + +ERROR = style('ERROR', fg='bright_red', bold=True) +INFO = style('INFO', fg='bright_blue', bold=True) +WARNING = style('WARNING', fg='bright_yellow', bold=True) + + +def printf( + text: str = '', + *, + fg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + indent: int = 0, + verbose: bool = False, +) -> None: + """ + Print styled text with optional indent and verbose gating. + + :param text: text to print + :param fg: foreground color + :param bold: bold mode + :param dim: dim mode + :param indent: number of spaces to indent output by + :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr + """ + if verbose and not VERBOSE: + return + if verbose: + fg = fg or 'bright_white' + dim = True if dim is None else dim + file = sys.stderr + else: + file = None + + print( + style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim), + file=file, + ) + + +# ====================== +# Command-line arguments +# ====================== + + +ColorOption = Literal['never', 'auto', 'always'] + +DEFAULT_COLOR_OPTION: ColorOption = 'always' + + +class Args(argparse.Namespace): + """ + :class:`argparse.Namespace` annotated with args supported by this script. + """ + filenames: Sequence[str] + require_pkl: bool + executable: str + verbose: bool + dump_yaml: bool + color: ColorOption | None + timeout: float | None + no_cache: bool + + +def parse_args(argv: Sequence[str] | None = None) -> Args: + """ + Build argument parser and parse command-line args. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: :class:`Args` (typed :class:`argparse.Namespace` subclass) + """ + global VERBOSE + + parser = argparse.ArgumentParser( + description='Validate Pkl files by evaluating them with ``pkl eval``', + formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50), + add_help=False, + ) + + def add_positional_args() -> None: + """ + Register the positional arguments group on the parser. + """ + positional_args = parser.add_argument_group('Positional arguments') + positional_args.add_argument( + 'filenames', + nargs='*', + help='.pkl files to check', + ) + add_positional_args() + + def add_pkl_executable_opts() -> None: + """ + Register the Pkl executable options group on the parser. + """ + pkl_executable_opts = parser.add_argument_group('Pkl executable options') + pkl_executable_opts.add_argument( + '--require-pkl', + action='store_true', + default=False, + help=( + 'Exit with error (nonzero status) if ``pkl`` executable is not found.\n' + 'By default, simple warning outputted and script exits with status code 0' + ), + ) + pkl_executable_opts.add_argument( + '--executable', + metavar='PATH', + type=str, + default='pkl', + help='Filepath to ``pkl`` executable to use. Default: "pkl" (resolve from PATH)', + ) + add_pkl_executable_opts() + + def add_output_opts() -> None: + """ + Register the output options group on the parser. + """ + output_opts = parser.add_argument_group('Output options') + output_opts.add_argument( + '-v', + '--verbose', + action='store_true', + default=False, + help='Output validation result for each analyzed file to console', + ) + output_opts.add_argument( + '--dump-yaml', + action='store_true', + default=False, + help='Dump YAML generated from module evaluation to stdout', + ) + output_opts.add_argument( + '--color', + choices=get_args(ColorOption), + default=DEFAULT_COLOR_OPTION, + help=( + f'Whether to format messages in ANSI color\n' + f'{style("Default:", fg="bright_white", dim=True)} {style(DEFAULT_COLOR_OPTION, fg="green", dim=True)}' + ), + ) + add_output_opts() + + def add_execution_opts() -> None: + """ + Register the execution options group on the parser. + """ + execution_opts = parser.add_argument_group('Execution options') + execution_opts.add_argument( + '-t', + '--timeout', + metavar='SECONDS', + type=float, + default=None, + help='Duration (in seconds) after which evaluation of source module will be timed out', + ) + add_execution_opts() + + def add_package_opts() -> None: + """ + Register the package options group on the parser. + """ + package_opts = parser.add_argument_group('Package options') + package_opts.add_argument( + '--no-cache', + action='store_true', + default=False, + help='Disable caching of packages', + ) + add_package_opts() + + def add_other_opts() -> None: + """ + Register the miscellaneous options group on the parser. + """ + other_opts = parser.add_argument_group('Other options') + other_opts.add_argument( + '-h', + '--help', + action='help', + default=argparse.SUPPRESS, + help='Show this help message and exit', + ) + add_other_opts() + + args = parser.parse_args(argv, namespace=Args()) + VERBOSE = args.verbose + return args + + +# ================= +# Core script logic +# ================= + + +def main(argv: Sequence[str] | None = None) -> int: + """ + Validate Pkl files by evaluating them with ``pkl eval``. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: 0 on success, non-zero on failure + """ + args = parse_args(argv) + if not args.filenames: + printf('No filenames to check', fg='yellow') + return 0 + + if not shutil.which(args.executable): + if args.require_pkl: + err = f'{args.executable} not found' + printf(f'{ERROR} {style(err, fg="bright_red")}') + printf(f'Install pkl: {PKL_INSTALLATION_DOCS_URL}', indent=8) + return 1 + printf( + f'{WARNING} ' + f'{style("Executable not found", fg="bright_white")}: ' + f'{style(args.executable, fg="bright_magenta")}' + ) + printf('Skipping Pkl syntax validation', indent=8) + printf(style(PKL_INSTALLATION_DOCS_URL, dim=True), indent=8) + return 0 + + base_cmd: list[str] = [args.executable, 'eval', '--format', 'yaml'] + if args.color is not None: + base_cmd.extend(['--color', args.color]) + if args.timeout is not None: + base_cmd.extend(['--timeout', str(args.timeout)]) + if args.no_cache: + base_cmd.append('--no-cache') + + retval: int = 0 + for filename in args.filenames: + try: + result = subprocess.run([*base_cmd, filename], check=True, capture_output=True, text=True) + + except subprocess.CalledProcessError as cmd_exc: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + if cmd_exc.stderr: + stderr_lines = cmd_exc.stderr.splitlines() + if unstyle(stderr_lines[0].strip()) == '–– Pkl Error ––': # noqa: RUF001 (pkl emits literal en-dashes) + stderr_lines = stderr_lines[1:] + printf('\n'.join(stderr_lines), indent=8) + else: + printf(str(cmd_exc), indent=8) + retval = 1 + + except Exception as e: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'{e!s}', indent=8) + retval = 1 + + else: + if args.verbose: + printf(f'{INFO} {style(filename, fg="bright_white")}') + printf(style('Valid Pkl', fg='green'), indent=8) + if args.dump_yaml: + printf(style('Compiled YAML:', fg='bright_white', bold=True, dim=True), indent=8) + printf(style(result.stdout, dim=True), indent=10) + + return retval + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/hooks/format-pkl.py b/scripts/hooks/format-pkl.py new file mode 100755 index 0000000..1ab7564 --- /dev/null +++ b/scripts/hooks/format-pkl.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to auto-format Pkl configuration files via ``pkl format``. + +If ``pkl`` is not found on PATH, prints a warning and exits successfully (exit code 0) so that the hook does not +block commits in environments where ``pkl`` is not installed. Otherwise, runs ``pkl format`` on all provided files +to enforce consistent formatting. +""" +from __future__ import annotations + +import argparse +import functools +import re +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Union +from typing import cast +from typing import get_args + +if TYPE_CHECKING: + from collections.abc import Sequence + + +# ========================== +# Constants/defaults/globals +# ========================== + + +PKL_INSTALLATION_DOCS_URL: str = 'https://pkl-lang.org/main/current/pkl-cli/index.html#installation' + +VERBOSE: bool = False + + +# =============== +# Types/protocols +# =============== + + +PathLike = Union[str, Path] +StyleColor = Union[int, tuple[int, int, int], str] + + +# ================== +# Formatting/logging +# ================== + + +def style( + text: Any, + *, + fg: StyleColor | None = None, + bg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """ + Style text with ANSI escape codes. + + :param text: the string to style with ansi codes + :param fg: foreground color + :param bg: background color + :param bold: enable or disable bold mode + :param dim: enable or disable dim mode + :param underline: enable or disable underline + :param overline: enable or disable overline + :param italic: enable or disable italic + :param blink: enable or disable blinking + :param reverse: enable or disable inverse rendering + :param strikethrough: enable or disable striking through text + :param reset: add a reset-all code at the end of the string + :return: styled text + """ + _ansi_colors: dict[str, int] = { + 'black': 30, 'bright_black': 90, + 'red': 31, 'bright_red': 91, + 'green': 32, 'bright_green': 92, + 'yellow': 33, 'bright_yellow': 93, + 'blue': 34, 'bright_blue': 94, + 'magenta': 35, 'bright_magenta': 95, + 'cyan': 36, 'bright_cyan': 96, + 'white': 37, 'bright_white': 97, + 'reset': 39, + } + _ansi_reset_all = '\033[0m' + + def _interpret_color(_color: StyleColor, offset: int = 0) -> str: + """ + Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string. + + :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple + :param offset: offset added to the base code (0 for foreground, 10 for background) + :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``) + """ + if isinstance(_color, int): + return f'{38 + offset};5;{_color:d}' + if isinstance(_color, (tuple, list)): + r, g, b = _color + return f'{38 + offset};2;{r:d};{g:d};{b:d}' + _color = cast('str', _color) + return str(_ansi_colors[_color] + offset) + + if not isinstance(text, str): + text = str(text) + + bits: list[str] = [] + if fg: + try: + bits.append(f'\033[{_interpret_color(fg)}m') + except KeyError: + raise TypeError(f'Unknown color {fg!r}') from None + if bg: + try: + bits.append(f'\033[{_interpret_color(bg, 10)}m') + except KeyError: + raise TypeError(f'Unknown color {bg!r}') from None + if bold is not None: + bits.append(f'\033[{1 if bold else 22}m') + if dim is not None: + bits.append(f'\033[{2 if dim else 22}m') + if underline is not None: + bits.append(f'\033[{4 if underline else 24}m') + if overline is not None: + bits.append(f'\033[{53 if overline else 55}m') + if italic is not None: + bits.append(f'\033[{3 if italic else 23}m') + if blink is not None: + bits.append(f'\033[{5 if blink else 25}m') + if reverse is not None: + bits.append(f'\033[{7 if reverse else 27}m') + if strikethrough is not None: + bits.append(f'\033[{9 if strikethrough else 29}m') + + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return ''.join(bits) + + +def unstyle(text: str) -> str: + """ + Remove ANSI styling information from a string. + + :param text: the text to remove style information from + :return: string with ANSI styling characters removed + """ + return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text) + + +def folduser(path: PathLike) -> str: + """ + Replace the user's home directory with ``~`` in a path string. + + :param path: path + :return: folded path string + """ + return str(path).replace(str(Path.home()), '~') + + +ERROR = style('ERROR', fg='bright_red', bold=True) +WARNING = style('WARNING', fg='bright_yellow', bold=True) +MODIFIED = style('MODIFIED', fg='bright_yellow') +UNMODIFIED = style('UNMODIFIED', fg='bright_blue') + + +def printf( + text: str = '', + *, + fg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + indent: int = 0, + verbose: bool = False, +) -> None: + """ + Print styled text with optional indent and verbose gating. + + :param text: text to print + :param fg: foreground color + :param bold: bold mode + :param dim: dim mode + :param indent: number of spaces to indent output by + :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr + """ + if verbose and not VERBOSE: + return + if verbose: + fg = fg or 'bright_white' + dim = True if dim is None else dim + file = sys.stderr + else: + file = None + + print( + style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim), + file=file, + ) + + +# ====================== +# Command-line arguments +# ====================== + + +GrammarVersion = Literal[1, 2] + +DEFAULT_GRAMMAR_VERSION: GrammarVersion = 2 + + +class Args(argparse.Namespace): + """ + :class:`argparse.Namespace` annotated with args supported by this script. + """ + filenames: Sequence[str] + require_pkl: bool + executable: str + grammar_version: GrammarVersion + verbose: bool + + +def parse_args(argv: Sequence[str] | None = None) -> Args: + """ + Build argument parser and parse command-line args. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: :class:`Args` (typed :class:`argparse.Namespace` subclass) + """ + global VERBOSE + + parser = argparse.ArgumentParser( + description='Format Pkl files using ``pkl format``', + formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50), + add_help=False, + ) + + def add_positional_args() -> None: + """ + Register the positional arguments group on the parser. + """ + positional_args = parser.add_argument_group('Positional arguments') + positional_args.add_argument( + 'filenames', + nargs='*', + help='.pkl files to format', + ) + add_positional_args() + + def add_pkl_executable_opts() -> None: + """ + Register the Pkl executable options group on the parser. + """ + pkl_executable_opts = parser.add_argument_group('Pkl executable options') + pkl_executable_opts.add_argument( + '--require-pkl', + action='store_true', + default=False, + help=( + 'Exit with error (nonzero status) if ``pkl`` executable is not found.\n' + 'By default, simple warning outputted and script exits with status code 0' + ), + ) + pkl_executable_opts.add_argument( + '--executable', + metavar='PATH', + type=str, + default='pkl', + help='Filepath to ``pkl`` executable to use. Default: "pkl" (resolve from PATH)', + ) + add_pkl_executable_opts() + + def add_grammar_options() -> None: + """ + Register the grammar version options group on the parser. + """ + grammar_opts = parser.add_argument_group('Grammar options') + grammar_opts.add_argument( + '--grammar-version', + type=int, + choices=get_args(GrammarVersion), + default=DEFAULT_GRAMMAR_VERSION, + help=( + f'The grammar compatibility version to use\n' + f' 1: 0.25 - 0.29\n' + f' 2: 0.30+ {style("(default)", dim=True, fg="bright_white")}' + ), + ) + add_grammar_options() + + def add_output_opts() -> None: + """ + Register the output options group on the parser. + """ + output_opts = parser.add_argument_group('Output options') + output_opts.add_argument( + '-v', + '--verbose', + action='store_true', + default=False, + help='Output formatting result for each processed file to console', + ) + add_output_opts() + + def add_other_opts() -> None: + """ + Register the miscellaneous options group on the parser. + """ + other_opts = parser.add_argument_group('Other options') + other_opts.add_argument( + '-h', + '--help', + action='help', + default=argparse.SUPPRESS, + help='Show this help message and exit', + ) + add_other_opts() + + args = parser.parse_args(argv, namespace=Args()) + VERBOSE = args.verbose + return args + + +# ================= +# Core script logic +# ================= + + +def main(argv: Sequence[str] | None = None) -> int: + """ + Format Pkl files using ``pkl format``. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: 0 on success, non-zero on failure + """ + args = parse_args(argv) + if not args.filenames: + print(style('No filenames to format', fg='yellow')) + return 0 + + if not shutil.which(args.executable): + if args.require_pkl: + err = f'{args.executable} not found' + printf(f'{ERROR} {style(err, fg="bright_red")}') + printf(f'Install pkl: {PKL_INSTALLATION_DOCS_URL}', indent=12) + return 1 + printf( + f'{WARNING} ' + f'{style("Executable not found", fg="bright_white")}: ' + f'{style(args.executable, fg="bright_magenta")}' + ) + printf('Skipping Pkl formatting', indent=12) + printf(style(PKL_INSTALLATION_DOCS_URL, dim=True), indent=12) + return 0 + + base_cmd: list[str] = [args.executable, 'format', '--diff-name-only'] + if args.grammar_version: + base_cmd.extend(['--grammar-version', str(args.grammar_version)]) + + retval: int = 0 + for filename in args.filenames: + try: + try: + subprocess.run([*base_cmd, filename], check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cmd_exc: + if cmd_exc.returncode == 11: + subprocess.run([*base_cmd, filename, '--write'], check=True, capture_output=True, text=True) + print(f'{MODIFIED} {style(filename, fg="bright_white")}') + continue + raise + + except subprocess.CalledProcessError as cmd_exc: + print(f'{ERROR} {style(filename, fg="bright_white")}') + if cmd_exc.stderr: + stderr_lines = [ + line for line in cmd_exc.stderr.splitlines() + if line.strip() and (line.strip() != 'An error occurred during formatting.') + ] + printf('\n'.join(stderr_lines), indent=12) + else: + printf(str(cmd_exc), indent=12) + + retval = max(retval, cmd_exc.returncode) + + except Exception as e: + printf(f'{ERROR} {style(filename, fg="bright_white")}') + printf(f'{e!s}', indent=12) + return 1 + + else: + if args.verbose: + printf(f'{UNMODIFIED} {style(filename)}') + + return retval + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/hooks/sort-json-array.py b/scripts/hooks/sort-json-array.py new file mode 100755 index 0000000..e01dfe2 --- /dev/null +++ b/scripts/hooks/sort-json-array.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to sort a top-level JSON array of objects by a chosen key. +""" +# /// script +# requires-python = ">= 3.9" +# dependencies = [] +# /// +from __future__ import annotations + +import argparse +import functools +import json +import re +import sys +import textwrap +from collections.abc import Sequence +from pathlib import Path +from typing import Any +from typing import Union +from typing import cast + +# ========================== +# Constants/defaults/globals +# ========================== + + +VERBOSE: bool = False +DEFAULT_KEY: str = 'command' +DEFAULT_INDENT: int = 4 + + +# =============== +# Types/protocols +# =============== + + +PathLike = Union[str, Path] +StyleColor = Union[int, tuple[int, int, int], str] + + +# ================== +# Formatting/logging +# ================== + + +ANSI_COLOR: dict[str, int] = { + 'black': 30, 'bright_black': 90, + 'red': 31, 'bright_red': 91, + 'green': 32, 'bright_green': 92, + 'yellow': 33, 'bright_yellow': 93, + 'blue': 34, 'bright_blue': 94, + 'magenta': 35, 'bright_magenta': 95, + 'cyan': 36, 'bright_cyan': 96, + 'white': 37, 'bright_white': 97, + 'reset': 39, +} +ANSI_RESET_ALL = '\033[0m' + + +def _interpret_color(_color: StyleColor, offset: int = 0) -> str: + """ + Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string. + + :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple + :param offset: offset added to the base code (0 for foreground, 10 for background) + :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``) + """ + if isinstance(_color, int): + return f'{38 + offset};5;{_color:d}' + if isinstance(_color, (tuple, list)): + r, g, b = _color + return f'{38 + offset};2;{r:d};{g:d};{b:d}' + _color = cast('str', _color) + return str(ANSI_COLOR[_color] + offset) + + +def style( + text: Any = '', + *, + fg: StyleColor | None = None, + bg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """ + Style text with ANSI escape codes. + + :param text: the string to style with ansi codes + :param fg: foreground color + :param bg: background color + :param bold: enable or disable bold mode + :param dim: enable or disable dim mode + :param underline: enable or disable underline + :param overline: enable or disable overline + :param italic: enable or disable italic + :param blink: enable or disable blinking + :param reverse: enable or disable inverse rendering + :param strikethrough: enable or disable striking through text + :param reset: add a reset-all code at the end of the string + :return: styled text + """ + if not isinstance(text, str): + text = str(text) + + bits: list[str] = [] + if fg: + try: + bits.append(f'\033[{_interpret_color(fg)}m') + except KeyError: + raise TypeError(f'Unknown color {fg!r}') from None + if bg: + try: + bits.append(f'\033[{_interpret_color(bg, 10)}m') + except KeyError: + raise TypeError(f'Unknown color {bg!r}') from None + if bold is not None: + bits.append(f'\033[{1 if bold else 22}m') + if dim is not None: + bits.append(f'\033[{2 if dim else 22}m') + if underline is not None: + bits.append(f'\033[{4 if underline else 24}m') + if overline is not None: + bits.append(f'\033[{53 if overline else 55}m') + if italic is not None: + bits.append(f'\033[{3 if italic else 23}m') + if blink is not None: + bits.append(f'\033[{5 if blink else 25}m') + if reverse is not None: + bits.append(f'\033[{7 if reverse else 27}m') + if strikethrough is not None: + bits.append(f'\033[{9 if strikethrough else 29}m') + + bits.append(text) + if reset: + bits.append(ANSI_RESET_ALL) + return ''.join(bits) + + +def unstyle(text: str) -> str: + """ + Remove ANSI styling information from a string. + + :param text: the text to remove style information from + :return: string with ANSI styling characters removed + """ + return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text) + + +def folduser(path: PathLike) -> str: + """ + Replace the user's home directory with ``~`` in a path string. + + :param path: path + :return: folded path string + """ + return str(path).replace(str(Path.home()), '~') + + +ERROR = style('ERROR', fg='bright_red', bold=True) +INFO = style('INFO', fg='bright_blue', bold=True) +WARNING = style('WARNING', fg='bright_yellow', bold=True) + + +def printf( + text: str = '', + *, + fg: StyleColor | None = None, + bold: bool | None = None, + dim: bool | None = None, + indent: int = 0, + verbose: bool = False, +) -> None: + """ + Print styled text with optional indent and verbose gating. + + :param text: text to print + :param fg: foreground color + :param bold: bold mode + :param dim: dim mode + :param indent: number of spaces to indent output by + :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr + """ + if verbose and not VERBOSE: + return + if verbose: + fg = fg or 'bright_white' + dim = True if dim is None else dim + file = sys.stderr + else: + file = None + + print( + style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim), + file=file, + ) + + +# ====================== +# Command-line arguments +# ====================== + + +class Args(argparse.Namespace): + """ + :class:`argparse.Namespace` annotated with args supported by this script. + """ + filenames: Sequence[str] + key: str + indent: int + verbose: bool + + +def parse_args(argv: Sequence[str] | None = None) -> Args: + """ + Build argument parser and parse command-line args. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: :class:`Args` (typed :class:`argparse.Namespace` subclass) + """ + global VERBOSE + + parser = argparse.ArgumentParser( + description='Sort a top-level JSON array of objects by a chosen key', + formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50), + add_help=False, + ) + + def add_positional_args() -> None: + """ + Register the positional arguments group on the parser. + """ + positional_args = parser.add_argument_group('Positional arguments') + positional_args.add_argument( + 'filenames', + nargs='*', + help='JSON files (top-level array of objects) to sort', + ) + add_positional_args() + + def add_sort_opts() -> None: + """ + Register the sorting options group on the parser. + """ + sort_opts = parser.add_argument_group('Sorting options') + sort_opts.add_argument( + '-k', + '--key', + default=DEFAULT_KEY, + help=f'Object key whose value each array entry is sorted by (default: {DEFAULT_KEY!r})', + ) + sort_opts.add_argument( + '--indent', + type=int, + default=DEFAULT_INDENT, + help=f'Number of spaces to indent rewritten JSON by (default: {DEFAULT_INDENT})', + ) + add_sort_opts() + + def add_output_opts() -> None: + """ + Register the output options group on the parser. + """ + output_opts = parser.add_argument_group('Output options') + output_opts.add_argument( + '-v', + '--verbose', + action='store_true', + help='Output sort result for each analyzed file to console', + ) + add_output_opts() + + def add_other_opts() -> None: + """ + Register the miscellaneous options group on the parser. + """ + other_opts = parser.add_argument_group('Other options') + other_opts.add_argument( + '-h', + '--help', + action='help', + default=argparse.SUPPRESS, + help='Show this help message and exit', + ) + add_other_opts() + + args = parser.parse_args(argv, namespace=Args()) + VERBOSE = args.verbose + return args + + +# ================= +# Core script logic +# ================= + + +def sort_file(path: Path, *, key: str, indent: int) -> int: + """ + Sort a single JSON file's top-level array of objects by ``key`` in place. + + The file is rewritten only when the sorted, re-serialized content differs from the original. Output matches + the ``pretty-format-json`` hook (sorted object keys, trailing newline) so the two never produce competing rewrites. + + :param path: path to the JSON file to sort + :param key: object key whose value each array entry is sorted by + :param indent: number of spaces to indent the rewritten JSON by + :return: 0 if already sorted, 1 if the file was rewritten or could not be processed + """ + try: + original = path.read_text(encoding='utf-8') + except Exception as open_exc: + printf(f'{ERROR} {style(path, fg="bright_white")}') + printf(f'Unable to load file: {open_exc!s}', fg='bright_red', indent=8) + return 1 + + try: + data = json.loads(original) + except json.JSONDecodeError as json_exc: + printf(f'{ERROR} {style(path, fg="bright_white")}') + printf(f'Unable to decode JSON: {json_exc!s}', fg='bright_red', indent=8) + return 1 + + if (not isinstance(data, list)) or not all(isinstance(entry, dict) for entry in data): + printf(f'{ERROR} {style(path, fg="bright_white")}') + printf('Expected a top-level array of objects', fg='bright_red', indent=8) + return 1 + + ordered = sorted(data, key=lambda entry: entry.get(key, '')) + sorted_text = json.dumps(ordered, indent=indent, ensure_ascii=True, sort_keys=True, separators=(',', ': ')) + '\n' + + if sorted_text == original: + if VERBOSE: + printf(f'{INFO} {style(path, fg="bright_white")}') + printf(style(f'Already sorted by {key!r}', fg='green'), indent=8) + return 0 + + path.write_text(sorted_text, encoding='utf-8') + printf(f'{INFO} {style(path, fg="bright_white")}') + printf(style(f'Sorted array by {key!r}', fg='yellow'), indent=8) + return 1 + + +def main(argv: Sequence[str] | None = None) -> int: + """ + Sort top-level JSON arrays of objects by a chosen key. + + :param argv: command-line arguments (default: ``sys.argv[1:]``) + :return: 0 if every file was already sorted, non-zero if any was rewritten or failed + """ + args = parse_args(argv) + if not args.filenames: + printf('No filenames to sort', fg='yellow') + return 0 + + retval: int = 0 + for filename in args.filenames: + retval |= sort_file(Path(filename), key=args.key, indent=args.indent) + return retval + + +if __name__ == '__main__': + sys.exit(main()) From e8124120280580ff7279898193e6dbc596e2942d Mon Sep 17 00:00:00 2001 From: Connor Mason Date: Tue, 14 Jul 2026 02:51:10 -0700 Subject: [PATCH 2/2] option annotations in Args for hook scripts --- scripts/hooks/check-json5.py | 4 ++-- scripts/hooks/check-pkl.py | 16 ++++++++-------- scripts/hooks/format-pkl.py | 10 +++++----- scripts/hooks/sort-json-array.py | 8 ++++---- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/scripts/hooks/check-json5.py b/scripts/hooks/check-json5.py index b4ae37b..07c03c3 100755 --- a/scripts/hooks/check-json5.py +++ b/scripts/hooks/check-json5.py @@ -218,8 +218,8 @@ class Args(argparse.Namespace): """ :class:`argparse.Namespace` annotated with args supported by this script. """ - filenames: Sequence[str] - verbose: bool + filenames: Sequence[str] # positional arg(s) + verbose: bool # --verbose def parse_args(argv: Sequence[str] | None = None) -> Args: diff --git a/scripts/hooks/check-pkl.py b/scripts/hooks/check-pkl.py index e8a7bea..6c57b69 100755 --- a/scripts/hooks/check-pkl.py +++ b/scripts/hooks/check-pkl.py @@ -222,14 +222,14 @@ class Args(argparse.Namespace): """ :class:`argparse.Namespace` annotated with args supported by this script. """ - filenames: Sequence[str] - require_pkl: bool - executable: str - verbose: bool - dump_yaml: bool - color: ColorOption | None - timeout: float | None - no_cache: bool + filenames: Sequence[str] # positional arg(s) + require_pkl: bool # --require-pkl + executable: str # --executable + verbose: bool # --verbose + dump_yaml: bool # --dump-yaml + color: ColorOption | None # --color + timeout: float | None # --timeout + no_cache: bool # --no-cache def parse_args(argv: Sequence[str] | None = None) -> Args: diff --git a/scripts/hooks/format-pkl.py b/scripts/hooks/format-pkl.py index 1ab7564..77a77e4 100755 --- a/scripts/hooks/format-pkl.py +++ b/scripts/hooks/format-pkl.py @@ -223,11 +223,11 @@ class Args(argparse.Namespace): """ :class:`argparse.Namespace` annotated with args supported by this script. """ - filenames: Sequence[str] - require_pkl: bool - executable: str - grammar_version: GrammarVersion - verbose: bool + filenames: Sequence[str] # positional arg(s) + require_pkl: bool # --require-pkl + executable: str # --executable + grammar_version: GrammarVersion # --grammar-version + verbose: bool # --verbose def parse_args(argv: Sequence[str] | None = None) -> Args: diff --git a/scripts/hooks/sort-json-array.py b/scripts/hooks/sort-json-array.py index e01dfe2..d388293 100755 --- a/scripts/hooks/sort-json-array.py +++ b/scripts/hooks/sort-json-array.py @@ -212,10 +212,10 @@ class Args(argparse.Namespace): """ :class:`argparse.Namespace` annotated with args supported by this script. """ - filenames: Sequence[str] - key: str - indent: int - verbose: bool + filenames: Sequence[str] # positional arg(s) + key: str # --key + indent: int # --indent + verbose: bool # --verbose def parse_args(argv: Sequence[str] | None = None) -> Args: