Skip to main content

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

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for penrun 0.2.2
File Size Uploaded
penrun-0.2.2.tar.gz 14.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for penrun 0.2.2
File Interpreter ABI Platform
penrun-0.2.2-py3-none-any.whl Python 3 none any Details

Total release size: 34.2 kB

Release files / penrun-0.2.2.tar.gz

Download URL penrun-0.2.2.tar.gz
Size 14.4 kB
Tags Source
SHA-256 checksum
How to use checksums
62e7f6d0a00fa2beea55a0a7954bd4d53993e03cc188229cbb2da4a3a3ea1348
BLAKE2b-256 checksum
How to use checksums
885406faa4a524ba32793c5aa5caf647145b294bc1485fb160a5ea8016e7ad7a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.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 Jul 27, 2026.

Transparency log

Release files / penrun-0.2.2-py3-none-any.whl

Download URL penrun-0.2.2-py3-none-any.whl
Size 19.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f59cb8395ea32792d269d57ce5b4672789dec088460647d1892daffcab8096a4
BLAKE2b-256 checksum
How to use checksums
a124b3eede5b85f48c775ffe001b02d14025e2d2deb24fa55d9bff3ad522cd6a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.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 Jul 27, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.3

2 release files

This release

0.2.2 This release

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page