Skip to content

Repository files navigation

penrun

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.

Requirements

Python 3.14+, standard library only. No runtime dependencies.

Installation

$ uvx penrun -h

Without installing, uv run penrun also works straight out of a checkout.

Quick start: normal mode

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-compressed

LATEST 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.123456

It 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.

Interrupting a run

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.

Quick start: batch mode

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 -b

Pass -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.org

For 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 -J

Run penrun -h for the rest of the batch flags (-S, -u, ...).

Python API

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.

Configuration

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.

Development

$ just lint
$ just fmt
$ just test

About

A testrunner for arbitrary commands that archives test artifacts

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages