🔌 Backends
A backend runs run blocks for one or more languages. rundot comes with two, for shell and Python, and other languages can be added by any package.
Run rundot backends to see which backends are installed, the languages each one runs, and the package it comes from:
python python, py rundot.backends.python:PythonBackend (rundot 0.2.0)
shell sh, bash, shell rundot.backends.shell:ShellBackend (rundot 0.2.0)
Built-in backends
| Backend | Languages | Needs | See |
|---|---|---|---|
| shell | sh, bash, shell |
bash |
Shell blocks |
| python | python, py |
nothing extra | Python blocks |
Writing a backend
A backend is a plain class with a name, the languages it runs, and two methods: validate() checks a block, and run() runs it. It doesn't need a base class, only to match this protocol from rundot.backends:
class Backend(Protocol):
"""Runs `run` blocks for one or more languages.
Backends are classes registered under the `rundot.backends` entry point group. Each is created
once, with no arguments, when backends are loaded.
"""
# Read-only, so a backend can set them as plain class attributes.
@property
def name(self) -> str: ...
@property
def languages(self) -> tuple[str, ...]:
"""The fence languages this backend runs, e.g. ("sh", "bash")."""
def validate(self, code: str) -> list[Problem]:
"""Return the problems found in a block, without running it. An empty list means it's fine.
code is the block as written, so it can contain <NAME> placeholders. Replacing them with
something this language accepts is up to the backend. Any error stops the runbook running;
warnings are only reported.
"""
def run(
self,
code: str,
*,
frontmatter: Mapping[str, Any],
env: Mapping[str, str],
cwd: Path,
output: Output,
) -> int:
"""Run a block and return its exit code, where 0 means it succeeded.
Call output with each line the block prints, without its newline, as soon as it's printed, and
stderr=True for a line it wrote to stderr.
Don't print anything directly; rundot owns the terminal. frontmatter is the runbook's
frontmatter, read-only. env is the block's complete environment and cwd is where it runs.
"""
Everything a backend needs is in rundot.backends (Problem, Output, stream and Interrupted) and rundot.inputs (PLACEHOLDER).
validate()
rundot check and rundot run call validate() on every block before anything runs. It returns a list of Problems:
@dataclass(frozen=True)
class Problem:
"""Something a backend found wrong with a block."""
message: str
line: int | None = None # line in the block, counting its first line as 1; None if it's about the whole block
warning: bool = False # warnings are reported but don't stop the runbook running
- Lines count from the block's first line, as 1. rundot turns them into lines in the runbook.
- An error stops the run; a warning is only shown.
-
The code still has its
<NAME>placeholders. Swap each for something your language accepts before checking it. The built-in backends use the bare name:from rundot.inputs import PLACEHOLDER script = PLACEHOLDER.sub(lambda match: match.group(1), code) -
Check that what
run()needs is installed, so a missing tool is reported before anything runs.
run()
run() gets:
code: the block with its placeholders filled in. Secrets are only inenv.frontmatter: the runbook's frontmatter, read-only, for settings of your own.env: the block's complete environment. Pass it on as it is.cwd: where the block runs, the folder rundot was started from.output: call it with each line the block prints, as it's printed. Passstderr=Truefor a line the block wrote to stderr, and rundot marks it with a red│.output(line)alone is fine too: the line is shown like stdout.
It returns the block's exit code. A backend never prints: rundot shows each line, masks secrets and records the transcript.
stream()
Most backends run a command, and stream() does all of that for you:
def stream(command: Sequence[str], *, env: Mapping[str, str], cwd: Path, output: Callable[..., None]) -> int:
It runs command, passes each line to output as it's printed, and returns the exit code. The built-in backends use it, because it gets the details right:
- stderr is marked. Each line the command writes to stderr goes to
outputwithstderr=True. stdout and stderr are read separately, so two lines written to them at almost the same moment can arrive in either order. Given anoutputthat only takes the line,stream()merges them instead, in the order they were printed. - The command shares rundot's terminal, so it can still ask for a password.
- Ctrl-C works. The first press lets the command stop by itself; a second kills it and everything it started. Then
stream()raisesInterruptedwith the exit code.
A backend that doesn't use stream() should also raise Interrupted(exit_code) once its block has stopped after Ctrl-C.
Shipping your own backend
Anyone can publish a backend in their own package, and once it's installed alongside rundot, it loads like a built-in one. Here's a complete backend for Node.js, in a package called rundot-node:
rundot-node/
pyproject.toml
rundot_node/
__init__.py
rundot_node/__init__.py:
import re
import shutil
import subprocess
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
from rundot.backends import Problem, stream
from rundot.inputs import PLACEHOLDER
class NodeBackend:
"""Runs each block as a Node.js script."""
name = "node"
languages = ("js", "javascript")
def validate(self, code: str) -> list[Problem]:
if shutil.which("node") is None:
return [Problem("node isn't installed")]
# Swap each placeholder for its bare name, which is a plain identifier, so the code still parses.
script = PLACEHOLDER.sub(lambda match: match.group(1), code)
result = subprocess.run(["node", "--check"], input=script, capture_output=True, text=True)
if result.returncode == 0:
return []
# node starts with where the error is, e.g. "[stdin]:3", and says what it is further down.
where = re.match(r"\[stdin\]:(\d+)", result.stderr)
errors = [line for line in result.stderr.splitlines() if line.startswith("SyntaxError: ")]
return [Problem(errors[0] if errors else result.stderr.strip(), line=int(where.group(1)) if where else None)]
def run(
self,
code: str,
*,
frontmatter: Mapping[str, Any],
env: Mapping[str, str],
cwd: Path,
output: Callable[[str], None],
) -> int:
return stream(["node", "-e", code], env=env, cwd=cwd, output=output)
pyproject.toml registers it under the rundot.backends entry point group:
[project]
name = "rundot-node"
version = "0.1.0"
dependencies = ["rundot>=0.1.0"]
[project.entry-points."rundot.backends"]
node = "rundot_node:NodeBackend"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Install it alongside rundot:
uv tool install rundot --with rundot-node # once it's on PyPI
uv tool install rundot --with ./rundot-node # from a local folder, while you work on it
With pip, install it into rundot's environment. Then rundot backends lists it:
node js, javascript rundot_node:NodeBackend (rundot-node 0.1.0)
python python, py rundot.backends.python:PythonBackend (rundot 0.2.0)
shell sh, bash, shell rundot.backends.shell:ShellBackend (rundot 0.2.0)
and runbooks can use it:
---
title: Hello from Node
---
## Say hello
```js run
console.log(`Hello from Node ${process.version}`);
```
To test a backend, call its methods directly: run() with output=lines.append, then check the exit code and lines (or output=lambda line, stderr=False: lines.append((line, stderr)) to check which stream each came from), and validate() on a broken block. See rundot's backend tests.
Things to know
- Each language can only have one backend. If two claim the same language, rundot stops with an error naming both.
- Frontmatter keys of your own get a
checkwarning, sincecheckonly knows rundot's keys. It doesn't stop a run.
Adding a backend to rundot
A backend for a widely used language can ship with rundot itself. It's the same code, in a different place:
rundot/backends/<name>.py, implementing the protocol.shell.pyandpython.pyare good models.-
One entry point line in rundot's
pyproject.toml, next to the built-in ones:[project.entry-points."rundot.backends"] shell = "rundot.backends.shell:ShellBackend" python = "rundot.backends.python:PythonBackend"Then run
make install, anduv run rundot backendsshould list it. -
tests/backends/test_<name>.py, which really runs blocks intmp_path, skipped if the language isn't installed, e.g.pytest.mark.skipif(shutil.which("ruby") is None, reason="ruby isn't installed"). If GitHub'subuntu-latestdoesn't have the language, add a step to.github/workflows/ci.ymlthat installs it. - A row in Built-in backends, and a section in the runbook format on how its blocks run.
- A CHANGELOG entry, in both
CHANGELOG.mdanddocs/CHANGELOG.md.
CONTRIBUTING.md has the rest: setting up, and what to run before opening a pull request.