As a library¶
stevin is a command you run, and a library you call. The command is the
library's first customer: every stevin subcommand is argument parsing and
rendering around the functions on this page, which is how they stay honest.
Reach for this when stevin is a step inside something larger — a deployment
task that plans, shows the plan its own way, asks its own question and applies
it; a notebook; a policy check that refuses a plan with a destructive step in
it.
Everything below is exported from stevin itself. Names reached through a
submodule are the implementation, and move without notice.
The seven steps¶
Every host does these, in this order.
import stevin
# 1. the project
project = stevin.Project.find() # or .load("stevin.yml")
# 2. a target, resolved
target = project.resolve(project.default) # asks the Databricks CLI
# about a bundle, if there is one
# 3. the specs, with every diagnostic
specs = project.load_specs(target)
if specs.errors:
for problem in specs.errors:
print(problem) # file:line:column, and what
raise SystemExit(1)
# 4. a connection
conn = stevin.Connection.from_target(target)
# 5. a plan
plan = stevin.plan(project, target, conn, specs=specs)
print(plan.summary) # Plan: 1 add, 2 change, …
# 6. apply it
if not plan.empty:
run = stevin.apply(plan, conn, project=project, target=target)
print(run.ok, run.ran, run.failed_step)
# 7. or just ask whether anything moved
if not stevin.drift(project, target, conn).empty:
print("the workspace no longer matches the specs")
plan, drift and adopt take select= to narrow the work to some specs, and
read it the way the command line reads --select: a name as short as "orders"
or as full as "dev.sales.orders", a pattern like "sales.*", or a list of
them, in any case. One that matches no spec raises PlanningError — a typo is
not an empty plan. A function is taken as your own predicate over full names.
A bundle you have already resolved¶
A host that just deployed a Databricks Asset Bundle holds what
databricks bundle validate -o json -t <target> printed. Hand that over and
no subprocess runs:
Without it, stevin asks the CLI itself — DATABRICKS_CLI_PATH first, then
PATH (stevin.find_cli()). If the CLI is there and fails, that is a
BundleError carrying its words: a bundle that doesn't resolve has no names
to plan against. On a machine with no CLI at all, the bundle file stands in and
what only the CLI could have settled stays unknown, with the reason.
databricks is a shim?
On a machine that manages tools with shims, the databricks on PATH can
be mise, which the Databricks SDK's own databricks-cli authentication
then can't use. Point DATABRICKS_CLI_PATH at the real binary; stevin
and the SDK both follow it.
Connecting¶
conn = stevin.Connection.from_target(target) # the usual way
conn = stevin.Connection(client=my_client, warehouse_id="abc123")
conn = stevin.Connection(profile="dev", warehouse_id="abc123")
conn = stevin.Connection(runner=my_runner) # already runs SQL
A client you pass is the client stevin uses; it never makes a second one.
The warehouse is settled in one order, documented once: what you passed, then
the target's warehouse_id, then DATABRICKS_WAREHOUSE_ID, then the warehouse
a bundle's lookup: names. A failure here is NotConnected.
Reading a plan¶
A Plan is a frozen object, not a string to parse:
plan.empty, plan.is_destructive, plan.highest_risk
plan.summary.add, plan.summary.change, plan.summary.destroy, plan.summary.steps
plan.unmanaged # live objects no spec describes
plan.orphaned # stevin's own, whose spec is gone
plan.not_managed # what `manage:` hands to another tool
for diff in plan.diffs:
diff.table, diff.kind, diff.action # "table" / "view" / … , "create" / …
for step in diff.steps:
step.id, step.title, step.risk, step.sql, step.est_bytes, step.warnings
Render it the way you need: render_plan(plan, console) for a Rich console,
plan_text(plan) for a plain string, render_markdown(plan) for a pull-request
comment, plan_to_json(plan) / plan_from_json(text) for a file or a queue —
a plan made here can be applied somewhere else.
Applying¶
run = stevin.apply(
plan,
conn,
project=project,
target=target, # for where the run is recorded
allow_destructive=False,
observer=lambda step, status, note: print(step.id, status),
)
apply records every run in the project's history_schema; pass
history=stevin.MemoryHistory() in a test, or a store of your own. It takes
a lock per target, skips steps already true of the live table, and resumes a run
that stopped.
A project with no history_schema gets stevin.NoHistory(), which keeps
nothing and locks nothing — nothing is written outside the tables your specs
describe. What that costs is in the
safety model; the restore point a risky
step takes is then on the result:
run = stevin.apply(plan, conn, project=project, target=target)
for table, version in run.restore_points:
print(f"RESTORE TABLE {table} TO VERSION AS OF {version}")
for table, why in run.without_restore_point:
print(f"{table} was changed with no restore point: {why}")
A restore point that can't be taken — the table's version couldn't be read — doesn't
stop the run; the step's observer note and run.without_restore_point say so.
Before you ask a person to confirm, stevin.is_stale(plan, conn) says
whether the world has moved under the plan.
Importing what already exists¶
found = stevin.import_schema(conn, "main.sales", manage=project.manage)
for spec in found:
print(spec.filename, spec.relation.name)
write_somewhere(spec.text)
for name, reason in found.skipped:
print("left alone:", name, reason)
Nothing is written: where the specs go is yours. Owners are left out, as is
anything manage: hands to another tool.
Adopting drift¶
for adoption in stevin.adopt(project, target, conn):
print(adoption.path, *adoption.notes, sep="\n ")
adoption.write() # nothing is written until you say so
The other direction from apply: each Adoption carries the new text of a spec
file (before, after), what changed in it (notes), and what a plan would
still say afterwards (remaining). No workspace object is touched. select
narrows it the way it does for plan.
Verifying a workspace¶
for result in stevin.verify(conn, "main"):
print(result.mark, result.probe.name)
if not result.held:
print(" ", result.detail)
print(" ", result.probe.matters, result.probe.docs)
The assumptions every plan rests on, run against this workspace: stevin.PROBES
is the list, and each Result is held, differed or unknown. This one
writes — it makes a scratch schema, uses it and drops it — so a host that
runs it should say so first. Pass observer= to report each probe as it
finishes rather than waiting for the run.
Errors¶
Every error stevin raises on purpose descends from StevinError, so one
except reports and a subclass reacts:
| when | what a host does | |
|---|---|---|
SpecErrors |
specs that can't be read — all of them, at once | show the list |
BundleError |
the bundle doesn't resolve | show what the CLI said |
NotConnected |
no workspace, or no warehouse | check the profile or the id |
PlanningError |
the specs can't be planned together | show it |
IntrospectionError |
the workspace can't be read | retry, or show it |
StalePlan |
the world moved; .tables names it |
plan again |
DestructiveRefused |
it would destroy something; .tables names it |
ask a person |
ExecutionError |
anything else that stops a run | show it |
A step that fails while it is running doesn't raise: the result says which one
(run.failed_step, run.error), so the rest of the run stays on record.
NoHistory is not in this table: it was an error once, and is now the store a project
with no history_schema applies with.