Driving dirigent from Python¶
dirigent-client is the typed async SDK. It is the same package the CLI runs on, and it owns
the request and response schemas the server imports, so a shape the client parses is the shape
the server writes -- there is one definition of each, and it cannot drift.
The package depends on httpx2, pydantic, pyyaml, and dirigent-common, and on nothing
else in the workspace. Installing it does not install the engine or the server.
The four lines¶
from datetime import timedelta
from dirigent_client import Dirigent
async with Dirigent(url=DG_URL, token=DG_TOKEN) as dg:
plan = await dg.pipelines.apply(document, dry_run=True)
run = await dg.pipelines.run("daily-climate-load", params={"day": "2026-08-29"})
final = await dg.runs.wait(run.run_id, timeout=timedelta(hours=2))
async for entry in dg.runs.follow_logs(run.run_id):
print(entry.message)
document is whatever you have: a parsed mapping, a Path, or the YAML text itself.
Authenticating¶
Every route under /api/v1 requires a principal. For a program, that is an API token:
import os
from dirigent_client import Dirigent
dg = Dirigent(url=os.environ["DG_URL"], token=os.environ["DG_TOKEN"])
Mint the token with dg admin token create ci or await dg.admin.tokens.create("ci"); it is
shown once. An admin mints one for somebody else with
await dg.admin.users.create_token("ci-bot", "ci"). See the token lifecycle.
A browser has a second option -- dg.auth.login(username, password) sets an http-only
dirigent_session cookie, good for fourteen days. It exists for the UI. A program should hold
a token: it can be named, listed, and revoked by name, and a run it starts is attributed to
the token as well as to the user. When both a header and a cookie are present, the header wins.
The client takes a URL and a token and nothing else. Profiles, DG_URL, DG_TOKEN, and
~/.config/dirigent/profiles.yaml are the CLI's business; a program decides for itself where
its credentials come from.
The accessors¶
One per API resource, and the method names are the CLI's verbs.
| Accessor | What it reaches |
|---|---|
dg.pipelines |
list, get, apply, prune, versions, export, validate, activate, deactivate, delete, run, backfill |
dg.runs |
list, get, items, attempts, artifacts, artifact_text, cancel, retry, logs, report, report_document, wait, follow_logs, events |
dg.schedules |
list, get, create, update, pause, resume, firings, delete |
dg.webhooks |
list, get, create, rotate_token, enable, disable, deliveries, delete |
dg.trigger_documents |
list, get, delete |
dg.alerts |
rules, create_rule, update_rule, set_rule_paused, delete_rule, test, notifications, notification, retry |
dg.connections |
list, get, create, update, delete, check |
dg.schemas |
list, get, create, update, delete |
dg.blocks |
catalog, get |
dg.examples |
list, get |
dg.workers |
list |
dg.system |
info, health, ready |
dg.auth |
login, logout, whoami, change_password |
dg.admin |
users.list, users.create, users.update, users.deactivate, users.activate, users.reset_password, users.tokens, users.create_token, users.revoke_token, tokens.list, tokens.create, tokens.revoke |
Anything addressable is addressed by its code, which is the first positional argument
wherever one is taken: dg.pipelines.get("daily-load"),
dg.connections.create("modelling-api", kind="http", ...),
dg.schedules.create("daily-load", "nightly", ...). The human name and the description
are keyword arguments on every create and update that has them, and apply takes
code= to register a document under a code other than its own.
Every one of them returns a pydantic model from dirigent_client: PipelineOut, RunDetail,
ApplyResult, Catalog, and so on. They are importable from the top level, and
model_dump(mode="json") renders any of them back into the JSON the server sent.
Pages¶
Every listing answers a Page: items, and a next cursor that is None at the end.
page = await dg.runs.list(limit=100)
while True:
for run in page.items:
print(run.id, run.status.value)
if page.next is None:
break
page = await dg.runs.list(limit=100, after=page.next)
after is opaque -- pass back what next gave you and nothing else. limit runs from 1 to
500 and defaults to 50. Selection is keyset rather than offset, so a run started between two
pages neither pushes a row off the end nor arrives twice.
Applying and running¶
apply takes a whole dirigent/v1 document and either reports a plan or commits a version:
result = await dg.pipelines.apply("pipelines/daily-load.yaml", dry_run=True)
if not result.plan.ok:
for issue in result.plan.issues:
print(f"{issue.location}: {issue.message}")
A document this instance cannot run comes back as a 200 with action == "invalid", not as
a refusal, because the plan is the answer. result.plan.ok is the check; the other three
actions are create, update, and unchanged. unchanged means the digest matched and
nothing was written, which is what makes applying a whole repository on every merge idempotent.
Starting a run has a second non-error outcome:
accepted = await dg.pipelines.run("daily-load", params={"day": "2026-08-29"})
if accepted.run_id is None:
print(accepted.detail) # the concurrency policy declined a second concurrent run
runs.wait polls until the run reaches a status nothing will move it out of, widening the
interval while the run has nothing to say, and raises WaitTimeout when the budget runs out.
It never cancels the run; runs.cancel is a separate decision.
Error handling¶
Every refusal is a DirigentError carrying status, url, the parsed problem, and the
three the problem is read through: code, the dotted code of the catalogued message the
sentence was rendered from; params, what that message interpolated; and problems, the
Issue list when the refusal is a list of failures rather than one. Branch on code rather
than on the English, which is rewritten whenever a better sentence is found.
| Exception | When |
|---|---|
Unauthorized |
401: no credential, or one this instance does not accept |
Forbidden |
403: the credential is valid and does not permit this |
NotFound |
404: this instance holds no such thing |
Conflict |
409: the current state does not allow it |
ValidationFailed |
422: the content was refused, field by field, in problems |
RateLimited |
429: refused for now; retry_after when the server said how long |
ServerError |
5xx: the instance failed; its own log has the detail |
TransportError |
Nothing answered at that URL |
NotDirigent |
Something answered, and it was not a dirigent instance |
WaitTimeout |
runs.wait gave up; the run was left alone |
from dirigent_client import NotFound, ValidationFailed
try:
await dg.pipelines.run("daily-load", params=params)
except ValidationFailed as refusal:
for issue in refusal.problems:
print(issue.code, issue.location, issue.message)
except NotFound as refusal:
print(refusal.code, refusal.message)
An Issue is code, message, params, and a location where the failure is addressed at
a place -- params.day, steps.load.config -- so a caller reports where a document is wrong
without parsing a sentence for it.
NotDirigent is worth handling separately: nothing is wrong with the request, the URL is
pointing at the wrong thing. The client decides it by the absence of X-Dirigent-Version,
which a dirigent instance puts on every response, errors included.
Retries are automatic and narrow: a transport failure or a 5xx is retried with exponential
backoff, and only for idempotent methods. A POST or a PATCH is never repeated, because it
may have taken effect before the connection broke, and a repeat would mean a second run, a
second token, a second account.
Streaming logs¶
async for entry in dg.runs.follow_logs(run_id, step="load"):
print(f"{entry.level.value:<7} {entry.step_name or '-':<12} {entry.message}")
The tail is server-sent events, and the server closes one two ways. end means the run is
terminal and there is nothing more to read. expired means the stream reached the server's
wall-clock limit with the run still going: the client reopens it from the id of the last entry
it yielded and keeps going, so following a run for a day is the same call as following one for
a minute. A stream that closes saying neither was cut -- a proxy's idle timeout, a restarted
server -- and is reopened the same way, which is why a reconnection neither loses a line nor
repeats one. reconnects= bounds how many cut streams and transport failures it will absorb;
an expiry is not one of them, and an entry delivered restores the budget in full.
For a page rather than a stream, runs.logs takes after, limit, and step, and answers
the same Page every other listing does. next is null when there was nothing past this page,
which is the only end condition: a run still being written to will have more later.
Following a whole run¶
async for event in dg.runs.events(run_id):
match event:
case AttemptEvent():
print(f"{event.step_name} {event.item or '':<10} {event.status.value}")
case LogEntryOut():
print(f" {event.message}")
case RunOut():
print(f"run {event.status.value}")
runs.events is one stream carrying three things in the order they happened: an AttemptEvent
for every state an attempt reaches, a LogEntryOut for every line, and one RunOut when the
run settles, which is the last thing sent before end. expired and a cut stream are reopened
here exactly as they are for a log tail. Every attempt is replayed in its current state when
the stream opens and again after a reconnection, so a consumer keys on what it has already seen
rather than on how many times it read it; the log cursor resumes where it left off, so a line
is never repeated. An AttemptEvent is an AttemptOut plus item, the element a fan-out
attempt ran for, so nothing has to read the run's item grid to label a line.
A CI gate¶
Apply a document and run it, exiting non-zero when the run does not succeed:
import asyncio
import os
import sys
from datetime import timedelta
from pathlib import Path
from dirigent_client import Dirigent, PlanAction, RunStatus, WaitTimeout
async def gate(document: Path) -> int:
"""Apply, run, wait, and report; the return value is the job's exit code."""
async with Dirigent(url=os.environ["DG_URL"], token=os.environ["DG_TOKEN"]) as dg:
applied = await dg.pipelines.apply(document)
if applied.plan.action is PlanAction.INVALID:
for issue in applied.plan.issues:
print(f"::error::{issue}")
return 1
accepted = await dg.pipelines.run(applied.plan.code)
if accepted.run_id is None:
print(f"::warning::not started: {accepted.detail}")
return 0
try:
run = await dg.runs.wait(accepted.run_id, timeout=timedelta(hours=2))
except WaitTimeout as timed_out:
print(f"::error::{timed_out.message}")
return 1
for step in (await dg.runs.report(run.id)).steps:
print(f" {step.outcome:<10} {step.step:<24} {step.error or ''}")
if run.status is RunStatus.SUCCEEDED:
return 0
print(f"::error::run {run.id} {run.status.value}")
return 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(gate(Path(sys.argv[1]))))
- run: uv run python ci_gate.py pipelines/daily-load.yaml
env:
DG_URL: ${{ vars.DG_URL }}
DG_TOKEN: ${{ secrets.DG_TOKEN }}
Note the two outcomes that are not failures: a skipped run means the pipeline's concurrency
policy declined to start a second one, and completed_with_errors means a step failed inside
a continue-ruled branch. Deciding which of those a build should fail on is the gate's job,
not the API's.
RunStatus.COMPLETED_WITH_ERRORS is deliberately not in the accepted set above. The CLI makes
the same distinction with --strict.
More worked examples¶
examples/python/ holds six runnable scripts -- apply-and-run, log following, filtering,
connections, error handling, and the CI gate -- each a single file with the command that runs
it in its header. They are executed against a real instance in CI, so they cannot rot.
Synchronous callers¶
The SDK is async. There is no second set of synchronous methods, because that would mean two definitions of every signature to keep in step. What there is instead is a bridge:
from dirigent_client import BlockingDirigent
with BlockingDirigent(url=DG_URL, token=DG_TOKEN) as dg:
pipelines = dg.call(dg.pipelines.list())
for entry in dg.iterate(dg.runs.follow_logs(run_id)):
print(entry.message)
call runs one of the async accessors' coroutines on a private event loop in a background
thread and hands back the result; iterate does the same for an async iterator. The
accessors are the same objects, so nothing is duplicated. This is what dg itself uses.
Testing a block¶
dirigent-testing is the other package written for people outside this repository. It is a
pytest plugin, so installing it is the whole setup -- the fixtures arrive by entry point and
no conftest has to re-export anything.
from dirigent_testing import call_block
async def test_it_greets(block_ctx):
output = await call_block(Greet(), {"name": "ada"}, block_ctx)
assert output.greeting == "hello ada"
assert block_ctx.log.messages() == ["greeting"]
call_block validates the config against the block's own config_model first, the way the
engine does, and then makes the block's first call: an operator's execute or a sensor's
poke. block_ctx is a FakeContext -- a recording logger, real storage over a temporary
directory, an HTTP client built on a handler the test installs, and an in-memory runs facade.
defaults_only_environment is the fixture that takes DIRIGENT_*, DG_* and OTEL_* out of
one test's environment; request it from your own conftest to run a suite against defaults.
no_connection_outlives_its_loop fails a test that ends with a database connection it opened
still open, which otherwise surfaces much later as a RuntimeError raised in a thread against
some unrelated test; one line in your conftest holds a whole suite to it:
@pytest.fixture(autouse=True)
def _no_connection_outlives_its_loop(no_connection_outlives_its_loop): ...
A block's contract has one more method worth testing directly. check_config(config) returns
the extra refusals the block makes at apply, beyond what its schema already says -- a program
that does not compile, a format pair it cannot convert between -- as a list of Issue. It
defaults to returning nothing, so a block that has no such refusals implements nothing. The
instance calls it while applying a document, against the step's validated config, and shows
each issue at steps.<name>.config, which is why it is worth an assertion of its own:
def test_it_refuses_a_program_it_cannot_compile():
config = Recase.config_model.model_validate({"input": "ada", "program": "sideways"})
refused = Recase().check_config(config)
assert [issue.code for issue in refused] == ["recase.not_a_case"]
assert refused[0].message == "'sideways' is not a case: write upper or lower"
A pack asserts on two more: assert_contribution_conforms(contribution) lists the ways its
blocks are malformed, and check_pack_examples(contribution, examples_dir) lists everything
wrong with its example documents read against its own catalog -- an empty list from each is
what its CI expects.
The transform frames build on exactly this, and the transform page is where writing an engine is taught.
The REST API is still there¶
Nothing about the SDK closes off plain HTTP. /api/v1 is the stable surface, /openapi.json
is its complete reference and is unauthenticated, and /docs renders it. Every operation
carries a stable operation_id -- applyPipeline, runPipeline, getRun, getRunLogs --
so a generated client does not rename everything when an unrelated route is added:
Two things worth knowing if you go that way. Every error response has one shape:
{
"status": 422,
"title": "Unprocessable Content",
"detail": "body.priority: Input should be 'low', 'normal' or 'high'",
"code": "server.request_invalid",
"params": {"detail": "body.priority: Input should be 'low', 'normal' or 'high'"},
"problems": [
{
"code": "validation.enum",
"message": "Input should be 'low', 'normal' or 'high'",
"params": {"loc": ["body", "priority"], "input_kind": "str", "msg": "Input should be 'low', 'normal' or 'high'"},
"location": "body.priority"
}
],
"instance": "/api/v1/pipelines/daily-load/$run"
}
status, title, detail, code, params, problems and instance are the whole of it,
on every error response. detail is the one sentence to show a person and code is the
stable dotted name to branch on; problems is the list when a refusal is several failures,
each an issue with a code, a message, its own params, and a location when it is
addressed at a place.
And a $ in a path is deliberate: $apply, $run, $logs, $cancel, $check are actions
rather than resources, and the sigil is what keeps them from ever colliding with a code. It
needs no escaping in a URL.
Every list endpoint takes limit and after, and answers the same {"items": [...], "next":
...} envelope. limit runs from 1 to 500 and defaults to 50, except GET /runs/{id}/$logs,
which defaults to 200. There is no offset and no total on any of them: selection is keyset,
and after carries back the opaque cursor the previous page's next gave out. A cursor that
does not parse is a 422. See known debt.