Skip to main content

OrionBelt logo — a stylized belt of three stars

OrionBelt® Runner

Run OrionBelt Semantic Layer query batches and emit reports.

Version 0.9.0 OBSL 2.25.x Python 3.12+ License: BUSL-1.1

Pydantic v2 Typer WeasyPrint Ruff

Docker Hub Docker pulls Image size

Run OrionBelt Semantic Layer query batches and emit reports.

A run is a YAML document combining:

  • An OBSL endpoint (base URL, optional auth, optional locale/timezone, optional model to load)
  • A list of named queries — any valid OBML query body
  • A report config — markdown, HTML, or PDF output with sections bound to queries
  • Optional data exports — Parquet / Arrow / TSV to a folder or an S3 bucket (report optional: set no_report: true for an export-only run)

Numeric and timestamp cells are pre-rendered server-side using each column's format pattern from the OBML model (the runner sends format_values=true on every query), so reports show e.g. 1.853.429,67 for locale: de without any client-side formatting. See examples/monthly-revenue-2026-04-29.md (markdown), examples/monthly-revenue-2026-04-29.html (HTML), and examples/monthly-revenue-2026-04-29.pdf (PDF) for sample outputs.

Status

Early scaffold (v0.9.0). Markdown, HTML, and PDF reports, per-query data exports (Parquet / Arrow / TSV, to a folder or S3), and an always-on YAML run log sidecar. No scheduler yet — drive it from cron / systemd / GitHub Actions / Cloud Scheduler / etc.

Install

From PyPI — the runner is a CLI, so installing it as a tool keeps it out of your project's environment:

uv tool install orionbelt-runner              # core: markdown + HTML reports, TSV exports
uv tool install "orionbelt-runner[arrow]"     # + Parquet / Arrow exports and S3 destinations
uv tool install "orionbelt-runner[pdf]"       # + PDF output (requires Pango — see below)

pip install orionbelt-runner works the same way if you'd rather manage the environment yourself. Either way you get the orionbelt-runner command:

orionbelt-runner run spec.yaml

From a checkout, for development or to run an unreleased revision:

uv sync                  # core: markdown + HTML reports, TSV exports
uv sync --extra pdf      # also enable PDF output (requires Pango — see below)
uv sync --extra arrow    # also enable Parquet / Arrow exports and S3 destinations
uv run orionbelt-runner run spec.yaml

PDF output needs WeasyPrint, which draws through Pango — a system library rather than a wheel, which is why it sits behind an extra. On macOS: brew install pango. On Debian / Ubuntu: apt install libpango-1.0-0 libpangoft2-1.0-0, plus a font package such as fonts-dejavu-core on a system that has none, or text renders as empty boxes. Cairo and GDK-Pixbuf are not required despite older guides naming them: WeasyPrint has written PDFs itself since v53 and reads images through Pillow. See the WeasyPrint install guide for other platforms. Skip the extra if you only need markdown / HTML — or use the Docker image, which has all of this already.

Apple Silicon note: Homebrew installs libraries to /opt/homebrew/lib, which Python's loader doesn't search by default. If WeasyPrint can't find libgobject-2.0-0, prefix the runner with:

DYLD_LIBRARY_PATH=/opt/homebrew/lib:/usr/local/lib \
DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib:/usr/local/lib \
uv run orionbelt-runner run spec.yaml

DYLD_FALLBACK_LIBRARY_PATH is often enough, but setting both variables covers Python/cffi environments that call dlopen() with bare library names such as libgobject-2.0-0.

Run

uv run orionbelt-runner run examples/monthly-revenue.yaml

Override the OBSL endpoint without editing the spec:

uv run orionbelt-runner run examples/monthly-revenue.yaml \
  --base-url http://my-obsl:8080

Or via env (.env or shell):

OBSL_BASE_URL=http://my-obsl:8080 \
OBSL_API_KEY=obsl_pat_... \
uv run orionbelt-runner run examples/monthly-revenue.yaml

Docker

The runner ships as a container image on Docker Hub (ralforion/orionbelt-runner). The ENTRYPOINT is the CLI, so arguments go straight through:

docker run --rm ralforion/orionbelt-runner version

Mount your specs in and a directory for the reports out. The image works under /work, so relative paths in the spec resolve there:

docker run --rm \
  -e OBSL_BASE_URL=http://my-obsl:8080 \
  -e OBSL_API_KEY=obsl_pat_... \
  -v "$PWD/examples:/work/examples" \
  -v "$PWD/reports:/work/reports" \
  ralforion/orionbelt-runner run examples/monthly-revenue.yaml

When OBSL runs on the host, use --add-host=host.docker.internal:host-gateway and OBSL_BASE_URL=http://host.docker.internal:8080 so the container can reach it.

The image covers every output the runner produces — no extra is missing from it. pyarrow ships in it, so Parquet / Arrow and s3:// destinations work out of the box (mount a volume for local export folders, or point at a bucket and pass AWS credentials as env vars), and WeasyPrint ships with the Pango system libraries and a DejaVu font, so format: pdf renders without any host setup — the part that is fiddly to install locally is already done here.

docker run --rm \
  -e OBSL_BASE_URL=http://my-obsl:8080 \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION \
  -v "$PWD/examples:/work/examples" \
  ralforion/orionbelt-runner run examples/revenue-export-only.yaml

Build it yourself instead of pulling:

docker build -t orionbelt-runner .

Authentication

OBSL >= 2.12 supports AUTH_MODE=api_key (see the OBSL authentication guide). When the server enforces auth, give the runner an API key:

obsl:
  base_url: http://my-obsl:8080
  api_key: obsl_pat_...        # preferred
  api_key_header: X-API-Key    # optional; match the server's API_KEY_HEADER

Or via env, which overrides the spec — keep secrets out of YAML:

Variable Purpose
OBSL_API_KEY API key.
OBSL_API_KEY_HEADER Header name (default X-API-Key).

The key is sent in the X-API-Key header by default (OBSL also accepts Authorization: Bearer). When auth is off (AUTH_MODE=none) the key is ignored, so it's safe to leave set. A 401/403 from OBSL surfaces as an ObslAuthError with a hint about which key/header was sent.

Compatibility & startup preflight

This runner requires OBSL 2.16 or newer and is developed against 2.25.x. Before running any query, orionbelt-runner run calls the unauthenticated /health endpoint and checks:

  • the server version is at or above the floor (older → error, upgrade the server; newer than tested → a warning, the run proceeds), and
  • an API key is configured when the server reports AUTH_MODE=api_key.

A failed check exits non-zero with Preflight failed: … before any session is created. Pass --skip-preflight to bypass it (e.g. against a custom build).

OBSL 2.16 validates model and query payloads against its published JSON Schemas at the ingestion boundary. A malformed OBML model loaded by the runner, or a query body in the spec that violates the schema, comes back as a 422 and surfaces as an ObslSchemaError listing the offending fields. The schema is camelCase — use canonical camelCase keys, lowercase enum values, and a numeric (not string) model version.

Server expectations

The runner calls /v1/query/execute, so OBSL needs to be configured to execute queries (not just compile them):

  • QUERY_EXECUTE=true (or FLIGHT_ENABLED=true)
  • DB driver credentials configured for the dialect(s) you query

Three deployment shapes are supported, in order of preference:

  1. Single-model mode (MODEL_FILE=... on the server). Spec leaves obsl.model and obsl.model_id unset; the runner uses top-level shortcut endpoints.
  2. Multi-model server with a model already loaded. Set obsl.model_id in the spec; the runner still uses shortcut endpoints and keys into the named model.
  3. Runner-loaded model. Set obsl.model.yaml_path in the spec — the runner creates a session, posts the model to /v1/sessions/{id}/models, runs queries against /v1/sessions/{id}/query/execute, and deletes the session in a finally block. Useful for ad-hoc reports against a model you keep next to the spec file.

Spec format

See examples/monthly-revenue.yaml for a full spec.

name: Monthly Revenue
obsl:
  base_url: http://localhost:8080
  locale: de                       # optional — BCP-47, drives display formatting
  # timezone: Europe/Berlin        # optional — IANA TZ
  # model_id: sales                # multi-model server with a pre-loaded model
  # model:                         # OR: load your own model into a fresh session
  #   yaml_path: ./sales.obml.yaml # path is resolved relative to this spec file
  #   extends: [./fragments/dim-time.yaml]
queries:
  - name: total_revenue
    dialect: postgres
    query:
      select:
        measures: [Total Revenue]
report:
  format: markdown
  output: reports/{name}-{date}.md
  title: "Monthly Revenue  {date}"
  sections:
    - heading: Headline number
      query: total_revenue
      render: value

Section render modes:

render Output
table Table of all rows
value Single bold value (first numeric column of first row by default)
list Bullet list of one column

Path placeholders are accepted in report.output, report.title, report.intro, and report.footer. The instant comes from OBSL's GET /v1/settings (server clock + effective TZ); falls back to the runner's UTC clock when settings is unreachable.

