Skip to content

cmd_tui's extras guard is dead: tui/app.py imports rich before textual, so the friendly message never prints #678

Description

@pbean

cmd_tui's ModuleNotFoundError guard maps only ("textual", "tomlkit"), but the name that actually surfaces on a no-extras install is rich — so the guard misses, re-raises, and the operator gets a traceback instead of the install hint it exists to print.

The guard

# src/bmad_loop/cli.py:3568-3577
try:
    from .tui.app import run_tui
except ModuleNotFoundError as e:
    if (e.name or "").partition(".")[0] in ("textual", "tomlkit"):
        print(
            "error: the TUI requires optional dependencies — uv tool install 'bmad-loop[tui]'",
            file=sys.stderr,
        )
        return 1
    raise

Why it never fires

src/bmad_loop/tui/app.py imports rich before textual:

# src/bmad_loop/tui/app.py:20-21
from rich.text import Text
from textual import work

So from .tui.app import run_tui raises with e.name == "rich", which is not in the tuple. The raise arm runs.

Compounding it: rich is declared in no extra and no dependency group at all (pyproject.toml) — it arrives only as a transitive dependency of textual. A guard that enumerates the extra's own package names therefore cannot ever catch it.

Reproduction

Under a sys.meta_path blocker for pyte / rich / textual / tomlkit, reproducing cmd_tui's try/except verbatim:

import .tui.app raised ModuleNotFoundError name='rich'
  guard tuple ('textual','tomlkit') matches? False
  => GUARD MISSED -> bare raise, user sees a traceback

Scope

Same family as #650 (cmd_listtui.dataimport pyte), and the two should ship together. They are not the same defect: #650 is a core command that should not need the extra at all, whereas tui genuinely requires it — here the bug is purely that the failure is unhandled rather than explained.

Fix notes

Gate on the failure, not on a package allowlist — the import target is the extra's own module tree, so any ModuleNotFoundError escaping from .tui.app import ... means the extra is not installed. If an allowlist is kept for precision, it must include rich.

The guard test must block pyte, rich, textual and tomlkit. tui/data.py:24's import pyte precedes its rich imports, so a blocker covering only pyte leaves the rich half untested — which is how this survived. Ablation rule applies: restore the narrow tuple and confirm the test reddens.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Robustness, enhancement, tests, or docs worth schedulingarea:tuiTextual TUIbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions