Skip to content

Use the CLI

You want to compile a spec directory, or find out which metrics it can actually answer, without writing a Python script. bloomery is that: eight commands, each a thin argument shell over one public function.

bloomery resolve specs/
Stage: complete
Fingerprint: blm1:46f0d4f549273b5e43db9b6961ce4ef35919611d2987e1dc0f841c355840087c

Reachable (3)
  average_order_value
  gross_revenue
  order_count

Unreachable (1)
  margin  missing: cogs

Marts (1)
  order_items  grain: order_item

That is the command the rest exist alongside. "Did the spec I just wrote do what I meant" is the question bloomery is best placed to answer, and the specific missing leaf is the part a summary would throw away.

A spec that is refused still gets an answer. resolve calls evaluate(), which reports refusals rather than raising them, so a draft mid-edit prints how far analysis got, what was reachable at that point, and every refusal with its source path:

Stage: guardrails
  analysis stopped here — every count below is a prefix, not a total

Reachable (2)
  landed_revenue
  shipping_cost

Unreachable (0)

Refusals (1)
  marts: marts.order_items.measures.shipping_cost
    GrainViolation: measure 'shipping_cost' has grain 'order' (one row per
      order), not the mart's grain 'order_item' …

Exit code 1 — the spec is still refused, and a pipeline branching on the code must not start reading it as fine. Read the stage before the counts: at any stage but complete they are a prefix, and Unreachable (0) there means "never computed" rather than "nothing unreachable".

A project that wires a steps: document reports the unwired step here, because the CLI passes no registry — see Steps are the one thing the CLI cannot wire below. bloomery compile on the same project refuses for the same reason.

The nine commands

bloomery compile     <dir> [--target sqlmesh] [--dialect duckdb] [--catalog F] [--out DIR]
                           [--emit-ir PATH] [--upstream ALIAS=PATH]
bloomery plan        <old-dir> <new-dir> [--catalog F] [--format table|json]
bloomery resolve     <dir> [--catalog F] [--format table|json]
bloomery check       <dir> [--catalog F] [--format table|json]
bloomery lineage     <dir> --node ID [--direction upstream|downstream|both] [--max-depth N]
                           [--catalog F] [--format table|json]
bloomery timeline    <dir>... --node ID [--catalog F] [--format table|json]
bloomery explain     <dir> --metrics a,b [--by x,y] [--where JSON] [--grain month]
                           [--limit N] [--policy 'dim op value'] [--dialect duckdb]
                           [--format table|json]
bloomery schema      [--kind entity_model] [--out DIR]
bloomery fingerprint <dir> [--catalog F]

Every command reads files, calls the public API, and writes stdout or a directory. Nothing is executed, nothing is cached, and there is no state anywhere.

One flag needs no command: bloomery --version prints the installed release and exits 0. It is a flag rather than a command of its own because the moment you most need it is when something is wrong enough that you do not trust the rest of the surface.

The spec directory

<dir> is a directory of .yaml / .yml documents. Each is keyed by its filename stem, which is the prefix you will see in every error message — so a refusal reads metrics: metrics.revenue.agg: … and you know which file to open.

A catalog is not part of a project, so it has to be told apart from the project documents. Two ways, in order:

  • --catalog path/to/catalog.yaml — explicit, and the only way to point several projects at one shared catalog.
  • A file named catalog.yaml in the directory. Recognized by name, never by looking inside it.

If a catalog document ends up in the project by accident, load_project refuses it by name and tells you to pass it separately. That is a loud failure, which is the point.

Compiling

bloomery compile specs/ --target sqlmesh --dialect duckdb --out out/
out/config.yaml
out/models/gold/dim_date.sql
out/models/gold/mart_order_items.sql
out/models/silver/order.sql
out/models/silver/order_item.sql

config.yaml is what makes that directory a SQLMesh project — without it sqlmesh declines to read the models at all. It carries the dialect you compiled for and a model_defaults.start taken from the catalog's date_dimension.start_year, which is not a nicety: with no start, SQLMesh backfills every time-partitioned model over a single day and reports success.

It carries no gateway, and it never will. A connection holds hosts and credentials and this compiler reads no environment — the same reason no profiles.yml is emitted for dbt. Supply one through SQLMESH__GATEWAYS__…, and do not edit a gateways: block into the emitted file: SQLMesh keeps the connection in the same file as the project settings, so the next compile overwrites it.

