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).
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
Metadata
Release files for penrun 0.2.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| penrun-0.2.3.tar.gz | 14.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| penrun-0.2.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 34.4 kB
Release files / penrun-0.2.3.tar.gz
| Download URL | penrun-0.2.3.tar.gz |
|---|---|
| Size | 14.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c9c1cd6b977edf4742509a8645d9eb726f366866139c267b96879a81bbfa88b0
|
|
BLAKE2b-256 checksum How to use checksums |
ad49b6b483fa8ff65a327646526704899e3636f6b385d694f1aa519aa9b727b6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logRelease files / penrun-0.2.3-py3-none-any.whl
| Download URL | penrun-0.2.3-py3-none-any.whl |
|---|---|
| Size | 19.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fceee7342283af22c518dbacd0c0887b528e957a8b4737fc37e0fc61d5c5acc5
|
|
BLAKE2b-256 checksum How to use checksums |
1c8934bb96e089ef93355ce2675ae9f55f1458df54c90d126f7376d04bb301d7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency log