diff --git a/.gitignore b/.gitignore index 8d525d3..3e79360 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ env/ # Misc .DS_Store + +# pytest +.pytest_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e32d044 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Michael 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. diff --git a/README.md b/README.md index 0f8caf9..c727913 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ pip install . | `-t TITLE` | Plot title | | `-d DELIM` | Output column delimiter for uplot | | `--dry-run` | Print the `awk \| uplot` command without running it | +| `--uplot-args ARGS` | Extra arguments passed through to uplot (quote the string) | +| `--version` | Show version and exit | | `--help` | Show usage | ## Examples diff --git a/awkplot_cli.py b/awkplot_cli.py index 90143d7..3203107 100644 --- a/awkplot_cli.py +++ b/awkplot_cli.py @@ -5,14 +5,24 @@ """ import argparse +import os import shlex import shutil import signal import subprocess import sys +__version__ = "0.1.0" + PLOT_TYPES = ["hist", "bar", "line", "lineplot", "scatter", "density", "box", "count"] +# Flags that belong to awkplot and should not appear after the awk program +_AWKPLOT_FLAGS = { + "-p", "--plot", "-H", "--header", "-c", "--colors", + "-s", "--size", "-t", "--title", "-d", "--delimiter", + "--dry-run", "--uplot-args", "--version", +} + def build_parser(): p = argparse.ArgumentParser( @@ -34,6 +44,8 @@ def build_parser(): -t TITLE plot title (uplot --title) -d DELIM output column delimiter passed to uplot (uplot --delimiter) --dry-run print the awk | uplot command without running it + --uplot-args extra arguments passed through to uplot (quote the string) + --version show version and exit examples: awkplot -p hist '{print $3}' data.tsv @@ -69,6 +81,11 @@ def build_parser(): p.add_argument("--dry-run", dest="dry_run", action="store_true", help="print the command pipeline without executing") + p.add_argument("--uplot-args", dest="uplot_args", metavar="ARGS", + help="extra arguments passed through to uplot (quote the string)") + + p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + # ── positionals ─────────────────────────────────────────────────────────── p.add_argument("args", nargs=argparse.REMAINDER, help="awk program (first arg) then input files, or just files when -f is used") @@ -97,6 +114,20 @@ def check_deps(): sys.exit("awkplot: required tools not found on PATH:\n " + "\n ".join(missing)) +def _check_swallowed_flags(positionals): + """Error out if any positional looks like an awkplot flag that was + placed after the awk program (and therefore swallowed by REMAINDER).""" + for token in positionals: + if token in _AWKPLOT_FLAGS or (token.startswith("-") and not os.path.exists(token)): + # Heuristic: if a token starts with '-' and is not an existing + # file, it is almost certainly a misplaced flag. + sys.exit( + f"awkplot: unrecognised or misplaced option {token!r}\n" + " hint: all awkplot/uplot flags must come *before* the awk program\n" + " usage: awkplot [opts] 'awk program' [file ...]" + ) + + def build_awk_cmd(ns): cmd = ["awk"] if ns.field_sep is not None: @@ -109,14 +140,19 @@ def build_awk_cmd(ns): positionals = ns.args if ns.prog_files: # all positionals are input files + _check_swallowed_flags(positionals) cmd += positionals else: # first positional is the awk program if not positionals: - sys.exit("awkplot: awk program required as first positional argument\n" - " hint: awkplot [opts] 'awk program' [file ...]") - cmd.append(positionals[0]) - cmd += positionals[1:] + # Default to '{print}' so stdin-only invocations work + # (e.g. some_cmd | awkplot -p hist) + cmd.append("{print}") + else: + cmd.append(positionals[0]) + files = positionals[1:] + _check_swallowed_flags(files) + cmd += files return cmd @@ -137,6 +173,8 @@ def build_uplot_cmd(ns): cmd += ["--title", ns.title] if ns.delimiter: cmd += ["--delimiter", ns.delimiter] + if ns.uplot_args: + cmd += shlex.split(ns.uplot_args) return cmd @@ -155,25 +193,29 @@ def main(): # ── execute pipeline ─────────────────────────────────────────────────────── # Ignore SIGPIPE in the parent so closing the write end doesn't crash us. - signal.signal(signal.SIGPIPE, signal.SIG_IGN) + if hasattr(signal, "SIGPIPE"): + 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() + awk_output, _ = awk_proc.communicate() + awk_rc = awk_proc.returncode + + if awk_rc != 0: + sys.exit(awk_rc) + + if not awk_output or not awk_output.strip(): + sys.exit("awkplot: awk produced no output") - uplot_rc = uplot_proc.wait() - awk_rc = awk_proc.wait() + 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}") - # Surface the first non-zero exit code, awk takes priority. - if awk_rc != 0: - sys.exit(awk_rc) if uplot_rc != 0: sys.exit(uplot_rc) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_awkplot_cli.py b/tests/test_awkplot_cli.py new file mode 100644 index 0000000..f465e2e --- /dev/null +++ b/tests/test_awkplot_cli.py @@ -0,0 +1,224 @@ +"""Tests for awkplot_cli — build_awk_cmd, build_uplot_cmd, parse_size, and dry-run.""" + +import argparse +import subprocess +import sys +import types + +import pytest + +# Import the module under test +sys.path.insert(0, ".") +import awkplot_cli + + +# ─── helpers ────────────────────────────────────────────────────────────────── + +def _parse(argv): + """Parse *argv* through the real parser (no sys.argv mutation).""" + parser = awkplot_cli.build_parser() + return parser.parse_args(argv) + + +def _dry_run(argv): + """Run the CLI with --dry-run and return the printed pipeline string.""" + result = subprocess.run( + [sys.executable, "-m", "awkplot_cli", "--dry-run"] + argv, + capture_output=True, text=True, + ) + return result.stdout.strip(), result.returncode + + +# ─── parse_size ─────────────────────────────────────────────────────────────── + +class TestParseSize: + def test_valid(self): + assert awkplot_cli.parse_size("20:60") == ("20", "60") + + def test_valid_with_whitespace(self): + assert awkplot_cli.parse_size(" 15 : 80 ") == ("15", "80") + + def test_missing_width(self): + with pytest.raises(SystemExit): + awkplot_cli.parse_size("20:") + + def test_missing_height(self): + with pytest.raises(SystemExit): + awkplot_cli.parse_size(":60") + + def test_non_numeric(self): + with pytest.raises(SystemExit): + awkplot_cli.parse_size("abc:60") + + def test_too_many_colons(self): + with pytest.raises(SystemExit): + awkplot_cli.parse_size("10:20:30") + + +# ─── build_awk_cmd ──────────────────────────────────────────────────────────── + +class TestBuildAwkCmd: + def test_simple_program(self): + ns = _parse(["{print $1}", "data.tsv"]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "{print $1}", "data.tsv"] + + def test_field_separator(self): + ns = _parse(["-F", ",", "{print $1}", "data.csv"]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "-F", ",", "{print $1}", "data.csv"] + + def test_awk_vars(self): + ns = _parse(["-v", "x=10", "-v", "y=20", "{print x, y}"]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "-v", "x=10", "-v", "y=20", "{print x, y}"] + + def test_prog_file(self): + ns = _parse(["-f", "prog.awk", "data.tsv"]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "-f", "prog.awk", "data.tsv"] + + def test_no_program_defaults_to_print(self): + """Issue #1: when no program and no -f, default to {print}.""" + ns = _parse(["-p", "hist"]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "{print}"] + + def test_no_args_at_all_defaults_to_print(self): + """Bare invocation should also default to {print}.""" + ns = _parse([]) + cmd = awkplot_cli.build_awk_cmd(ns) + assert cmd == ["awk", "{print}"] + + +# ─── build_uplot_cmd ───────────────────────────────────────────────────────── + +class TestBuildUplotCmd: + def test_default_hist(self): + ns = _parse(["{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd == ["uplot", "hist"] + + def test_plot_type(self): + ns = _parse(["-p", "bar", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd == ["uplot", "bar"] + + def test_header(self): + ns = _parse(["-H", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert "--header" in cmd + + def test_colors(self): + ns = _parse(["-c", "red,blue", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd == ["uplot", "hist", "--color", "red", "--color", "blue"] + + def test_size(self): + ns = _parse(["-s", "20:60", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert "--height" in cmd and "--width" in cmd + h_idx = cmd.index("--height") + w_idx = cmd.index("--width") + assert cmd[h_idx + 1] == "20" + assert cmd[w_idx + 1] == "60" + + def test_title(self): + ns = _parse(["-t", "My Title", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd == ["uplot", "hist", "--title", "My Title"] + + def test_delimiter(self): + ns = _parse(["-d", ",", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd == ["uplot", "hist", "--delimiter", ","] + + def test_uplot_args_passthrough(self): + ns = _parse(["--uplot-args", "--nbins 30", "{print $1}"]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert "--nbins" in cmd + assert "30" in cmd + + def test_all_flags(self): + ns = _parse([ + "-p", "scatter", "-H", "-c", "cyan", "-s", "20:60", + "-t", "test", "-d", "\t", "{print $1, $2}", + ]) + cmd = awkplot_cli.build_uplot_cmd(ns) + assert cmd[1] == "scatter" + assert "--header" in cmd + assert "--color" in cmd + assert "--height" in cmd + assert "--title" in cmd + assert "--delimiter" in cmd + + +# ─── swallowed flags detection (issue #2) ──────────────────────────────────── + +class TestSwallowedFlags: + def test_flag_after_program_errors(self): + """Issue #2: flags after awk program should error, not be swallowed.""" + with pytest.raises(SystemExit): + ns = _parse(["{print $1}", "data.csv", "-p", "bar", "-t", "hi"]) + awkplot_cli.build_awk_cmd(ns) + + def test_flag_after_files_with_prog_file_errors(self): + """When -f is used, positionals that look like flags should error.""" + with pytest.raises(SystemExit): + ns = _parse(["-f", "prog.awk", "data.tsv", "--title", "oops"]) + awkplot_cli.build_awk_cmd(ns) + + +# ─── dry-run integration tests ─────────────────────────────────────────────── + +class TestDryRun: + def test_simple(self): + output, rc = _dry_run(["{print $1}", "data.tsv"]) + assert rc == 0 + assert "awk" in output + assert "{print $1}" in output + assert "data.tsv" in output + assert "uplot hist" in output + + def test_with_flags(self): + output, rc = _dry_run(["-p", "scatter", "-t", "test", "-s", "20:60", + "{print $1, $2}", "data.csv"]) + assert rc == 0 + assert "uplot scatter" in output + assert "--title" in output + assert "--height" in output + + def test_no_program_defaults_to_print(self): + """Issue #1: dry-run should work with no awk program.""" + output, rc = _dry_run(["-p", "hist", "-t", "test"]) + assert rc == 0 + assert "{print}" in output + + def test_field_sep(self): + output, rc = _dry_run(["-F", ",", "{print $1}", "data.csv"]) + assert rc == 0 + assert "-F" in output + + def test_prog_file(self): + output, rc = _dry_run(["-f", "prog.awk", "data.tsv"]) + assert rc == 0 + assert "-f" in output + assert "prog.awk" in output + + def test_uplot_args_passthrough(self): + output, rc = _dry_run(["--uplot-args", "--nbins 30", "{print $1}"]) + assert rc == 0 + assert "--nbins" in output + assert "30" in output + + +# ─── version flag ───────────────────────────────────────────────────────────── + +class TestVersion: + def test_version_flag(self): + result = subprocess.run( + [sys.executable, "-m", "awkplot_cli", "--version"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert awkplot_cli.__version__ in result.stdout