A project that backfills by time and declares no catalog date dimension gets no config.yaml, because there is no honest start to put in it — write your own, as before.

Without --out, the artifacts go to stdout as JSON — path, content, kind and checksum for each — so you can place them yourself. --target takes sqlmesh, cube, dbt, metricflow, or the name of an emitter you registered. metricflow writes one semantic_manifest.json instead of models — the semantic layer over marts the other targets build.

Steps are the one thing the CLI cannot wire. A StepRegistry is assembled by the caller and handed to compile_project; bloomery reads no step manifests from disk, by design — that absence is what stops an authored spec from becoming a code-loading surface. A flag would have to invent a manifest loader. A project using steps: compiles through Python, and UnknownStep names the versions the registry does hold. resolve reports that refusal too, rather than answering reachability as though the wiring were not there.

Composing across projects

A project with an imports.yaml reads another project's published surface, and what crosses is the upstream's compiled IR — never its spec documents, and never a path bloomery goes looking for. So composing on the command line is two commands: the upstream writes its IR, and the downstream is handed that file under the alias it imports it by.

bloomery compile tests/fixtures/ecom_basic --emit-ir /tmp/platform.json --out /tmp/upstream
bloomery compile tests/fixtures/cross_project/downstream --target dbt \
    --upstream platform=/tmp/platform.json --out /tmp/downstream
/tmp/downstream/dbt_project.yml
/tmp/downstream/dependencies.yml
/tmp/downstream/macros/generate_schema_name.sql
/tmp/downstream/models/gold/mart_lines.sql
/tmp/downstream/models/schema.yml
/tmp/downstream/models/silver/shipment.sql
/tmp/downstream/models/sources.yml

mart_lines is the downstream's own mart over an entity it imported: no model is emitted for the upstream's relations, because the upstream builds them. --emit-ir writes the IR beside the artifacts and is independent of --target — one IR file serves every downstream target. --upstream is repeatable, once per alias, and platform is the downstream's private spelling of the upstream in imports.yaml; a project carries no name of its own, so nothing is resolved by it.

The path is yours. bloomery reads the file you name and nothing else — no registry, no package index, no lookup beside the spec directory. A checkout, a CI artifact, a file you copied by hand: how the upstream IR got there is your problem, which is what keeps a compile free of I/O it did not ask for. Omit --upstream and the compile is refused with UnknownUpstream naming the alias and what was supplied.

The IR is versioned. One written by a different bloomery release is refused on load with the message plan() gives for the same mismatch — recompile both sides with one compiler — rather than being read as though the shapes agreed.

Planning a change

bloomery plan deployed/ proposed/
Changes (2, 0 breaking)
  additive  field:discount    field added
  widening  field:unit_price  type widened

Backfill scope
  (none)

Downstream metrics
  gross_revenue

Both directories are compiled to IR and diffed. The breaking count is the number you decide on; the rest is context. See evolve a spec for what each change class means.

Tracing one node over time

plan compares two spec sets. timeline takes as many as you have, and reports one node across all of them:

bloomery timeline q1/ q2/ q3/ --node metric.gross_revenue

The directories are positional and the order you give is the order reported — nothing is sorted, and no directory name is parsed. The label printed for each version is the string you typed, so name your directories so that the order you want falls out of the glob:

