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.yamlin 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.