This tool was initially rescued from the gallia commit history and ported to Python.
penrun runs any command inside a well-defined, timestamped artifacts directory. Point it at a test,
scan, or anything else you invoke repeatedly, and every run gets its own directory with the command's
compressed output and exit code - so results are never overwritten and old runs stay reproducible.
Python 3.14+, standard library only. No runtime dependencies.
$ uvx penrun -hWithout installing, uv run penrun also works straight out of a checkout.
Run any command; penrun creates penrun-artifacts/<command>/run-<timestamp>/ for it:
$ penrun ls -lah
$ tree penrun-artifacts/ls
penrun-artifacts/ls
├── LATEST -> run-20260723-101334.123456
└── run-20260723-101334.123456
├── META.json # command, start/end time, exit code, environment (with -e), error (on failure)
└── OUTPUT.zst # combined stdout+stderr, zstd-compressedLATEST always points at the most recent run of that command, regardless of tags or where
artifacts_template places the timestamped directory (see below).
When the work is done, penrun reports where it went, on stderr, so the path is one copy-paste
away without reconstructing it from the template:
Artifacts directory: /home/you/project/penrun-artifacts/ls/run-20260723-101334.123456It names the directory the artifacts actually live in, which differs by mode: the run directory
itself in normal mode, and in batch mode the single run-<timestamp> directory the per-job
directories sit in — printed once when every job has finished, not once per job. It is printed
even when the command fails or times out, which is when you are most likely to want the log.
Ctrl-C stops the batch. No further jobs are scheduled; the jobs already running are given a
moment to wind down on their own (they sit in the same process group, so they received the
same SIGINT), and penrun then exits 130. A second Ctrl-C stops waiting and kills those
children outright — the escape hatch for a job that blocks or ignores SIGINT. Whatever ran
keeps its artifacts, and an interrupted job's META.json records "exit_code": null with an
error, the same shape a timeout produces.
penrun sets these environment variables for COMMAND:
PENRUN_COMMAND- the command as run, shell-quoted.PENRUN_ARTIFACTS- the absolute path of this run's artifacts directory.PENRUN_BATCH_MODE=1- only in batch mode.PENRUN_PWD_CONF/PENRUN_GIT_ROOT_CONF/PENRUN_USER_CONF- path of each config file found while searching (see Configuration), regardless of which one was actually used.
Run penrun -h for the full list of flags.
Batch mode (-b) reads a command - or command fragment - per line from stdin and runs each one,
sequentially by default (-j N for up to N in parallel):
$ printf 'true\nfalse\ntrue\n' | penrun -bPass -T with a template to turn plain input lines into full commands; {1} is replaced with the
line. Since every job below runs curl, add -N to name each job's directory after its input line
instead of the program name - otherwise they'd all land in the same curl/ directory:
$ printf 'example.com\nexample.org\n' | penrun -b -N -T 'curl -Lo {1}.html https://{1}'
$ tree -L 2 penrun-artifacts/curl
penrun-artifacts/curl
├── LATEST -> run-20260723-101334.123456
└── run-20260723-101334.123456
├── example.com
└── example.orgFor programmatic callers that already know each job's command and artifacts directory name, -J/--json
reads newline-delimited JSON objects instead of plain lines - one {"executable": "...", "args": [...], "dirname": "..."} per line (dirname is optional, falling back to executable's name):
$ printf '%s\n' \
'{"executable": "curl", "args": ["-Lo", "example.com.html", "https://example.com"], "dirname": "example.com"}' \
'{"executable": "curl", "args": ["-Lo", "example.org.html", "https://example.org"], "dirname": "example.org"}' \
| penrun -b -JRun penrun -h for the rest of the batch flags (-S, -u, ...).
penrun exposes the same functionality as an importable library, so other Python tools can drive it
without going through the CLI.
Single command - equivalent to penrun -t staging curl https://example.com:
import penrun
options = penrun.RunOptions(config=penrun.Config(artifacts_base="penrun-artifacts"), tag="staging")
penrun.run(["curl", "https://example.com"], options)Batch, every job running the same program - equivalent to the -J example above:
batch = penrun.Batch(
executable="curl",
shared_args=["-Lo"],
jobs=[
penrun.BatchJob(args=["example.com.html", "https://example.com"], dirname="example.com"),
penrun.BatchJob(args=["example.org.html", "https://example.org"], dirname="example.org"),
],
)
penrun.run_batched(batch, options, group_by_executable=True, schedule=penrun.ScheduleOptions(max_workers=2))Batch mixing completely different programs, by overriding executable on the jobs that don't run
Batch.executable:
batch = penrun.Batch(
executable="nmap",
jobs=[
penrun.BatchJob(args=["-oA", "scan", "example.com"], dirname="nmap"),
penrun.BatchJob(executable="curl", args=["-I", "https://example.com"], dirname="curl-head"),
],
)
penrun.run_batched(batch, options)Errors raise penrun.PenrunError subclasses (e.g. penrun.CommandNotFoundError, penrun.ConfigError,
each carrying a matching .exit_code) instead of exiting the process. If RunOptions.timeout/-w fires,
COMMAND is killed and Python's built-in TimeoutError is raised instead.
penrun looks for a config file in this order and stops at the first match: ./.penrun.toml,
$(git rev-parse --show-toplevel)/.penrun.toml, then ~/.config/penrun/config.toml. See
config.toml for all available keys with explanations.
$ just lint
$ just fmt
$ just test