Skip to content

Repository files navigation

md-babel-py

Execute code blocks in markdown files and insert the results.

Demo

Use cases:

  • Keep documentation examples up-to-date automatically
  • Validate code snippets in docs actually work
  • Generate diagrams and charts from code in markdown
  • Literate programming with executable documentation

Languages

Shell

echo "cwd: $(pwd)"
cwd: /work

Python

a = "hello world"
print(a)
hello world

Sessions preserve state between code blocks:

print(a, "again")
hello world again

Node.js

console.log("Hello from Node.js");
console.log(`Node version: ${process.version}`);
Hello from Node.js
Node version: v22.21.1

Matplotlib

import matplotlib.pyplot as plt
import numpy as np
plt.style.use('dark_background')
x = np.linspace(0, 4 * np.pi, 200)
plt.figure(figsize=(8, 4))
plt.plot(x, np.sin(x), label='sin(x)', linewidth=2)
plt.plot(x, np.cos(x), label='cos(x)', linewidth=2)
plt.xlabel('x')
plt.ylabel('y')
plt.legend()
plt.grid(alpha=0.3)
plt.savefig('{output}', transparent=True)

output

Pikchr

SQLite's diagram language:

color = white
fill = none
linewid = 0.4in

# Input file
In: file "README.md" fit
arrow

# Processing
Parse: box "Parse" rad 5px fit
arrow
Exec: box "Execute" rad 5px fit

# Fan out to languages
arrow from Exec.e right 0.3in then up 0.4in then right 0.3in
Sh: oval "Shell" fit
arrow from Exec.e right 0.3in then right 0.3in
Node: oval "Node" fit
arrow from Exec.e right 0.3in then down 0.4in then right 0.3in
Py: oval "Python" fit

# Merge back
X: dot at (Py.e.x + 0.3in, Node.e.y) invisible
line from Sh.e right until even with X then down to X
line from Node.e to X
line from Py.e right until even with X then up to X
Out: file "README.md" fit with .w at (X.x + 0.3in, X.y)
arrow from X to Out.w

output

Asymptote

Vector graphics:

import graph;
import stats;

size(400,200,IgnoreAspect);
defaultpen(white);

int n=10000;
real[] a=new real[n];
for(int i=0; i < n; ++i) a[i]=Gaussrand();

draw(graph(Gaussian,min(a),max(a)),orange);

int N=bins(a);

histogram(a,min(a),max(a),N,normalize=true,low=0,rgb(0.4,0.6,0.8),rgb(0.2,0.4,0.6),bars=true);

xaxis("$x$",BottomTop,LeftTicks,p=white);
yaxis("$dP/dx$",LeftRight,RightTicks(trailingzero),p=white);

output

Graphviz

A -> B -> C
A -> C

output

OpenSCAD

cube([10, 10, 10]);
sphere(r=7);

output

Diagon

ASCII art diagrams:

1 + 1/2 + sum(i,0,10)
        10   
        ___  
    1   ╲    
1 + ─ + ╱   i
    2   ‾‾‾  
         0
A -> B -> C
A -> C
┌───┐
│A  │
└┬─┬┘
 │┌▽┐
 ││B│
 │└┬┘
┌▽─▽┐
│C  │
└───┘

Install

Nix (recommended)

# Run directly from GitHub
nix run github:leshy/md-babel-py -- run README.md --stdout

# Or clone and run locally
nix run . -- run README.md --stdout

Docker

# Pull from Docker Hub
docker run -v $(pwd):/work lesh/md-babel-py:main run /work/README.md --stdout

# Or build locally via Nix
nix build .#docker     # builds tarball to ./result
docker load < result   # loads image from tarball
docker run -v $(pwd):/work md-babel-py:latest run /work/file.md --stdout

pipx

pipx install md-babel-py
# or: uv pip install md-babel-py
md-babel-py run README.md --stdout

If not using nix or docker, evaluators require system dependencies:

Language System packages
python python3
node nodejs
dot graphviz
asymptote asymptote, texlive, dvisvgm
pikchr pikchr
openscad openscad, xvfb, imagemagick
diagon diagon
# Arch Linux
sudo pacman -S python nodejs graphviz asymptote texlive-basic openscad xorg-server-xvfb imagemagick

# Debian/Ubuntu
sudo apt-get install python3 nodejs graphviz asymptote texlive xvfb imagemagick openscad

Note: pikchr and diagon may need to be built from source. Use Docker or Nix for full evaluator support.

Usage

# Edit file in-place
md-babel-py run document.md

# Output to separate file
md-babel-py run document.md --output result.md

# Print to stdout
md-babel-py run document.md --stdout

# Only run specific languages
md-babel-py run document.md --lang python,sh

# Only run blocks matching some text (like pytest's -k)
md-babel-py run document.md -k "session=plot"

# Re-run automatically whenever the file changes
md-babel-py run document.md --watch

