Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Mike Tisza

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,16 @@ pip install .
| `-t TITLE` | Plot title |
| `-d DELIM` | Output column delimiter for uplot |
| `--dry-run` | Print the `awk \| uplot` command without running it |
| `--version` | Show the awkplot version |
| `--help` | Show usage |

If no awk program and no `-f` are given, awkplot defaults to `{print}`, so
`something | awkplot` works as a drop-in replacement for bare `uplot`
(as long as there's data on stdin). Any awkplot/uplot flags must come
*before* the awk program and input files; flags placed after them are
rejected with an error instead of being silently forwarded to `awk` as
bogus input files.

## Examples

```bash
Expand Down Expand Up @@ -111,3 +119,19 @@ awk [awk-flags] 'program' [files] | uplot <type> [uplot-flags]
```

`--dry-run` prints the shell-quoted pipeline so you can inspect or tweak it.

## Development

Run the unit tests with:

```bash
pip install pytest
pytest
```

The `examples/demo.sh` script doubles as a smoke test and requires `awk` and
`uplot` on `PATH`.

## License

MIT — see [LICENSE](LICENSE).
65 changes: 50 additions & 15 deletions awkplot_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
"""

import argparse
import os
import shlex
import shutil
import signal
import subprocess
import sys

PLOT_TYPES = ["hist", "bar", "line", "lineplot", "scatter", "density", "box", "count"]
DEFAULT_PROGRAM = "{print}"
__version__ = "0.1.0"


def build_parser():
Expand Down Expand Up @@ -68,6 +70,7 @@ def build_parser():

p.add_argument("--dry-run", dest="dry_run", action="store_true",
help="print the command pipeline without executing")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")

# ── positionals ───────────────────────────────────────────────────────────
p.add_argument("args", nargs=argparse.REMAINDER,
Expand Down Expand Up @@ -97,6 +100,23 @@ def check_deps():
sys.exit("awkplot: required tools not found on PATH:\n " + "\n ".join(missing))


def check_leftover_flags(positionals, start=0):
"""Reject positionals that look like flags awkplot doesn't know about.

argparse.REMAINDER stops option parsing at the first positional, so any
awkplot/uplot flags placed after the awk program (or after -f) would
otherwise be silently forwarded to awk as bogus input files. Fail loudly
instead of producing a plausible-looking but wrong plot.
"""
for tok in positionals[start:]:
if tok.startswith("-") and tok != "-" and not os.path.exists(tok):
sys.exit(
f"awkplot: unrecognized option {tok!r} found after the awk program/files\n"
" hint: awkplot flags must come before the awk program, "
"e.g. awkplot -p bar -t hi '{print $1}' data.csv"
)


def build_awk_cmd(ns):
cmd = ["awk"]
if ns.field_sep is not None:
Expand All @@ -109,12 +129,19 @@ def build_awk_cmd(ns):
positionals = ns.args
if ns.prog_files:
# all positionals are input files
check_leftover_flags(positionals)
cmd += positionals
else:
# first positional is the awk program
if not positionals:
elif not positionals:
# No program and no -f. Default to `{print}` so plain
# `something | awkplot` works, but only if there's something to
# read; otherwise there's nothing to plot.
if sys.stdin.isatty():
sys.exit("awkplot: awk program required as first positional argument\n"
" hint: awkplot [opts] 'awk program' [file ...]")
cmd.append(DEFAULT_PROGRAM)
else:
# first positional is the awk program
check_leftover_flags(positionals, start=1)
cmd.append(positionals[0])
cmd += positionals[1:]

Expand Down Expand Up @@ -154,26 +181,34 @@ def main():
check_deps()

# ── execute pipeline ───────────────────────────────────────────────────────
# Ignore SIGPIPE in the parent so closing the write end doesn't crash us.
signal.signal(signal.SIGPIPE, signal.SIG_IGN)

try:
awk_proc = subprocess.Popen(awk_cmd, stdout=subprocess.PIPE)
uplot_proc = subprocess.Popen(uplot_cmd, stdin=awk_proc.stdout)
# Let awk_proc receive SIGPIPE if uplot exits early.
awk_proc.stdout.close()

uplot_rc = uplot_proc.wait()
awk_rc = awk_proc.wait()

awk_output, _ = awk_proc.communicate()
awk_rc = awk_proc.returncode
except KeyboardInterrupt:
sys.exit(130)
except FileNotFoundError as e:
sys.exit(f"awkplot: {e}")

# Surface the first non-zero exit code, awk takes priority.
if awk_rc != 0:
sys.exit(awk_rc)

# uplot produces a confusing Ruby backtrace on empty input; fail clearly
# instead of leaking that upstream error to the user.
if not awk_output.strip():
sys.exit("awkplot: awk produced no output")

try:
uplot_proc = subprocess.Popen(uplot_cmd, stdin=subprocess.PIPE)
uplot_proc.communicate(input=awk_output)
uplot_rc = uplot_proc.returncode
except KeyboardInterrupt:
sys.exit(130)
except FileNotFoundError as e:
sys.exit(f"awkplot: {e}")
except BrokenPipeError:
uplot_rc = 0

if uplot_rc != 0:
sys.exit(uplot_rc)

Expand Down
4 changes: 2 additions & 2 deletions examples/demo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ echo "=== 2. Bar chart: letter frequencies in this script ==="
grep -o '[a-z]' "$0" \
| sort | uniq -c | sort -rn | head -10 \
| awk '{print $2, $1}' \
| "$AWKPLOT" -p bar -t "Top 10 letters" -H
| "$AWKPLOT" -p bar -t "Top 10 letters" -H -d ' '

echo
echo "=== 3. Scatter: y = x^2 + noise ==="
awk 'BEGIN { srand(7); for (x=1;x<=60;x++) print x, x*x + (rand()-0.5)*80 }' \
| "$AWKPLOT" -p scatter -s 20:60 -c cyan -t "y = x^2 + noise"
| "$AWKPLOT" -p scatter -s 20:60 -c cyan -t "y = x^2 + noise" -d ' '

echo
echo "=== 4. Line: simple sine wave ==="
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ awkplot = "awkplot_cli:main"

[tool.setuptools]
py-modules = ["awkplot_cli"]

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
169 changes: 169 additions & 0 deletions tests/test_awkplot_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Unit tests for awkplot_cli's pure-ish helper functions."""

import argparse
import subprocess
import sys
from pathlib import Path

import pytest

import awkplot_cli as cli

REPO_ROOT = Path(__file__).resolve().parent.parent
AWKPLOT_BIN = REPO_ROOT / "awkplot"


def make_ns(**overrides):
"""Build a namespace with build_parser's defaults, overridden as needed."""
ns = cli.build_parser().parse_args([])
for key, value in overrides.items():
setattr(ns, key, value)
return ns


# ── parse_size ────────────────────────────────────────────────────────────

def test_parse_size_valid():
assert cli.parse_size("20:60") == ("20", "60")


def test_parse_size_missing_colon():
with pytest.raises(SystemExit):
cli.parse_size("2060")


def test_parse_size_non_integer():
with pytest.raises(SystemExit):
cli.parse_size("20:sixty")


def test_parse_size_empty_part():
with pytest.raises(SystemExit):
cli.parse_size(":60")


# ── build_uplot_cmd ──────────────────────────────────────────────────────

def test_build_uplot_cmd_defaults():
ns = make_ns(plot_type="hist")
assert cli.build_uplot_cmd(ns) == ["uplot", "hist"]


def test_build_uplot_cmd_all_flags():
ns = make_ns(
plot_type="scatter",
header=True,
colors="red, blue",
size="20:60",
title="my title",
delimiter=",",
)
assert cli.build_uplot_cmd(ns) == [
"uplot", "scatter",
"--header",
"--color", "red",
"--color", "blue",
"--height", "20",
"--width", "60",
"--title", "my title",
"--delimiter", ",",
]


# ── build_awk_cmd ────────────────────────────────────────────────────────

def test_build_awk_cmd_basic_program():
ns = make_ns(args=["{print $1}", "data.csv"])
assert cli.build_awk_cmd(ns) == ["awk", "{print $1}", "data.csv"]


def test_build_awk_cmd_with_prog_file():
ns = make_ns(prog_files=["prog.awk"], args=["data.csv"])
assert cli.build_awk_cmd(ns) == ["awk", "-f", "prog.awk", "data.csv"]


def test_build_awk_cmd_forwards_field_sep_and_vars():
ns = make_ns(field_sep=",", awk_vars=["t=10"], args=["{print}"])
assert cli.build_awk_cmd(ns) == ["awk", "-F", ",", "-v", "t=10", "{print}"]


def test_build_awk_cmd_no_program_no_stdin_tty_errors(monkeypatch):
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
ns = make_ns(args=[])
with pytest.raises(SystemExit):
cli.build_awk_cmd(ns)


def test_build_awk_cmd_no_program_defaults_when_stdin_has_data(monkeypatch):
# Regression test for the demo.sh bug: `something | awkplot` with no
# awk program should default to `{print}` instead of erroring out.
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
ns = make_ns(args=[])
assert cli.build_awk_cmd(ns) == ["awk", cli.DEFAULT_PROGRAM]


def test_build_awk_cmd_rejects_leftover_flags_after_program():
# Regression test: flags placed after the awk program used to be
# silently forwarded to awk as bogus input files.
ns = make_ns(args=["{print $1}", "d.csv", "-p", "bar", "-t", "hi"])
with pytest.raises(SystemExit):
cli.build_awk_cmd(ns)


def test_build_awk_cmd_rejects_leftover_flags_with_prog_file():
ns = make_ns(prog_files=["prog.awk"], args=["data.csv", "--dry-run"])
with pytest.raises(SystemExit):
cli.build_awk_cmd(ns)


def test_build_awk_cmd_allows_existing_file_starting_with_dash(tmp_path, monkeypatch):
weird_file = tmp_path / "-weird.csv"
weird_file.write_text("1,2\n")
monkeypatch.chdir(tmp_path)
ns = make_ns(args=["{print}", "-weird.csv"])
assert cli.build_awk_cmd(ns) == ["awk", "{print}", "-weird.csv"]


# ── CLI-level (dry-run) smoke tests ──────────────────────────────────────

def run_cli(args):
return subprocess.run(
[str(AWKPLOT_BIN), *args],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)


def test_cli_dry_run_default_program_with_stdin():
result = subprocess.run(
[str(AWKPLOT_BIN), "--dry-run", "-p", "hist"],
input="1\n2\n3\n",
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert result.returncode == 0
assert result.stdout.strip() == "awk '{print}' | uplot hist"


def test_cli_dry_run_leftover_flags_error_out():
result = run_cli(["--dry-run", "{print $1}", "d.csv", "-p", "bar", "-t", "hi"])
assert result.returncode != 0
assert "unrecognized option" in result.stderr


def test_cli_dry_run_basic():
result = run_cli(["--dry-run", "-F,", "-p", "scatter", "-c", "red",
"-s", "20:60", "-H", "{print $2,$5}", "data.csv"])
assert result.returncode == 0
assert result.stdout.strip() == (
"awk -F , '{print $2,$5}' data.csv | "
"uplot scatter --header --color red --height 20 --width 60"
)


def test_cli_version():
result = run_cli(["--version"])
assert result.returncode == 0
assert "awkplot" in result.stdout