Placeholder Example Notes
{name} Monthly Revenue the spec name
{date} 2026-04-29 YYYY-MM-DD in the resolved TZ
{datetime} 2026-04-29T18-02-06Z filesystem-safe; trailing Z only when TZ is UTC
{time} 18:02:06 colons are unsafe on Windows paths — use {time_filename}
{time_filename} 18_02_06 filesystem-safe
{tz} Europe/Berlin IANA name
{tz_filename} / {timezone} Europe, Berlin / replaced with , for path safety
{runner_version} 0.4.0 the OrionBelt Runner version that produced this report
{duration_ms} 1234 wall-clock run duration in whole milliseconds; best in intro / footer / title, not paths

report.footer additionally accepts result-derived counters: {number_of_queries}, {number_of_sections}, {number_of_rows} (camelCase aliases — {numberOfQueries} etc. — also work).

Queries from a folder

For larger query libraries, point at a directory instead of (or in addition to) inline queries::

queries_dir: ./queries        # recursive *.yaml + *.yml, alpha-sorted
queries:                      # optional, runs after dir queries in spec order
  - name: ad_hoc
    query: { ... }

Each file is a full QuerySpec (name, dialect, query, optional description). When name: is omitted the filename stem is used verbatim — total-revenue.yamltotal-revenue. Duplicate names across the dir + inline list raise an error at load time. Paths resolve relative to the spec file.

Outputs

A run produces up to three artefacts in the report directory, plus any configured exports: destinations:

reports/monthly-revenue-2026-04-29.md           ← report
reports/monthly-revenue-2026-04-29.run.yaml     ← run log (always)
reports/monthly-revenue-2026-04-29_exports/     ← TSV exports (opt-in)
  ├── total_revenue.tsv
  ├── revenue_by_nation.tsv
  └── top_orders_raw.tsv

exports/Monthly Revenue/2026-04-29/             ← `exports:` target (opt-in)
  ├── total_revenue.parquet
  ├── revenue_by_nation.parquet
  └── top_orders_raw.parquet
s3://analytics-landing/orionbelt/…/*.parquet    ← `exports:` target (opt-in)

Report — markdown, HTML, or PDF

format: markdown (default) writes a .md. format: html writes a self-contained HTML5 document with inline default CSS and a light/dark theme — no external assets, so the file works when emailed, opened from disk, or served from a static host. The output extension follows the template you set in output:.

report:
  format: html
  output: reports/{name}-{date}.html
  title: "Monthly Revenue  {date}"

See examples/monthly-revenue-2026-04-29.html for a rendered HTML sample.

format: pdf reuses the same HTML pipeline and runs the result through WeasyPrint — so PDF layout stays automatically in lockstep with the HTML output. A print-only stylesheet adds page margins and a page n / total footer; section headings (h2) get a page-break-before: auto / page-break-after: avoid hint so tables don't get orphaned.

report:
  format: pdf
  output: reports/{name}-{date}.pdf
  title: "Monthly Revenue  {date}"
  pdf_page_size: A4         # "A4" (default) or "A3"
  pdf_orientation: portrait # "portrait" (default) or "landscape"

pdf_page_size and pdf_orientation are ignored for markdown / html output. Reach for A3 or landscape when a table has many columns or wide cell values that wrap awkwardly in A4 portrait — the same content, just more horizontal room.

PDF requires the optional pdf extra (uv tool install "orionbelt-runner[pdf]") and WeasyPrint's system libraries — see Install. Both are already present in the Docker image, which is the least painful way to get PDF output. See examples/monthly-revenue-2026-04-29.pdf for a rendered PDF sample.

Run log (YAML sidecar) — always written