# Dry run - show what would execute
md-babel-py run document.md --dry-run

# Longer limit for each isolated subprocess (default 60s; useful for CI or large downloads)
md-babel-py run document.md --execution-timeout 120

Isolated evaluators (non-session blocks) run each snippet in a subprocess and stop it after this many seconds. Session-based blocks use separate timeouts inside the session.

Selecting blocks with -k

-k TEXT runs only the blocks whose text contains TEXT, case-insensitively. It searches the code, the language, the session name (as plot or session=plot), and params (as output=diagram.svg).

Selection applies to execution, so a session block picked out on its own will not have the state its earlier blocks would have built.

Watching

--watch runs the file (or directory, with --recursive) and then keeps running it whenever it changes, until interrupted:

md-babel-py run docs/ --recursive --watch
md-babel-py run document.md --watch --watch-interval 0.5   # default is 1s

Changes are detected by hashing file contents, not by mtime or inode, so a save-via-rename from an editor, a cp -p, and a git checkout all register. md-babel's own rewrite of the file it just ran does not count as a change, so watching does not loop. A file that is still being written is left alone until its contents stop changing.

Writing results

When editing a file in place, each block's result is written as soon as that block finishes, so a long document fills in as it runs instead of all at once at the end.

Every write re-reads the file first and applies the result to the document as it stands, so edits made while the run is in progress are kept. If a block itself is edited while it is running, the result it was computing is dropped rather than written over the new code. Under --watch, a file edited mid-run is processed again instead of having that edit absorbed.

--stdout and --output are unaffected: they produce one document at the end.

Caching

Block results are cached by default (in $XDG_CACHE_HOME/md-babel); pass --no-cache to re-execute everything.

An isolated block is keyed by its own code and evaluator config. A session block is keyed by the chain of blocks before it in that session, so a session whose blocks are all unchanged is served entirely from cache without ever starting the interpreter. Editing a block invalidates that block and every block after it in the same session; other sessions in the file are unaffected.

Because a block that misses needs the REPL state its predecessors built, any session containing a miss is re-executed in full.

Code Block Syntax

```python session=main
x = 42
```

Flags

Flag Description
session=NAME Share state with other blocks using the same session name
output=PATH Write output to file (for images/diagrams)
expected-error Expect this block to fail; test fails if it succeeds
skip Don't execute this block
no-result Execute but don't insert result block
fold Wrap code in collapsible <details> (uses language as summary)
fold="TEXT" Wrap code in collapsible <details> with custom summary

Result Placement

Results are inserted after the code block. Use the fold flag to automatically wrap code in a collapsible <details> element:

```python fold
print("hello")
```

This produces:

<details><summary>Python</summary>

```python fold
print("hello")
```

</details>

```results
hello
```

For manual control, if a code block is inside a <details> tag, the result is placed after </details>:

<details>
<summary>diagram source</summary>

```pikchr output=diagram.svg
box "Hello"
```

</details>

![output](diagram.svg)

Custom Parameters

Any key=value pair becomes a parameter for the evaluator command:

```diagon mode=GraphDAG
A -> B
```

With config "defaultArguments": ["{mode}"], the {mode} placeholder is replaced with GraphDAG.

GitHub Action

- uses: leshy/md-babel-py@main
  with:
    files: 'README.md docs/*.md'
Input Description Default
files Markdown files to process (glob patterns) required
args Additional arguments ''
fail-on-change Fail if files were modified (CI check) false

Example with auto-commit:

- uses: leshy/md-babel-py@main
  with:
    files: '*.md docs/**/*.md'

- uses: stefanzweifel/git-auto-commit-action@v5
  with:
    commit_message: 'Update markdown code block results'

Configuration

Create config.json in your project or ~/.config/md-babel/config.json:

{
  "evaluators": {
    "codeBlock": {
      "python": {
        "path": "/usr/bin/env",
        "defaultArguments": ["python3"],
        "session": {
          "command": ["python3", "-i"],
          "prompts": [">>> ", "... "]
        }
      }
    }
  }
}

File-based Evaluators

For tools that use input/output files:

{
  "openscad": {
    "path": "xvfb-run",
    "defaultArguments": ["-a", "openscad", "-o", "{output_file}", "{input_file}"],
    "inputExtension": ".scad"
  }
}

Development

direnv Setup

Two .envrc files are provided:

File Description
.envrc.nix Nix flake devShell (all evaluators + dev tools)
.envrc.venv Python venv only
ln -s .envrc.nix .envrc
direnv allow

Nix Development Shell

nix develop
# Provides: md-babel-py, pytest, mypy, ruff, and all evaluators

Manual Setup

pip install -e ".[dev]"
pytest tests/ -v
mypy md_babel_py/
ruff check md_babel_py/

Nix Packages

Package Description
default Full package with all evaluators in PATH
minimal Just Python package, no bundled evaluators
docker Docker image tarball

License

MIT

About

python org-babel like system for markdown files

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages