Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/bmad_loop/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
119 changes: 119 additions & 0 deletions tests/test_entry_point.py
Original file line number Diff line number Diff line change
@@ -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