From ccdf1bb81f18ba9f6fa5e90862dface8216373a7 Mon Sep 17 00:00:00 2001 From: "Simon A. F. Lund" Date: Fri, 23 Oct 2026 11:59:33 +0200 Subject: [PATCH] feat(cli): group the report options, and open the report only on request Every task-run ended by opening report.html in a browser unless -s was given, which on a desktop pops a window after each run and is never wanted in CI or over SSH, and the three report options were spelled three different ways. The report is still written by default; the options are now --report-open to also open it, --report-skip to not write it, and --report-only to write it for an existing output and exit. -n/--no-report and -p/--produce-report keep working as deprecated spellings of --report-skip and --report-only, and -s/--skip-report is accepted and ignored, each with a warning. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Simon A. F. Lund --- docs/source/250_quickstart.cmd | 2 +- docs/source/250_quickstart.txt | 2 +- docs/source/index.rst | 7 +- src/cijoe/cli/cli.py | 100 ++++++++++++++++----- src/cijoe/core/auxiliary/cijoe-completions | 2 +- tests/core/test_argparse.py | 10 +-- tests/core/test_loglevel.py | 2 +- tests/core/test_report_args.py | 60 +++++++++++++ tests/core/test_report_open.py | 61 +++++++++++++ 9 files changed, 212 insertions(+), 34 deletions(-) create mode 100644 tests/core/test_report_args.py create mode 100644 tests/core/test_report_open.py diff --git a/docs/source/250_quickstart.cmd b/docs/source/250_quickstart.cmd index 6c8e132a..549d364e 100644 --- a/docs/source/250_quickstart.cmd +++ b/docs/source/250_quickstart.cmd @@ -1 +1 @@ -cijoe --produce-report || true +cijoe --report-only || true diff --git a/docs/source/250_quickstart.txt b/docs/source/250_quickstart.txt index fff37d9d..1f7602e9 100644 --- a/docs/source/250_quickstart.txt +++ b/docs/source/250_quickstart.txt @@ -1 +1 @@ -cijoe --produce-report +cijoe --report-only diff --git a/docs/source/index.rst b/docs/source/index.rst index 5b941217..8c61dd98 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -32,8 +32,8 @@ Quickstart ========== **Try it yourself!** The commands below install **cijoe**, prints a list of -examples, emits and runs one of them, produces an HTML report, and opens -it using your preferred browser if one is available. +examples, emits and runs one of them, and produces an HTML report; add +``--report-open`` to also open it in your preferred browser. .. literalinclude:: 200_quickstart.cmd :language: bash @@ -41,7 +41,8 @@ it using your preferred browser if one is available. **cijoe** operates silently by default, as in, it does not print output unless errors occur. Options for customizing the behavior include: -- ``-n`` / ``--no-report``: Disable the generation and display of the HTML report. +- ``--report-open``: Open the HTML report in a browser once it is written. +- ``--report-skip``: Do not write the HTML report. - ``-l`` / ``--log-level``: Increase verbosity by setting a higher log level. - ``-m`` / ``--monitor``: Enable real-time monitoring of the task. diff --git a/src/cijoe/cli/cli.py b/src/cijoe/cli/cli.py index 9ffa2471..58f56adf 100644 --- a/src/cijoe/cli/cli.py +++ b/src/cijoe/cli/cli.py @@ -244,7 +244,7 @@ def cli_produce_report(args): if reporter.func is None: reporter.load() - setattr(args, "report_open", args.skip_report) + setattr(args, "report_open", getattr(args, "open_report", False)) return reporter.func(args, cijoe) @@ -516,6 +516,32 @@ def create_adhoc_task(args): sys.exit(main(args)) +def _resolve_report_args(args) -> int: + """Map the deprecated report options onto --report-skip and --report-only""" + + for old, new, attr in [ + ("-n / --no-report", "--report-skip", "report_skip"), + ("-p / --produce-report", "--report-only", "report_only"), + ]: + if getattr(args, f"{attr}_deprecated", False): + log.warning(f"{old} is deprecated; use {new} instead") + setattr(args, attr, True) + if getattr(args, "report_noopen_deprecated", False): + log.warning( + "-s / --skip-report is deprecated; the report is only opened with" + " --report-open" + ) + for attr in ["report_skip", "report_only", "report_noopen"]: + if hasattr(args, f"{attr}_deprecated"): + delattr(args, f"{attr}_deprecated") + + if getattr(args, "report_skip", False) and getattr(args, "report_only", False): + log.error("--report-skip and --report-only cannot be combined") + return errno.EINVAL + + return 0 + + def parse_args(): """Parse command-line interface.""" @@ -575,24 +601,57 @@ def parse_args(): help="Dump command output to stdout", ) run_group.add_argument( + "--tag", + "-t", + type=str, + action="append", + help="Tags to identify a task-run." + " This will be prefixed while storing in archive", + ) + + report_group = parent_parser.add_argument_group( + "report", "The report written at the end of a task-run" + ) + # Not 'report_open': that is core.reporter's own argument, which a task step + # may set and which is deleted from the namespace again after the step + report_group.add_argument( + "--report-open", + dest="open_report", + action="store_true", + help="Open the report in a browser once it is written", + ) + report_group.add_argument( + "--report-skip", + action="store_true", + help="Do not write a report at the end of the task-run", + ) + report_group.add_argument( + "--report-only", + action="store_true", + help="Write the report for the output at '-o / --output' and exit, without" + " running a task; with --report-open, also open it", + ) + # Deprecated spellings of the above, mapped by _resolve_report_args() + report_group.add_argument( "--no-report", "-n", + dest="report_skip_deprecated", action="store_true", - help="Skip the producing, and opening, a report at the end of the task-run", + help=argparse.SUPPRESS, ) - run_group.add_argument( + report_group.add_argument( "--skip-report", "-s", - action="store_false", - help="Skip the report opening at the end of the task-run", + dest="report_noopen_deprecated", + action="store_true", + help=argparse.SUPPRESS, ) - run_group.add_argument( - "--tag", - "-t", - type=str, - action="append", - help="Tags to identify a task-run." - " This will be prefixed while storing in archive", + report_group.add_argument( + "--produce-report", + "-p", + dest="report_only_deprecated", + action="store_true", + help=argparse.SUPPRESS, ) utils_group = parent_parser.add_argument_group( @@ -604,13 +663,6 @@ def parse_args(): action="store_true", help="Move the output at '-o / --output' to 'cijoe-archive/YYYY-MM-DD_HH:MM:SS", ) - utils_group.add_argument( - "--produce-report", - "-p", - action="append_const", - const=1, - help="Produce report, and open it, for output at '-o / --output' and exit.", - ) utils_group.add_argument( "--integrity-check", "-i", @@ -714,6 +766,12 @@ def parse_args(): setattr(args, "script_name", ident) args.step = [] + # Before log.basicConfig(), which sets the default level to ERROR: the + # deprecation warnings must be seen without raising the log level + err = _resolve_report_args(args) + if err: + return err, args + levels = [log.ERROR, log.INFO, log.DEBUG] log.basicConfig( format="%(levelname)s:%(module)s:%(funcName)s(): %(message)s", @@ -750,7 +808,7 @@ def main(args=None): if args.version: return cli_version(args) - if args.produce_report: + if args.report_only: return cli_produce_report(args) if args.archive: @@ -798,7 +856,7 @@ def main(args=None): err = cli_task(args) - if not args.no_report: + if not args.report_skip: report_err = cli_produce_report(args) err = err if err else report_err diff --git a/src/cijoe/core/auxiliary/cijoe-completions b/src/cijoe/core/auxiliary/cijoe-completions index 85192c68..e8525eca 100755 --- a/src/cijoe/core/auxiliary/cijoe-completions +++ b/src/cijoe/core/auxiliary/cijoe-completions @@ -9,7 +9,7 @@ _cijoe_completion() { prev="${COMP_WORDS[COMP_CWORD-1]}" # Define the possible options for cijoe - options="--help --config -c --workflow -w --output -o --log-level -l --no-report -n --skip-report -s --tag -t --archive -a --produce-report -p --monitor -m --integrity-check -i --resources -r --example -e --version -v" + options="--help --config -c --workflow -w --output -o --log-level -l --report-open --report-skip --report-only --no-report -n --skip-report -s --tag -t --archive -a --produce-report -p --monitor -m --integrity-check -i --resources -r --example -e --version -v" # Provide options completion if the current word starts with -- if [[ "$cur" == --* ]]; then diff --git a/tests/core/test_argparse.py b/tests/core/test_argparse.py index ece5ad69..1971ff99 100644 --- a/tests/core/test_argparse.py +++ b/tests/core/test_argparse.py @@ -27,12 +27,11 @@ def test_run_group(): output, f"-{loglevel}", "--monitor", - "--no-report", - "--skip-report", + "--report-open", + "--report-skip", "--tag", tag, "--archive", - "--produce-report", "--integrity-check", "--resources", "--example", @@ -46,11 +45,10 @@ def test_run_group(): assert args.output.name == output assert len(args.log_level) == len(loglevel) assert args.monitor - assert args.no_report - assert not args.skip_report + assert args.open_report + assert args.report_skip assert args.tag == [tag] assert args.archive - assert args.produce_report assert args.integrity_check assert args.resources assert args.example == example diff --git a/tests/core/test_loglevel.py b/tests/core/test_loglevel.py index d85c5b33..de33c052 100644 --- a/tests/core/test_loglevel.py +++ b/tests/core/test_loglevel.py @@ -13,7 +13,7 @@ def run_loglevel_script(tmp_path, loglevel) -> subprocess.CompletedProcess[str]: str(script_file), "--config", str(config_path), - "--no-report", + "--report-skip", ] if loglevel: diff --git a/tests/core/test_report_args.py b/tests/core/test_report_args.py new file mode 100644 index 00000000..3388fc45 --- /dev/null +++ b/tests/core/test_report_args.py @@ -0,0 +1,60 @@ +"""The --report-* options, and the deprecated spellings they replace""" + +import errno +import logging +import sys + +import pytest + +from cijoe.cli.cli import parse_args + + +def parse(monkeypatch, *argv): + monkeypatch.setattr(sys, "argv", ["cijoe", "task.yaml", *argv]) + return parse_args() + + +def test_defaults(monkeypatch): + err, args = parse(monkeypatch) + assert not err + assert not args.open_report + assert not args.report_skip + assert not args.report_only + + +def test_report_only_with_open(monkeypatch): + err, args = parse(monkeypatch, "--report-only", "--report-open") + assert not err + assert args.report_only and args.open_report + + +def test_skip_and_only_conflict(monkeypatch): + err, _ = parse(monkeypatch, "--report-skip", "--report-only") + assert err == errno.EINVAL + + +@pytest.mark.parametrize( + "old, attr", + [ + ("-n", "report_skip"), + ("--no-report", "report_skip"), + ("-p", "report_only"), + ("--produce-report", "report_only"), + ], +) +def test_deprecated_maps_onto_new(monkeypatch, caplog, old, attr): + with caplog.at_level(logging.WARNING): + err, args = parse(monkeypatch, old) + assert not err + assert getattr(args, attr) + assert "deprecated" in caplog.text + assert not any(name.endswith("_deprecated") for name in vars(args)) + + +@pytest.mark.parametrize("old", ["-s", "--skip-report"]) +def test_deprecated_skip_report_is_accepted_and_ignored(monkeypatch, caplog, old): + with caplog.at_level(logging.WARNING): + err, args = parse(monkeypatch, old) + assert not err + assert not args.open_report + assert "deprecated" in caplog.text diff --git a/tests/core/test_report_open.py b/tests/core/test_report_open.py new file mode 100644 index 00000000..0990e05e --- /dev/null +++ b/tests/core/test_report_open.py @@ -0,0 +1,61 @@ +"""The report is written after a run, but only opened when asked for""" + +import sys +import webbrowser + +import pytest + +from cijoe.cli import cli + +TASK = """\ +--- +doc: A single step, that runs anywhere +steps: +- name: noop + run: "true" +""" + + +@pytest.mark.parametrize( + "extra, written, opened", + [ + ([], True, 0), + (["--report-open"], True, 1), + (["--report-skip"], False, 0), + (["-s"], True, 0), + ], +) +def test_report_open(tmp_path, monkeypatch, extra, written, opened): + calls = [] + monkeypatch.setattr(webbrowser, "open", lambda url, *a, **k: calls.append(url)) + monkeypatch.chdir(tmp_path) + + config = tmp_path / "cijoe-config.toml" + config.write_text("[cijoe.task]\nfail_fast = true\n") + task = tmp_path / "cijoe-task.yaml" + task.write_text(TASK) + + argv = ["cijoe", "-c", str(config), str(task), "-o", str(tmp_path / "out")] + monkeypatch.setattr(sys, "argv", argv + extra) + assert cli.main() == 0 + + assert (tmp_path / "out" / "report.html").exists() == written + assert len(calls) == opened + + +@pytest.mark.parametrize("step_with", ["", " with:\n report_open: false\n"]) +def test_reporter_as_a_task_step(tmp_path, monkeypatch, step_with): + """core.reporter keeps working as a task step, with and without its argument""" + + monkeypatch.setattr(webbrowser, "open", lambda url, *a, **k: None) + monkeypatch.chdir(tmp_path) + + config = tmp_path / "cijoe-config.toml" + config.write_text("[cijoe.task]\nfail_fast = true\n") + task = tmp_path / "cijoe-task.yaml" + task.write_text(TASK + "- name: report\n uses: core.reporter\n" + step_with) + + argv = ["cijoe", "-c", str(config), str(task), "-o", str(tmp_path / "out")] + monkeypatch.setattr(sys, "argv", argv) + assert cli.main() == 0 + assert (tmp_path / "out" / "report.html").exists()