Always emitted next to the report at <report-stem>.run.yaml, even when queries fail (when it's most useful). Captures, for each query: the compiled SQL as a literal block scalar, the OBSL explain plan (planner + reasons + joins + CFL legs), the resolved info (fact tables / dimensions / measures), wall-clock and server-side timing, warnings, and any errors. The file header records the spec name, OBSL version / api_version, session/model IDs, and the report path.

YAML so it's both human-skimmable and trivially machine-parseable in one step. Useful for debugging, audit trails, and downstream tooling that wants the SQL or the plan.

See examples/monthly-revenue-2026-04-29.run.yaml.

Per-query TSV exports — opt-in

Set report.export_results: true to write each query's rows as TSV into a sibling <report-stem>_exports/ directory:

report:
  output: reports/{name}-{date}.md
  export_results: true

One file per query, named after the query and sanitised to safe path chars. TSV uses \t separator and \n line endings; cells with embedded tabs / newlines / quotes are quoted (compatible with pandas.read_csv(..., sep='\t')). Cells reflect the same format_values=true data the report shows. Exports are only written on a fully successful run.

See examples/monthly-revenue-2026-04-29_exports/.

Data exports — Parquet / Arrow / TSV, to a folder or S3

exports: is a list of destinations, each written on a fully successful run — one file per query, named after the query (sanitised to safe path chars):

exports:
  - format: parquet                        # parquet | arrow | tsv
    uri: exports/{name}/{date}/            # local folder
    compression: zstd                      # default | none | snappy | gzip | brotli | lz4 | zstd

  - format: parquet
    uri: s3://analytics-landing/orionbelt/{date}/
    region: eu-central-1
    # endpoint_override: http://localhost:9000   # MinIO / R2 / Ceph

uri takes the same placeholders as report.output ({name}, {date}, {datetime}, {time_filename}, {tz}, …) and names a directory prefix. Local relative paths are rebased under --output-dir when that flag is set, exactly like the report path. Several targets can run side by side (e.g. a local folder for the team, an S3 prefix for the warehouse).

Values are natively typed. Parquet and Arrow targets read through OBSL's Arrow transport (?format=arrow), which answers with a real Arrow table rather than JSON. The server's own types are written through untouched — decimal128 for governed DECIMAL measures, timestamp[us], int64, double — with no client-side inference and no locale-formatted strings anywhere in the path.

Against a deployment that doesn't answer the Arrow frame, the runner falls back to JSON rows (one request, no retry) and infers types from OBSL's per-column type hint, including decimal(p, s). That path yields double where the transport would have given exact decimal128, so prefer a server on the supported line.

The runner only pays for what a run actually consumes:

spec executes per query
report (or a tsv target) only 1 — formatted
report (or a tsv target) + parquet / arrow 2 — formatted for the report, raw for the export
no_report: true with only parquet / arrow targets 1 — raw

So a report run keeps rendering from the formatted call (its cells stay OBSL-authoritative), while an export-only job doesn't pay double DB cost for display strings nobody reads.

format extension values compression
parquet .parquet native types (raw run) snappy (default), gzip, zstd, brotli, lz4, none
arrow .arrow native types (raw run) uncompressed (default), lz4, zstd
tsv .tsv formatted, as in the report

parquet / arrow and any s3:// destination need the optional arrow extra (uv sync --extra arrow), which pulls in pyarrow — it provides both the file formats and the S3 client. tsv to a local folder needs nothing extra.

S3 credentials are never read from the spec. pyarrow uses the standard AWS chain: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN), ~/.aws/credentials, or the instance / task / IRSA role — so a spec file stays safe to commit. Only the non-secret region and endpoint_override live in the spec; AWS_ENDPOINT_URL works as a fallback for the latter.

Failures are visible, but never destructive. An unreachable bucket or a failed raw re-execute is logged (export_target_failed / raw_execute_failed) and skipped, leaving the report and run log intact — but the CLI then prints what didn't land and exits 1, so a scheduled job whose data never arrived can't look like a clean run:

$ orionbelt-runner run extract.yaml; echo "exit=$?"
Run log written: reports/Revenue Extract-2026-04-29T12-00-00.run.yaml
  export parquet → s3://analytics-landing/… failed: OSError: bucket not found

1 export target(s) failed
exit=1

A query whose raw re-execute failed is omitted from typed targets rather than written with string columns, so a downstream schema never silently changes shape — and that omission is reported the same way. On RunResult, succeeded still means "every query ran"; fully_delivered additionally means "every configured export landed".

Export-only runs — no_report

Set no_report: true to skip report rendering entirely: queries run, exports: targets are written, and the run log still lands (that's the audit trail).

name: Revenue Extract
no_report: true
queries: [...]
exports:
  - format: parquet
    uri: s3://analytics-landing/orionbelt/{name}/{date}/

The report: block becomes optional in this mode — keep it to toggle reporting off without deleting the config (the run log then stays in its configured folder), or drop it and the log goes to {name}-{datetime}.run.yaml, rebased under --output-dir. report.export_results (the sibling-TSV shortcut) is report-relative and therefore inert here; use an exports: target instead.

See examples/revenue-export-only.yaml.

Architecture

The runner talks to OBSL through a small ObslClient Protocol. One implementation today (HTTP). Tests can drop in a fake; an in-process implementation can be added later without touching the runner, report, or CLI code.

spec.yaml ──▶ load_spec ──▶ Runner ──▶ ObslClient ──▶ OBSL (HTTP)
                              │
                              ├─▶ render_markdown / render_html / render_pdf ──▶ report.md|html|pdf
                              ├─▶ render_runlog                 ──▶ report.run.yaml
                              ├─▶ render_tsv (× N)              ──▶ report_exports/*.tsv
                              └─▶ render_export (× N) ──▶ Sink  ──▶ ./folder/*.parquet|arrow|tsv
                                                                    s3://bucket/prefix/*

License

Copyright 2025 RALFORION d.o.o.

Licensed under the Business Source License 1.1. The Licensed Work will convert to Apache License 2.0 on 2030-03-16.

For commercial licensing inquiries, contact: licensing@ralforion.com

Third-party dependencies

THIRD-PARTY-NOTICES.md lists every third-party package the runner can pull in — the runtime closure plus the arrow and pdf extras — with its license, its full license text, and whether the Docker image actually redistributes it.

That last column matters, because the artifacts differ. The wheel contains only orionbelt_runner; the sdist adds this repository's own sources. Neither carries any third-party code — pip fetches it from PyPI — so for both the file is informational. The image genuinely redistributes what the Dockerfile installs — today both extras, so the whole closure, WeasyPrint and pyphen included — and carries the same texts at /app/.venv/lib/python*/site-packages/*.dist-info/licenses/ alongside /app/LICENSE and /app/THIRD-PARTY-NOTICES.md.

The interpreter and the Debian base the image is built on sit below what uv.lock can see, so they are covered by a Platform layer section in the same file: CPython is PSF-2.0, the OS packages carry their terms at /usr/share/doc/*/copyright inside the image, and corresponding source stays retrievable from https://snapshot.debian.org because the base is pinned at build time. No GPL code is linked into or imported by the runner.

Everything in the closure is permissive (MIT / BSD / Apache-2.0 / ISC / PSF) with two documented exceptions, certifi (MPL-2.0) and pyphen (tri-licensed; the image ships it, so RALFORION elects LGPL-2.1-or-later and never the GPL option) — both explained in that file. An acknowledgement is bound to the license expression that was reviewed, not to the package name, so a dependency that relicenses comes back through the gate.

CI regenerates the notices and fails if a dependency arrives under a license nobody has signed off on:

uv run --no-sync python scripts/third_party_notices.py          # regenerate
uv run --no-sync python scripts/third_party_notices.py --check  # what CI runs

RALFORION d.o.o.

Copyright © 2026 RALFORION d.o.o.
OrionBelt® is a registered trademark of RALFORION d.o.o.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

orionbelt_runner-0.10.0.tar.gz (279.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

orionbelt_runner-0.10.0-py3-none-any.whl (53.2 kB view details)

Uploaded Python 3

File details

Details for the file orionbelt_runner-0.10.0.tar.gz.

File metadata

  • Download URL: orionbelt_runner-0.10.0.tar.gz
  • Upload date:
  • Size: 279.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for orionbelt_runner-0.10.0.tar.gz
Algorithm Hash digest
SHA256 e8c17eb0453568cc30d8f1ff9befb61f0c6b79b49636cbafbaf70524d83ae4e3
MD5 ab37bff4bc38adaf88e1f8879571cecb
BLAKE2b-256 c373a5ed020d4d92b444aded79e642f4995ad5f9add1f3090ef6b1e9d99fddd3

See more details on using hashes here.

Provenance

The following attestation bundles were made for orionbelt_runner-0.10.0.tar.gz:

Publisher: pypi-publish.yml on ralforion/orionbelt-runner

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file orionbelt_runner-0.10.0-py3-none-any.whl.

File metadata

File hashes

Hashes for orionbelt_runner-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dd164d02b864649e4b6bb5c9b200d993743fd9403386cc9365514b704faa4cf0
MD5 05d407de3546fc9a84d44ce46032621f
BLAKE2b-256 38a2979ebf73ddad8676f8687e128d9fa686d49fe9327fc4a459b1f886993e22

See more details on using hashes here.

Provenance

The following attestation bundles were made for orionbelt_runner-0.10.0-py3-none-any.whl:

Publisher: pypi-publish.yml on ralforion/orionbelt-runner

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.10.0 This release

2 files

0.9.0

2 files

0.4.0

2 files

0.3.1

2 files

0.3.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page