API¶
The public API — everything importable from bloomery, kept in lockstep with the
package's __all__.
The list is closed over its own signatures: any type named in a public signature is
exported from bloomery too, so Project, Catalog, ProjectIR, EmittedArtifact and
the naming policies are importable from the root rather than only from their subpackages.
The subpackage paths keep working. See Stability for what each surface
promises.
Loading¶
load_catalog(text: str) -> Catalog¶
Parse a catalog document from YAML text. Pure — strings in, model out; all I/O belongs
to the caller. Raises SpecParseError on YAML, shape, or kind failures.
load_project(sources: Mapping[str, str]) -> Project¶
Parse a project from named YAML documents. Keys are document names (used as
source-path prefixes in errors), values are YAML text. Each document self-identifies
via its version key; documents are processed in sorted-name order, and all failures
across all documents are batched into a single SpecParseError.
Compiling¶
compile_project(project, *, target, dialect, naming=None, catalog=None, steps=EMPTY_REGISTRY) -> tuple[EmittedArtifact, ...]¶
Compile a parsed project into target artifacts — the whole pipeline (resolve,
typecheck, guardrails, lower, emit) as one pure function. target is a Target or
the string name of a registered extension emitter; dialect is "duckdb",
"trino", or "postgres"; naming defaults to layer-based naming
(silver.<entity>, gold.mart_<name>). Same specs in, byte-identical artifacts out.
Each EmittedArtifact carries path, content, kind, and checksum.
steps is a StepRegistry — the manifests behind whatever the project's
steps_version document wires. It is a caller-assembled argument rather than a spec
document because bloomery reads no step files: a project that wires a step and compiles
without its registry is refused, not silently compiled without it. A project wiring no
steps needs nothing here.
Target¶
The shipped emit targets: Target.SQLMESH, Target.CUBE, Target.DBT,
Target.METRICFLOW. A string enum, so target="sqlmesh" also works.
METRICFLOW emits the semantic manifest rather than models — one
semantic_manifest.json, holding what manifest_json serializes from the
manifest object emit_manifest returns, and nothing else. A project with no
marts emits no artifact.
Analysis¶
resolve(project: Project, catalog: Catalog | None = None) -> Resolution¶
Run reference validation, recipe validation, and reachability without emitting
anything. The Resolution carries reachable_metrics, unreachable_metrics (each
with its specific missing leaves), per-field provenance (direct / recipe / native),
the deterministic topological order, and the dependency graph those three were
computed from.
lineage(graph, root, direction=Direction.UPSTREAM, *, max_depth=None) -> Lineage¶
Walk a Resolution.graph from one node and return the reachable sub-DAG — the
nodes and the labelled edges, never enumerated paths, whose count is exponential in
the graph's width. Direction.UPSTREAM answers "what is this built from",
Direction.DOWNSTREAM answers "what would break if this moved", and
Direction.BOTH merges the two — its nodes and edges are the union of what the two
walks reached, so every edge carried is one a walk actually traversed. That matters where
an ancestor of the root also connects directly to a descendant of it: the bypass edge is
real lineage, but it is not the root's, and inducing over the merged node set would carry
it.
The root is at depth 0, so max_depth=N carries every node within distance N and
only edges whose both endpoints are carried; truncated says whether the bound cut
the walk short. A root with no lineage in that direction is a one-node result rather
than an error — a source column has no upstream, and that is an answer. A negative
max_depth raises ValueError.
Requires an acyclic graph, which resolve() guarantees: it raises CircularDerivation
before returning.
Each edge carries a label saying how one node feeds the next. The vocabulary is closed:
| Label | From → To | Means |
|---|---|---|
direct |
source column → entity field | a mapped field, no recipe |
recipe:<id> |
source column → entity field | a validated catalog recipe, id recorded |
step:<ref@version> |
source column → entity field | a field computed by a Tier 1 sql_macro |
canonical |
entity field → canonical field | the field links to a catalog canonical |
requires |
canonical field → metric | a metric's leaf requirement |
requires_metrics |
metric → metric | a metric composed of metrics |
step_input |
entity field → step | a step reading a mapped entity, whole |
step_input |
step → step | a step reading another step's output |
step_output |
step → entity field | a step's declared output |
The two parameterised labels carry a suffix after the : — a recipe id, or a macro's
ref@version. Split on the colon to compare families.
build_project_ir(project, catalog=None, *, steps=EMPTY_REGISTRY) -> ProjectIR¶
Compile specs into the frozen intermediate representation without emitting — the
input to project_fingerprint, plan, and MetricFlowPlanner.plan. Runs the same
resolution, typecheck, and guardrail stages as compile_project, so a spec that
builds an IR is a spec that compiles — and takes steps for the same reason and on
the same terms.
project_fingerprint(ir: ProjectIR) -> str¶
The blm1:-prefixed SHA-256 content hash of a project IR — the value stamped into
every emitted artifact's header. Stable within a bloomery version, deliberately not
across versions.
evaluate(project, *, catalog=None, steps=EMPTY_REGISTRY) -> SpecEvidence¶
Everything knowable about a spec without touching data, as one frozen value: what is reachable, what is not and which leaf is missing, what was refused and where, and the shape of every mart. Never raises for a spec-level refusal — refusals are the return value, and whatever analysis completed before them comes back alongside.
InvariantViolated and every programming error still propagate. See
Assess a spec and
Close an open decision.
SpecEvidence¶
stage_reached, reachable, unreachable, refusals, marts, entities,
unresolved, provenance, fingerprint. Read stage_reached first: every tuple is
empty both when there was nothing to find and when the stage that finds it never ran, and
those mean opposite things. fingerprint is None unless stage_reached is
Stage.COMPLETE.
OpenDecision¶
canonical, gap, entity, field, options, blocks — one decision the spec leaves
open, and the edit that would close it. blocks names the metrics waiting on it and is
never empty: a canonical field nothing requires is not work. See
Close an open decision.
options are the recipes the catalog declares for that field, in the order the
catalog declares them. They are not suggestions, not candidates, and not ranked — bloomery
validates a recorded choice and never makes one, including where there is exactly one
option. Catalog order is authored information (recipes are ordered by reliability), so
re-sorting it destroys something rather than normalizing it.
An entry appears only where it can name one edit. A canonical field belonging to an
entity that several mappings build is left out, because such an entity's columns are per
mapping and no single document is the edit; the metric blocked on it is still in
unreachable.
Gap¶
UNLINKED — no entity field carries canonical: <name>, so the edit is an entity-model
one. UNMAPPED — a field carries the link and no mapping produces it, so the edit is a
mapping field and it is where a recipe id is recorded. The two report identically without
this field, and they are the two different edits.
RecipeOption¶
id, requires, expr. requires names the alias slots a mapping's from: must
bind to source paths — never canonical fields — so recipes do not compose and a chooser's
loop cannot grow work by doing it. expr is None for an identity over a single
requirement.
FieldProvenance¶
entity, field, provenance (DIRECT / RECIPE / NATIVE), recipe_id — set iff
the provenance is RECIPE. On SpecEvidence.provenance and on Resolution.provenance:
what a spec has already decided, and what it decided.
Stage¶
RESOLVE, TYPECHECK, LOWER, GUARDRAILS, COMPLETE — the stage analysis stopped
at. Treat it as an open enum: compare against COMPLETE and read everything else
as "analysis stopped early", so a stage added later does not break the comparison.
MartSummary¶
name, grain, measures, dimensions (role-qualified), materialization — a mart's
shape, projected from the IR rather than recomputed.
UnreachableMetric¶
name, missing, via. missing names the specific leaves — the canonical fields
nothing maps — because the fix is always a mapping. via names the metrics between this
one and those leaves, when it is blocked through another metric rather than on its own.
Spec-diff planning¶
plan(old: ProjectIR | None, new: ProjectIR) -> Plan¶
Diff two compiled IRs into a classified migration plan. plan(None, new) is the
initial deploy; plan(ir, ir) is empty. Raises RenameTargetMissing on a stale
renamed_from annotation and ContractViolation on an expand/contract breach; every
other change, breaking included, is classified and returned. See
Evolve a spec safely.
Plan¶
changes (sorted Change tuple), backfill_scope (BackfillScope), replay_scope
(ReplayScope), downstream_impact (affected metric names), and affected_exposures
(the declared consumers those changes reach — computed from the changed marts as well as
the downstream metrics, so an exposure that depends only on a mart is not omitted).
Properties: has_changes, breaking.
Change¶
One classified difference: entity, subject (<kind>:<name>), change_class,
human-readable detail, and compact old/new value reprs.
ChangeClass¶
The closed classification vocabulary: ADDITIVE, WIDENING, RENAME, RESTATING,
BREAKING.
BackfillScope¶
entities — the sorted entities whose stored rows a plan invalidates — and
restates_history, true when any RESTATING change is present.
ReplayScope¶
entities — the entities whose <entity>__reject tables a plan invalidates, ordered
parents first (topologically, ties by name, a cycle broken at its first member by name),
so a caller running them in order
replays a child after its parent.
Distinct from BackfillScope because the two name different storage: a backfill
recomputes an entity from bronze, while a replay re-runs the current mapping against
rows that are not in bronze's incremental window at all. Populated only where a change
can actually free rows: the old disposition was quarantine, and the rule is now
gone, now disposes as flag, or has relaxed parameters. A tightening — a narrowed
bound, or quarantine → fail — needs a backfill and no replay: every quarantined row
still fails the rule, so replaying it drains nothing. Where relaxation is undecidable
from the parameters (a pattern regex, an expression), the replay is reported.
A replayed entity brings its dependents: an entity whose referential rule at
on_missing: quarantine reaches one in scope is named too, and one at unknown_member
or flag is named in BackfillScope instead.
bloomery emits the replay merge artifact; executing it is the caller's. See Add quality
rules.
Request-time planning¶
MetricFlowPlanner(hydrator, max_limit=50_000, default_limit=None, *, naming=None)¶
The planner port backed by an embedded, render-only MetricFlow. naming must match
the policy the artifacts were emitted with.
.plan(ir, request, *, dialect, policy=None) -> QueryPlan — validate the request,
check mart coverage, and render SQL; nothing is executed. Refusals raise the planner
error taxonomy (UnknownMember, UnreachableAtGrain, AmbiguousDimension,
InvalidRequest, FilterTypeMismatch, and the UnsupportedFilter family). See
Plan a metric request.
MetricRequest(metrics, dimensions=(), filters=(), time_grain=None, order_by=(), limit=None)¶
A structured metric request. Construction enforces the structural rules (at least one
metric, no duplicates, order over requested members only, limit >= 1). filters is
CNF: a tuple of clauses (implicit AND), each a Predicate or one AnyOf group.
Predicate(dimension, op, values=())¶
One typed single-dimension filter (S-0032). op is an Op member; value arity is
checked per operator (is_null takes exactly one bool; like/ilike take one or
more patterns). Floats are accepted and normalized to Decimal(str(value)) at
construction; non-finite numerics raise InvalidLiteral.
AnyOf(predicates)¶
One disjunction group — OR across its predicates, AND with every other clause.
Exactly one level: members are Predicate only, and may span different dimensions.
Op¶
The closed filter-operator vocabulary: EQ, NE, GT, GTE, LT, LTE, IN,
NOT_IN, IS_NULL, LIKE, ILIKE. A string enum, so op="eq" also works.
like/ilike operands are SQL LIKE patterns — caller-owned wildcards with \ as
the escape character; nothing is auto-wrapped.
OrderSpec(field, direction="asc")¶
One ordering term over a requested metric or dimension — never arbitrary SQL. Carries no nulls placement (non-default placements are refused at the JSON front door).
TimeGrain¶
Requestable grains: HOUR, DAY, WEEK, MONTH, QUARTER, YEAR. Applies to
every date-role dimension in the request; HOUR is refused at coverage (marts carry
day–year buckets).
RowPolicy(dimension, op, value)¶
A row-level scoping filter — dimension, Op, scalar or scalar tuple — rendered
through the same escaping pipeline as user filters (via as_clause()) and prepended
to them, reaching every scan. A policy is one predicate; range policies compose into
the request filters instead. Deciding whose policy applies is upstream work.
QueryPlan¶
The planner's product: sql, columns (tuple of ColumnDescriptor), mart,
warnings, explanation (with .render()), and fingerprint (sha256(sql)).
ColumnDescriptor¶
One output column, carrying both names it has:
name— what you asked for, in bloomery's vocabulary (ordered_month).sql_alias— what the emitted SQL actually projects (order__ordered_day__month).type— the logical type.role—"dimension"or"measure".label— optional description.
Constructed by keyword only — sql_alias sits second rather than last, so a positional
call would misassign every field after the first.
Bind result rows by sql_alias, display name. For a measure the two agree; for a
dimension they do not, because MetricFlow qualifies the column by entity and suffixes a
date role with its grain. Positional binding — columns[i] against projection i — works
and always did.
bloomery.planner.parse_filter_json(payload, *, clause_cap=64) -> tuple[Clause, ...]¶
The public JSON front door for the Mongo-flavoured filter grammar ($and/$or/
$not, field maps {field: scalar | {op: value} | [array]}, spellings $eq $neq $gt
$gte $lt $lte $in $nin $null $like $ilike). Normalizes before refusing — De Morgan
push-down, complement inversion, CNF distribution with the clause cap enforced during
distribution — and refuses only with UnsupportedFilter leaves carrying stable
.reason codes.
Two failure classes, deliberately distinct. A construct the vocabulary reviewed and
declined — a set relation, a hierarchy operator, $regex, an over-cap CNF expansion, a
non-invertible negation — raises UnsupportedFilter with a .reason from
KNOWN_UNSUPPORTED. Refusals fire wherever the parser reaches them: operator refusals,
non-finite literals, and the nesting-depth cap during tree construction;
UnsupportedNegation and the CNF clause cap after the rewrite. .normalized is set
wherever it says something useful — the form the document had reached for
UnsupportedNegation, a size sentinel (>64 levels deep, >64 clauses) for the two
caps. A malformed document — a non-mapping payload, a
field map of the wrong shape, an unknown $op, an operand of the wrong type — raises
InvalidRequest: it never reaches the closed list, because malformed input is a schema
error, not a reviewed gap. The same split holds for parse_sort_json and
parse_page_json: only a well-formed placement or a well-formed non-zero offset reaches
UnsupportedSortNulls/UnsupportedPagination; anything else is InvalidRequest.
bloomery.planner.parse_sort_json(payload) -> tuple[OrderSpec, ...]¶
Sort documents ({field: "asc" | "desc" | {"dir": …, "nulls": …}}) to order terms. A
nulls equal to the canonical default (first for asc, last for desc) is dropped; a
well-formed non-default placement raises UnsupportedSortNulls. A present nulls key
must hold exactly "first" or "last" — a wrong type, an explicit null, or an unknown
word is InvalidRequest; omitting the key is the canonical default.
bloomery.planner.parse_page_json(payload) -> int | None¶
Pagination documents ({"limit": …, "offset": …}) to the request limit. Non-zero
offsets and cursor keys (after/before) raise UnsupportedPagination; a malformed
payload — a non-mapping document, an unknown key, a non-int limit/offset — raises
InvalidRequest.
bloomery.planner.KNOWN_UNSUPPORTED: frozenset[str]¶
The closed refusal list: exactly the .reason codes the three parse functions can
raise, drift-guarded by test. Adapters assert their refusal handling covers this set.
LruManifestHydrator(naming, *, max_entries=500, fetch_l2=None, prewarm=False)¶
The default in-process manifest cache behind the planner: an LRU of hydrated semantic
manifests keyed by HydrationKey. On a miss it consults the caller-injected
fetch_l2 callable (your storage, your I/O), falling back to rebuilding from the IR.
hits/misses/hit_rate are plain counters to poll into your metrics system.
.get(ir) returns the hydrated lookup for a project.
fetch_l2 must answer for the key it is handed, or return None. Non-empty bytes
are hydrated and cached under that key unexamined, so a store keyed loosely returns
another project's manifest and keeps returning it. Bloomery cannot check this — the
payload is MetricFlow's manifest and carries no fingerprint, so verifying would mean
re-deriving one on every hit, which is the parse the L2 exists to avoid. None is
always safe: a miss rebuilds from the IR. See also Thread safety.
HydrationKey¶
The cache key covering all three invalidation axes: spec_fingerprint,
bloomery_version, metricflow_version. A spec edit or a version bump changes the
key, so stale entries are a miss, never an error.
Thread safety¶
The advice elsewhere in these docs — build the planner once and reuse it — means, in a web service, shared across threads. The contract that makes that sound:
MetricFlowPlanneris safe to share. Its instance state is set at construction and only read afterwards;plan()takes everything else as arguments. Concurrentplan()calls on one shared planner are exercised by test (tests/unit/test_runtime/test_hydration.py) and return identical plans.LruManifestHydratoris safe to share. The cache isfunctools.lru_cache, whose internal state is lock-protected in CPython, and a manifest is hydrated as a pure function of(key, ir)— the cross-thread cache-poisoning bug that motivated that shape is regression-tested.- A cold miss is not deduplicated. Threads missing the same key concurrently each
hydrate (and each call
fetch_l2) — pinned by test, forced rather than raced for. The results are identical, so this costs duplicate work on a cold key, never wrong answers. If yourfetch_l2is expensive, put single-flight deduplication inside it; bloomery will not call your I/O under a lock it owns. fetch_l2must be thread-safe. It is caller-owned I/O, invoked concurrently and never serialized by bloomery.
What is not promised: mutating a NamingPolicy or registering transforms
(register_transform) while other threads compile or plan. Registration is
process-global and intended for import time.
Schema export¶
spec_json_schema(kind: SpecKind) -> JsonDict¶
The JSON Schema for one spec kind, generated from the Pydantic model so it cannot drift
from the parser. Deterministic (sorted keys), addressable ($schema plus a
version-carrying $id), and faithful to the authored shape rather than the parsed
one. See JSON Schema.
all_spec_schemas() -> Mapping[SpecKind, JsonDict]¶
Every kind's schema, in SpecKind order — six standalone documents, not a bundle.
SpecKind¶
The six loadable kinds: CATALOG, ENTITY_MODEL, MAPPING, MARTS, METRICS,
STEPS. A string enum, so spec_json_schema("metrics") also works.
JsonDict¶
One JSON Schema document — dict[str, object]. A schema is produced to be serialized,
not indexed.
Extension points¶
register_transform(spec: TransformSpec) -> None¶
Register an extension transform into the process-global overlay consulted after the
built-in whitelist. Name collisions raise TransformRegistrationError. See
Transforms.
register_emitter(emitter: TargetEmitter) -> None¶
Register an extension target emitter; the emitter's name becomes a valid target=
string for compile_project. Collisions raise EmitError.
Supporting types¶
Signature closure puts every type named above in the root namespace, so none of them needs a deep import:
| Group | Names |
|---|---|
| Specs and IR | Project, Catalog, ProjectIR, UnreachableMetric |
| Compilation | EmittedArtifact, ArtifactKind, TargetEmitter, NamingPolicy, DefaultNaming |
| Analysis | Resolution, FieldProvenance, Provenance, Node, NodeKind, Graph, Edge, Lineage, Direction, OpenDecision, Gap, RecipeOption |
| Planner | Clause, Scalar, Explanation, MeasureExplanation, ColumnRole, OrderDirection |
| Steps | StepRegistry, StepManifest, EMPTY_REGISTRY |
| Transforms and types | TransformSpec, ArgKind, Builder, OutputType, LogicalType |
| Schema export | SpecKind, JsonDict |
| Fix suggestions | MartCoverage, MeasureRef |
Project, Catalog and ProjectIR are handles: you receive one and pass it back.
Their fields are internal and change freely — read Resolution or QueryPlan instead.
Errors¶
BloomeryError¶
The base of every error the package raises — str(exc) is the human message,
exc.source_path addresses the offending spec node, exc.collected holds individual
failures on batched aggregates. The full tree lives in Errors.
Five refusals also carry a structured fix suggestion — UnknownMember.did_you_mean,
UnreachableAtGrain.covering_marts, GrainViolation.offending_measures,
UnknownStep.available_versions, UnsupportedFilter.nearest_supported — each exposing a
value bloomery already computed on its way to writing the message. The two payload types
MartCoverage and MeasureRef are exported from the root. See
Errors.
Command line¶
bloomery compile|plan|resolve|explain|schema|fingerprint is a thin argument shell over
exactly the functions above — no logic of its own, no execution, no state. It is the only
part of the package that touches a filesystem, and nothing in the library imports it. See
Use the CLI.