Bodies, context & tools

A target's body is ordinary Python. It gets one argument, ctx, which is its line to the engine: every command, every tool call, every log line and every piece of state goes through it, so the engine can apply dry-run, redaction, timeouts and cancellation the same way it does for a TypeScript build.

Bodies

from zuke import Build, Context, run, target


async def build_wheel(ctx: Context) -> None:
    if ctx.dry_run:
        await ctx.log("dry run: nothing will be uploaded")
    result = await ctx.sh("uv", "build")
    await ctx.summary({"wheel": result.stdout.strip().splitlines()[-1]})


class App(Build):
    wheel = target().executes(build_wheel)                  # an async def
    hello = target().executes(lambda ctx: ctx.log("hi"))    # a lambda returning an awaitable
    count = target().executes(lambda ctx: None)             # or plain sync code

A body may be a function, an async def or a lambda, and whatever it returns is awaited when it is awaitable — so lambda ctx: ctx.sh(...) runs the command. A body fails the target by raising. The worker's own stdout and stderr become log lines, so a stray print cannot break the run, but await ctx.log(...) is the way to say something.

Running commands

from zuke import CommandError

async def body(ctx: Context) -> None:
    out = await ctx.sh("git", "rev-parse", "HEAD")           # ExecResult(code, stdout, stderr)
    sha = out.stdout.strip()
    await ctx.sh("pytest", "-q", cwd="tests", env={"CI": "1"})
    probe = await ctx.sh("pg_isready", check=False)          # no raise; read probe.code
    try:
        await ctx.sh("false")
    except CommandError as error:
        await ctx.log(f"{error.argv} failed with {error.result.code}")

ctx.sh(*argv) asks the engine to run a command. Arguments are discrete — there is no shell, so a value with spaces or quotes is one argument and nothing can be injected. It runs in the build's directory and environment (a uv run virtualenv included); cwd, env and stdin adjust one call. A non-zero exit raises CommandError, which carries the argv and the result; pass check=False to read the code yourself. Output is streamed to the log, captured into the ExecResult, and redacted of every secret.

The context

MemberWhat it gives you
await ctx.sh(*argv, ...)Run a command through the engine.
await ctx.log(line)A log line attributed to this target.
await ctx.secret(value)Redact a value from everything the run prints or records from now on.
await ctx.summary(pairs)Notes in this target's row of the build summary.
ctx.state, ctx.state_of(target)Durable state of this target, or another.
await ctx.outcome_of(target), await ctx.outcomes()What other targets did in this run.
await ctx.plan()The planned targets and each one's dependencies.
await ctx.signals()The external signals the run has received.
ctx.run_id, ctx.dry_run, ctx.targetThe run's id, whether it is a dry run, and this target's name.

A service's phases get the smaller ServiceContext: sh, log, secret and target. Every member is in the API reference.

Durable state

VERSION = "1.4.0"


async def deploy(ctx: Context) -> None:
    state = await ctx.state.get()                 # what this target last wrote
    if state.get("deployed") == VERSION:
        return
    await ctx.sh("./deploy.sh", VERSION)
    await ctx.state.set({"deployed": VERSION})    # merged, kept across runs and resumes

    build = await ctx.outcome_of("build")         # what another target did this run
    if build is not None and build.status == "succeeded":
        ...

The engine keeps each target's state in the run record, across runs and across a suspended run's resume. set merges a patch; try_set merges it only if the engine will record it now and says whether it did. See Durable run state.

Tool facades

Every typed wrapper the engine ships — Docker, Kubernetes, Helm, Terraform, the cloud CLIs, npm, Git, GitHub, the AI reviewers and the rest — has a module under zuke.tools, generated from the engine's own tool spec, so it matches the TypeScript wrapper setter for setter:

from zuke import Build, Context, run, target
from zuke.tools.docker import DockerTasks
from zuke.tools.git import GitTasks


async def image(ctx: Context) -> None:
    sha = (await GitTasks.rev_parse(ctx, lambda s: s.rev("HEAD").short())).stdout.strip()
    await DockerTasks.build(ctx, lambda s: s.tag(f"app:{sha}").file("Dockerfile").pull())
    await DockerTasks.push(ctx, lambda s: s.image(f"app:{sha}"))

A task takes the context first, then a settings lambda. The lambda records the setters it calls, and the facade sends them to the engine in one call; the engine runs the real wrapper with your build's environment and directory, under the run's dry-run, redaction and cancellation. Pyright and your editor complete every task and setter. The tool catalog lists the wrappers — a @zuke/docker there is zuke.tools.docker here — and the Python API reference has every facade.

Reach for a facade before ctx.sh: when a tool has a zuke.tools module, its typed settings, argv purity and tool resolution come with it.

Testing a build

run(App) does nothing on import, so a build file is an ordinary module your tests can import. Each target describes itself, which makes the graph checkable without running anything:

from build import App


def test_publish_runs_after_the_tests() -> None:
    assert App.publish.manifest()["dependsOn"] == ["test"]


def test_the_targets_are_declared() -> None:
    assert list(App.targets_) == ["lint", "test", "publish"]