Metadata-Version: 2.3
Name: penrun
Version: 0.2.3
Summary: Run any command inside a well-defined, timestamped artifacts directory.
Author: AISEC Pentesting Team
License: Apache-2.0
Maintainer: Stefan Tatschner, Tobias Specht
Maintainer-email: Stefan Tatschner <stefan.tatschner@aisec.fraunhofer.de>, Tobias Specht <tobias.specht@aisec.fraunhofer.de>
Requires-Python: >=3.14
Description-Content-Type: text/markdown

<!--
SPDX-FileCopyrightText: AISEC Pentesting Team

SPDX-License-Identifier: CC0-1.0
-->

# penrun

This tool was initially rescued from the gallia [commit history](https://github.com/Fraunhofer-AISEC/gallia/commit/985664905b6ef352db6fdc895af2ac79492539da) 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

```console
$ 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:

```console
$ 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](#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):

```console
$ 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:

```console
$ 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):

```console
$ 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`:

```python
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:

```python
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`:

```python
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`](config.toml) for all available keys with explanations.

## Development

```console
$ just lint
$ just fmt
$ just test
```
