Roadmap · Developer UX · P2 · S

Sharper run errors

A build-it-yourself guide. When someone's app.py won't load, the message they get is the difference between a five-second fix and a confused bug report. This rewrites _load_app so every failure says exactly what's wrong and what to do.

Done when: a missing file, a bad import, a misnamed app, and a wrong-type target each produce a specific, actionable line — and the import-error case points at the line in the user's file, not ModuLearn's machinery.
0 / 0 steps

Before / after

Each row is a real failure, with the message the rewritten loader produces — all captured from a live run.

FailureMessage
missing fileno such file: nope.py
  start one with: modulearn init nope
not a .pydata.csv is not a .py file
import errorboom.py failed to import (boom.py:1): ZeroDivisionError: division by zero
misnamed appnamed.py has no attribute 'app' — did you mean 'editor'? (modulearn run named.py:editor)
no app at allnoapp.py has no attribute 'app'
  assign one with: app = create_app(reg, on_train)
wrong type'app' in wrong.py is a int, not a FastAPI app …
Verified. All six messages above are the literal output of the code in this guide, run against the real loader. The seventh case — a valid app — still loads.

Steps

  1. Import traceback at the top of cli.py
    modulearn/cli.py — imports
    import traceback
  2. Replace _load_app with the sharp version
    modulearn/cli.py — swap the existing _load_app body

    The five improvements, in order: a fix hint for a missing file; a .py guard; an import error that walks the traceback back to the user's own file and names the line; a missing-attribute case that scans the module for a real app and suggests it; and a final type check so a non-app never reaches uvicorn.

    def _load_app(target: str):
        """Resolve ``path.py`` or ``path.py:attr`` to a FastAPI app, with a specific,
        actionable error for every way it can go wrong."""
        path_str, _, attr = target.partition(":")
        attr = attr or "app"
        path = Path(path_str).resolve()
        if not path.exists():
            stem = Path(path_str).stem or "myproject"
            sys.exit(f"modulearn: no such file: {path_str}\n"
                     f"  start one with:  modulearn init {stem}")
        if path.suffix != ".py":
            sys.exit(f"modulearn: {path_str} is not a .py file")
    
        sys.path.insert(0, str(path.parent))
        spec = importlib.util.spec_from_file_location(path.stem, path)
        module = importlib.util.module_from_spec(spec)
        try:
            spec.loader.exec_module(module)
        except Exception as e:
            # walk back to the last frame inside the user's file so the location is
            # theirs, not our import machinery's.
            frames = traceback.extract_tb(e.__traceback__)
            here = [f for f in frames if f.filename == str(path)]
            loc = f" ({path.name}:{here[-1].lineno})" if here else ""
            sys.exit(f"modulearn: {path.name} failed to import{loc}: "
                     f"{type(e).__name__}: {e}")
    
        obj = getattr(module, attr, None)
        if obj is None:
            apps = [n for n, v in vars(module).items() if _is_asgi_app(v)]
            hint = (f" — did you mean '{apps[0]}'?  (modulearn run {path.name}:{apps[0]})"
                    if apps else "\n  assign one with:  app = create_app(reg, on_train)")
            sys.exit(f"modulearn: {path.name} has no attribute '{attr}'{hint}")
    
        resolved = obj() if callable(obj) and not _is_asgi_app(obj) else obj
        if not _is_asgi_app(resolved):
            sys.exit(f"modulearn: '{attr}' in {path.name} is a {type(resolved).__name__}, "
                     f"not a FastAPI app (expected create_app(...) or a factory returning one)")
        return resolved
    The traceback walk is the key move. Filtering extract_tb to frames whose filename is the user's path means the reported line is where their code broke — a stray 1/0 in app.py shows app.py:1, not a line deep in importlib.
  3. Add a test for the failure modes
    tests/test_run_errors_cli.py

    Each _load_app failure calls sys.exit(msg), which raises SystemExit — assert on its message.

    import pytest
    from modulearn.cli import _load_app
    
    
    def test_missing_file(tmp_path):
        with pytest.raises(SystemExit) as ei:
            _load_app(str(tmp_path / "nope.py"))
        assert "no such file" in str(ei.value)
    
    
    def test_import_error_names_user_line(tmp_path):
        p = tmp_path / "boom.py"
        p.write_text("x = 1 / 0\napp = None\n")
        with pytest.raises(SystemExit) as ei:
            _load_app(str(p))
        assert "boom.py:1" in str(ei.value) and "ZeroDivisionError" in str(ei.value)
    
    
    def test_suggests_existing_app(tmp_path):
        p = tmp_path / "named.py"
        p.write_text("from modulearn.demo import build_app\neditor = build_app()\n")
        with pytest.raises(SystemExit) as ei:
            _load_app(str(p))
        assert "did you mean 'editor'" in str(ei.value)
    pytest tests/test_run_errors_cli.py -q     # 3 passed
  4. Commit, push, confirm CI
    git add modulearn/cli.py tests/test_run_errors_cli.py
    git commit -m "Sharper errors for modulearn run"
    git push
    gh run list --branch main --limit 1   # expect: success
  5. Mark it shipped on the roadmap

    Add done:true, to the {area:"dx", … title:"Sharper `modulearn run` errors"} item in docs/roadmap.html.