bloomery timeline history/*/ --node metric.gross_revenue

There is no manifest file and no --as-of, for the same reason there is no --steps: the moment bloomery reads a list of versions it owns what a history is, and that is yours.

The answer covers what the node is built from, so a metric whose own definition never moved still reports the field beneath it that did — each change naming the node it is about. See trace a definition over time for how to read the facets.

A node absent from some versions is an answer and prints as absent. A node absent from every version is refused with exit 1: node ids differ between versions when a project adopts an id:, so that means the spelling is wrong for this window. There is no did-you-mean, because suggestions need one graph and this has one per version — run bloomery lineage <dir> --node ... against a single directory to get one.

A project that wires steps: is refused here exactly as compile refuses it, and for the same reason. The Python API takes a registry per version.

Explaining a request

bloomery explain specs/ --metrics gross_revenue --by ordered_month --limit 5

prints the SQL, then the provenance record — how each number was computed, which mart served it, and whether a row policy applied. Filters arrive as the same JSON document parse_filter_json takes:

bloomery explain specs/ --metrics gross_revenue \
  --where '{"customer_id": {"$neq": "internal"}}'

--policy 'order_customer_id eq c1' applies a row-level scoping filter — a dimension, an operator, and a value, comma-separated for in / not_in. It is a filter, not an identity: deciding whose policy applies is your side of the boundary.

After the provenance record comes the evidence: every fact the plan rests on, and where each one came from.

Evidence (2 locked, 2 assumed)
  ASSUMED branch:order_items
          order_items is aggregated to the requested grain before the join, so it holds
          one row per key
  ASSUMED branch:orders
          orders is aggregated to the requested grain before the join, so it holds one
          row per key
  LOCKED  mart:order_items.line_discount
          line_discount is a measure of order_items, whose grain is order_item
  LOCKED  mart:orders.shipping_count
          shipping_count is a measure of orders, whose grain is order

LOCKED means a person wrote the fact down in a spec. ASSUMED means the compiler obtained it mechanically — a default, an inference from a type, or a propagation through a proof rule. The request above is both at once, and the split is the useful part: the measures are yours, and the reasoning that makes joining two grains safe is bloomery's.

ASSUMED is not a criticism. Such a fact is sound — nothing rests on a guess, because a guess cannot close a proof at all — and the grade records where the fact came from, not whether it is right. What it tells you is which of your semantics somebody has actually decided.

The grades themselves enforce nothing here. A consumer that wants to rest on declared premises only says so with requires_evidence: locked on the mart or the exposure; explain reports, and a finance mart and an exploration mart want different answers.

After the evidence comes the derivation: not the facts, but the argument over them. Each line is one proof's conclusion and the rule that admitted it, premises printed above the conclusion they support:

Derivation (3 proof(s))

  UniqueAtGrain(keys=total)  [R010: a branch's rows are unique at the result grain by its own aggregate]

  ServedAtGrain(grain=order_item, mart=order_items)  [R008: a measure embedded in a mart originates at that mart's grain]

  ServedAtGrain(grain=order, mart=orders)  [R008: a measure embedded in a mart originates at that mart's grain]

Three proofs for two measures: each mart's own authorization to serve a measure at its grain, and the join's — which is the one that makes this request safe rather than a fan-out, and the one a guardrail's silence could never have shown you. Read together with the evidence above, that is the whole answer: why it was accepted, and what the answer rests on.

Rule ids (R008, R010) are a public contract: append-only, never reused and never repointed, so a CI check may assert that a request is still admitted by the rule it was admitted by. A rule that is split or subsumed keeps its entry and names its successors, so a citation written last year still resolves.

Every request the planner answers carries both sections. Four shapes used not to — a metric computed from others, a semi-additive measure, a cumulative one, and two metrics restricted differently — and they now come through rules of their own, so a derived metric's R014 sits over the aggregate its inputs were computed from. A plan that genuinely derives nothing says so in words rather than as an empty heading: (no facts — this plan carries no proof) and (no proof — this plan derives nothing).

explain prints; it never runs anything. There is no bloomery run, no connection string, and no profile — not as an omission but as a decision. Execution belongs to whatever already owns your warehouse credentials.

Emitting the schemas

bloomery schema --out schemas/
bloomery schema --kind entity_model | jq .

See the JSON Schema reference for what the documents contain and how to point an editor at them.

Checking in CI

resolve answers "what can I compute, and what is missing for the rest". check answers "is what this project declares sound" — the same analysis, summarised as a gate:

bloomery check specs/
Stage: complete
Fingerprint: blm1:46f0d4f549273b5e43db9b6961ce4ef35919611d2987e1dc0f841c355840087c

  2  entities        resolved
  1  relationships   checked
  4  measures        type-check
  1  marts           checked
  0  conversions     proven
  0  temporal joins  anchored

0 refusal(s)

It needs no warehouse, no credentials, no network and no target — it reads files and calls evaluate(), the same function resolve calls — so it runs in pre-commit and on a runner with no secrets:

- name: bloomery semantic check
  run: bloomery check .

Each number is a surface that was checked, and there is no total. A sum would imply a coverage nobody proved: a green check says the project's own declarations hold, not that every future query against it is safe.

What fails it is a refusal, and only a refusal. An unreachable metric and an open decision are both reported by resolve and neither fails check — they say the mappings are incomplete, which is the state a draft passes through, not that what is written is wrong. Where analysis stopped before an IR existed, check says the counts are unavailable rather than printing six zeros: a zero reads as a surface that was checked and held nothing, and "nothing was checked" is false too — the stages that ran checked plenty, and what is missing is the arithmetic over an IR nobody built.

Each verb names the process the count came from, not a verdict. Marts are checked and not safe: a guardrail refusal is reported over the draft it was handed, so a project refused for a reason unrelated to its marts prints its mart count beside that refusal, and the stage never finished ruling on them. The quality mart is not counted at all — it is bloomery-owned and attaches after the guardrails, so nothing checked it and nobody wrote it.

Exit codes

Code Meaning
0 Success.
1 A refusal. Bloomery read the spec and said no, with a reason and a source path.
2 A usage error: a path that is not there, a flag that is not a flag.
3 An internal error: a bug in bloomery, not in your spec or invocation.

The line between them is whose mistake it was. 1 means bloomery read your spec and said no. 2 means the invocation was wrong before any spec was opened — a path, a flag, a --where document that is not JSON, a mistyped --target, --dialect, --grain or --policy, a file that is not UTF-8 or cannot be read. 3 means the mistake was bloomery's: an exception no handler claimed. It still prints the traceback to stderr — a bug report needs it — under a line asking you to report it, and never as the interface.

One non-code: a broken pipe is not an error at all. bloomery schema | head means the reader chose to stop; the command exits 0 quietly instead of a traceback.

main returns these codes rather than raising, so a Python caller can read them: from bloomery.cli import main; code = main(["resolve", "specs/"]). That holds for argparse's own refusals too.

The split matters for scripting. A refusal is a correct outcome — the compiler did its job — so a pipeline that retries on it will retry forever. Branch on the code:

if ! bloomery compile specs/ --out out/; then
  case $? in
    1) echo "spec refused; fix the spec" ;;
    2) echo "bad invocation" ;;
    3) echo "bloomery bug; report it" ;;
  esac
fi

Refusals go to stderr with their source path prepended:

entity_model: entities.order.fields.total.type: String should match pattern '^(?:string|int|…

Scripting with --format json

plan, resolve, check and explain take --format json, and it emits the same values the Python API returns — not a summary of them. bloomery resolve --format json carries each mart's measures and dimensions and the full text of every refusal even though the table prints neither, because a script should not have to drop into Python for a field the function already returned.

bloomery resolve specs/ --format json | jq '.unreachable[] | {name, missing, via}'
bloomery resolve specs/ --format json | jq '.refusals[] | {type, source_path}'
bloomery explain specs/ --metrics gross_revenue --format json | jq '.derivation[].rule'

explain --format json carries derivation as a top-level key — each proof as a document with its rule, conclusion, premises and facts. It is beside the plan rather than only under the nodes that carry it, because a branch's proof is not at the top level of a composed plan at all, and a run asserting on the reasoning should not have to walk a plan to find it.

Three conversions are worth knowing. A Decimal becomes a string (never a float — see determinism). A logical type becomes the string a spec writes it as, decimal(12, 4). And a refusal — which is an exception, not a dataclass — becomes its type, its message, and every attribute it carries, so source_path and the structured fix suggestions arrive as fields rather than as prose to re-parse.

There is no status field, and check does not add one. The verdict is the exit code, and a key repeating it would be a second answer that can disagree with the first. A consumer reading a stored artifact rather than an exit code reads the stage, which says more than a verdict would — where analysis stopped, not just that it did:

bloomery check specs/ --format json | jq -e '.stage_reached == "complete"'
bloomery check specs/ --format json | jq '.checked // "stopped before an IR was built"'

What the CLI will never grow

No run and no engine connection. No config file, profile, or credentials. No bloomery init or bloomery new entity — templates encode opinions about project layout that this library deliberately does not hold. No watch mode and no daemon, both of which imply state. Each of these is pinned by a test, because the pressure to add them arrives as a small, reasonable-looking patch.