Running it in CI

validate exits 1 when it finds an error and 0 otherwise, which is the whole integration story. Everything below is about where to put it.

As a gate in front of a batch

The case this was written for: a job that builds several hundred requests and submits them. Validate the whole set first, so a malformed field fails in a second rather than after two hundred paid renders.

#!/usr/bin/env bash
set -euo pipefail

fail=0
for f in requests/*.json; do
  if ! h3check validate "$f" > "/tmp/$(basename "$f").report"; then
    echo "--- $f"
    cat "/tmp/$(basename "$f").report"
    fail=1
  fi
done
exit "$fail"

set -e would abort on the first failure, which is why the call is wrapped in if. You want the whole list, not the first item.

GitHub Actions

name: validate render requests

on:
  pull_request:
    paths:
      - 'requests/**.json'

jobs:
  h3check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install h3check
      - name: Validate
        run: |
          for f in requests/*.json; do
            echo "::group::$f"
            h3check validate "$f" || echo "::error file=$f::request rejected"
            echo "::endgroup::"
          done

pip install h3check pulls nothing else in — there are no dependencies, so the step costs a second and needs no cache. If you would rather vendor than install, copy the module and h3_spec.json into the same directory and call python3 vendor/h3check.py; the spec is loaded relative to the module file, not the working directory.

pre-commit

- repo: local
  hooks:
    - id: h3check
      name: h3check
      entry: h3check validate
      language: python
      additional_dependencies: ["h3check"]
      files: ^requests/.*\.json$

pre-commit passes matched filenames as arguments. validate takes exactly one path, so this works as written only because the hook is invoked per file by default; if you set pass_filenames: false you will need the loop from the first example.

Reading requests from stdin

When requests are generated rather than stored, skip the temporary file:

python3 build_request.py --shot 14 | h3check validate -

Exit code behaves identically. This is also the form to use inside a Python job, since the validator is importable:

import json, subprocess, sys
from h3check import validate

report = validate(request_dict)
if not report.ok:
    print(report.render(), file=sys.stderr)
    sys.exit(1)

validate() returns a Report dataclass with errors, warnings and notes lists, an ok property, and a render() method that produces the indented text block the CLI prints. Using the dataclass directly is the cleanest way to make your own decisions about warnings.

Treating warnings as fatal

Warnings deliberately do not affect the exit code — see Errors, warnings and notes for why. If your pipeline disagrees, do not patch the tool; make the decision at the call site:

from h3check import validate

report = validate(request_dict)
if report.errors or report.warnings:
    raise SystemExit(report.render())

From a shell, grep the output rather than the exit code:

out=$(h3check validate request.json) || true
printf '%s\n' "$out"
printf '%s' "$out" | grep -q 'WARNING' && exit 1

The two warnings that exist today — ref2va with no references, and an attempt to switch audio off — are both cases where the request succeeds and quietly does something other than what you meant, so a strict pipeline treating them as fatal is a defensible choice.

Cost estimation in the same job

cost always exits 0, so it composes freely:

h3check cost --duration 8 --resolution 2K | tail -1
# total: $1.0400

For a whole batch, import the function instead of shelling out per request:

from h3check import snap, cost

total = 0.0
for req in requests:
    _, _, rendered = snap(req["duration"])
    total += cost(rendered, req["resolution"])
print(f"batch total: ${total:.2f}")

Note snap() in that loop. Billing is per rendered second, and only an 8-second request renders the length you asked for; summing the requested durations will under-count.