From 18ac56f7aa9dc6ff23ead6aad6637f9fd242849b Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 21 Jul 2026 18:07:45 -0700 Subject: [PATCH] feat(cli): support `python -m bmad_loop` + pin entry-point/exit-code semantics (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/bmad_loop/__main__.py delegating to cli.main(), mirroring the cli.py __main__ guard, so the package runs as a module alongside the installed `bmad-loop` console script. Add tests/test_entry_point.py: - subprocess smoke tests (`python -m bmad_loop --version`/`--help` and one `validate --json`) that spawn a real interpreter via sys.executable — the deliberate exception to the in-process cli.main() tests, so packaging regressions (startup import cycles, broken script wiring) become visible. - characterization tests pinning today's CLI exit codes: typed errors (BmadConfigError/SprintStatusError/PolicyError/GitError) and the broad backstop -> 1, argparse usage error -> 2. No behavior changes — the tests pin current semantics as the guard rail for the exit-code-taxonomy work (#241) and the cli.py composition extraction (#243). --- CHANGELOG.md | 10 ++++ src/bmad_loop/__main__.py | 12 ++++ tests/test_entry_point.py | 119 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/bmad_loop/__main__.py create mode 100644 tests/test_entry_point.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eda77a89..7f6f7b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `bmad-loop` are documented here. The format is based on [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0, breaking changes may land in a minor release. +## [Unreleased] + +### Added + +- **`python -m bmad_loop` (#240).** The package is now runnable as a module + (`python -m bmad_loop …`), mirroring the installed `bmad-loop` console script via a thin + `__main__.py`. Subprocess smoke tests exercise the module entry, and characterization tests + pin the current CLI exit codes (typed errors and the broad backstop → 1, argparse usage → 2). + ## [0.9.0] — 2026-07-21 ### Added @@ -1691,6 +1700,7 @@ enforced in CI. implementation phase, driven by a Python control loop with hook-based session transport and resumable on-disk run state. +[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.9.0...HEAD [0.9.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.9.0 [0.8.1]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.8.1 [0.8.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.8.0 diff --git a/src/bmad_loop/__main__.py b/src/bmad_loop/__main__.py new file mode 100644 index 00000000..2e8dafb2 --- /dev/null +++ b/src/bmad_loop/__main__.py @@ -0,0 +1,12 @@ +"""``python -m bmad_loop`` entry point — delegates to the console-script main(). + +Mirrors the ``if __name__ == "__main__"`` guard at the foot of ``cli.py`` so the +module form and the installed ``bmad-loop`` script share one dispatch path. +""" + +import sys + +from bmad_loop.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_entry_point.py b/tests/test_entry_point.py new file mode 100644 index 00000000..ef14e483 --- /dev/null +++ b/tests/test_entry_point.py @@ -0,0 +1,119 @@ +"""Installed-entry-point + exit-code characterization (issue #240, findings F-7/F-8). + +Two things every other CLI test leaves uncovered: + +- **Packaging.** Every test in ``test_cli.py`` calls ``cli.main()`` in-process, so a + startup import cycle or broken script wiring is invisible to the suite. The three + ``test_module_*`` cases below are the deliberate exception: they spawn a real + interpreter (``sys.executable -m bmad_loop``) so ``python -m bmad_loop`` — and, by + extension, the ``bmad-loop`` console script it mirrors — is exercised end to end. + Do not convert these to in-process ``cli.main()`` calls; the subprocess *is* the test. + +- **Exit-code semantics.** ``main()`` maps the typed error surface and the broad + backstop both to rc 1, and argparse usage errors to rc 2 (``cli.py`` dispatch tail). + The ``test_exit_*`` cases pin today's rc for each path. They are characterization + tests — a guard rail for the later exit-code-taxonomy work (#241) and the cli.py + composition extraction (#243), not an endorsement of the current mapping. +""" + +import json +import subprocess +import sys + +import pytest + +from bmad_loop import __version__, bmadconfig, cli +from bmad_loop import policy as policy_mod +from bmad_loop import sprintstatus, verify + + +def _run_module(*args, cwd): + """Spawn ``python -m bmad_loop`` under the test interpreter. + + ``cwd`` is a per-test temp dir (never the repo root), so ``-m``'s implicit + ``sys.path[0]`` cannot shadow the installed package — the module resolves via + the same install the ``bmad-loop`` script would use. ``sys.executable`` + + per-test ``cwd``/``--project`` keeps these xdist-safe: no shared writable state. + """ + return subprocess.run( + [sys.executable, "-m", "bmad_loop", *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=str(cwd), + ) + + +# ----------------------------------------------------------------- packaging smoke + + +def test_module_version_smoke(tmp_path): + """``python -m bmad_loop --version`` prints the package version to stdout, rc 0.""" + proc = _run_module("--version", cwd=tmp_path) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == f"bmad-loop {__version__}" + + +def test_module_help_smoke(tmp_path): + """``python -m bmad_loop --help`` prints usage under the ``bmad-loop`` prog, rc 0.""" + proc = _run_module("--help", cwd=tmp_path) + assert proc.returncode == 0, proc.stderr + assert "usage: bmad-loop" in proc.stdout + + +def test_module_validate_json_smoke(tmp_path): + """``validate --json`` in an empty project: rc 1 (the bmad-config gate fails) but + stdout is still a whole, parseable document — the one --json command that owes a + document at a nonzero exit. Proves the machine surface survives the module entry.""" + proc = _run_module("validate", "--json", "--project", str(tmp_path), cwd=tmp_path) + assert proc.returncode == 1, proc.stderr + doc = json.loads(proc.stdout) # whole-stdout parse == stdout-purity assertion + assert doc["schema_version"] == 1 + assert doc["ok"] is False + + +# ------------------------------------------------------- exit-code characterization + + +@pytest.mark.parametrize( + "exc", + [ + bmadconfig.BmadConfigError, + sprintstatus.SprintStatusError, + policy_mod.PolicyError, + verify.GitError, + ], +) +def test_exit_typed_error_is_1(tmp_path, capsys, monkeypatch, exc): + """Every exception in the first ``except`` tuple → rc 1, message on stderr. + Patched onto the dispatched handler (``main`` binds ``args.func`` from the module + global at parse time), so the raise happens inside the try that maps it.""" + + def boom(_args): + raise exc("boom") + + monkeypatch.setattr(cli, "cmd_validate", boom) + assert cli.main(["validate", "--project", str(tmp_path)]) == 1 + assert "error: boom" in capsys.readouterr().err + + +def test_exit_broad_backstop_is_1(tmp_path, capsys, monkeypatch): + """A non-typed exception falls through to the broad ``except Exception`` backstop + — also rc 1, message on stderr, never a bare traceback to the parked pane.""" + + def boom(_args): + raise RuntimeError("unexpected") + + monkeypatch.setattr(cli, "cmd_validate", boom) + assert cli.main(["validate", "--project", str(tmp_path)]) == 1 + assert "error: unexpected" in capsys.readouterr().err + + +def test_exit_argparse_usage_error_is_2(capsys): + """An argparse usage error (unknown subcommand) exits 2 via SystemExit — raised by + ``parse_args`` before the dispatch try, so it propagates rather than mapping to 1.""" + with pytest.raises(SystemExit) as excinfo: + cli.main(["definitely-not-a-command"]) + assert excinfo.value.code == 2 + assert "invalid choice" in capsys.readouterr().err