🔌 Backends
A backend runs run blocks for one or more languages. paulrun comes with two, for shell and Python, and other languages can be added by any package.
Run paulrun backends to see which backends are installed, the languages each one runs, and the package it comes from:
python python, py paulrun.backends.python:PythonBackend (paulrun 0.1.0)
shell sh, bash, shell paulrun.backends.shell:ShellBackend (paulrun 0.1.0)
Built-in backends
shell
Languages: sh, bash, shell. Needs bash.
Each block runs as one bash script that stops at the first command that fails, as if it began with set -eo pipefail. check finds syntax errors with bash -n, and adds shellcheck warnings if it's installed. See Shell blocks.
python
Languages: python, py. Needs nothing extra.
Each block runs as its own script, with the command in the runbook's python frontmatter key, or with the Python paulrun runs on. check finds syntax errors by compiling each block. See 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 without running it, and run() runs it. It doesn't need a base class; it only has to look like this protocol, from paulrun.backends:
class Backend(Protocol):
"""Runs `run` blocks for one or more languages.
Backends are classes registered under the `paulrun.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: Callable[[str], None],
) -> 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.
Don't print anything directly; paulrun 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 paulrun.backends (Problem, stream and Interrupted) and paulrun.inputs (PLACEHOLDER).
validate()
paulrun check calls validate() on every block in the backend's languages, and so does paulrun go, 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. paulrun turns them into lines in the runbook, so
checkpoints at the right place. - An error stops
gobefore anything runs. A warning is shown, and the run carries on. -
The code is as written, with
<NAME>placeholders still in it. Before checking it, swap each one for something your language accepts. The built-in backends use the placeholder's bare name, which is a plain word in shell and a plain name in Python:from paulrun.inputs import PLACEHOLDER script = PLACEHOLDER.sub(lambda match: match.group(1), code) -
Check that what
run()needs is installed.goalways validates first, so an error here is far friendlier thanrun()failing to start.
run()
run() gets:
code: the block with every placeholder replaced by its value. Secrets are never replaced; they're only inenv.frontmatter: the runbook's frontmatter, read-only, for a backend with settings of its own, like the Python backend'spythonkey.env: the block's complete environment, including every input. Pass it on as it is.cwd: the folder the runbook is in, where the block runs.output: call it with each line the block prints, as soon as it's printed.
It returns the block's exit code, where 0 means it succeeded and anything else stops the run.
A backend never prints. paulrun shows each line, replaces secrets with ****, and records it in the transcript, so a backend doesn't need to do any of that itself.
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[[str], None]) -> int:
It runs command, passes each line it prints to output as it's printed, and returns its exit code. It's what the built-in backends use, and it gets the details right:
- stdout and stderr are merged, so lines are shown in the order they were printed.
- The command shares paulrun's terminal, so it can still ask for a password the way
git,sshandgpgdo. - Ctrl-C works. On the first press, the command gets the chance to stop by itself, and anything it prints on the way out is still shown. A second press kills it, and everything it started. Either way, once it's stopped,
stream()raisesInterruptedwith its exit code, so the run can record how the block ended.
A backend that doesn't use stream() should do the same: once its block has stopped after Ctrl-C, raise Interrupted(exit_code). paulrun records the block's exit code, then finishes the run as interrupted.
Shipping your own backend
Anyone can publish a backend in their own package. Once it's installed alongside paulrun, it loads exactly like a built-in one.
Here's a complete backend for Node.js, in a package called paulrun-node:
paulrun-node/
pyproject.toml
paulrun_node/
__init__.py
paulrun_node/__init__.py:
import re
import shutil
import subprocess
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
from paulrun.backends import Problem, stream
from paulrun.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 paulrun.backends entry point group, which is how paulrun finds it:
[project]
name = "paulrun-node"
version = "0.1.0"
dependencies = ["paulrun>=0.1.0"]
[project.entry-points."paulrun.backends"]
node = "paulrun_node:NodeBackend"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Install it alongside paulrun:
uv tool install paulrun --with paulrun-node # once it's on PyPI
uv tool install paulrun --with ./paulrun-node # from a local folder, while you work on it
With pip, install it into the same environment as paulrun. Then paulrun backends lists it:
node js, javascript paulrun_node:NodeBackend (paulrun-node 0.1.0)
python python, py paulrun.backends.python:PythonBackend (paulrun 0.1.0)
shell sh, bash, shell paulrun.backends.shell:ShellBackend (paulrun 0.1.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, create it and call its methods directly, the way paulrun tests its own: call run() with output=lines.append and check the exit code and the lines, and call validate() on a broken block. See paulrun's backend tests.
Things to know
- Each language can only have one backend. If two installed backends claim the same language, paulrun stops with an error naming both.
paulrun backendsshows which languages are taken. - Frontmatter keys of your own get a
checkwarning. A backend can read its own settings fromfrontmatter, butcheckonly knows paulrun's keys, so it warns about any other. The warning doesn't stop a run.
Adding a backend to paulrun
A backend for a language lots of people use can ship with paulrun itself. Built-in backends load the same way as external ones, so it's the same code, in a different place:
paulrun/backends/<name>.py, implementing the protocol.shell.pyandpython.pyare good models: both usestream(), and swap placeholders before validating.-
One entry point line in paulrun's
pyproject.toml, next to the built-in ones:[project.entry-points."paulrun.backends"] shell = "paulrun.backends.shell:ShellBackend" python = "paulrun.backends.python:PythonBackend"Then run
make install, anduv run paulrun backendsshould list it. -
tests/backends/test_<name>.py, which really runs blocks intmp_path, with no mocking. Skip the tests if the language isn't installed, e.g.pytest.mark.skipif(shutil.which("ruby") is None, reason="ruby isn't installed"). CI runs on GitHub'subuntu-latest, which already has many languages; if yours isn't one of them, add a step to.github/workflows/ci.ymlthat installs it, so CI runs the tests rather than skipping them. - A section on this page, under Built-in backends: its languages, what it needs installed, how it runs a block, and how
checkvalidates one. - 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.