run errorsA 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.
Each row is a real failure, with the message the rewritten loader produces — all captured from a live run.
| Failure | Message |
|---|---|
| missing file | no such file: nope.py start one with: modulearn init nope |
| not a .py | data.csv is not a .py file |
| import error | boom.py failed to import (boom.py:1): ZeroDivisionError: division by zero |
| misnamed app | named.py has no attribute 'app' — did you mean 'editor'? (modulearn run named.py:editor) |
| no app at all | noapp.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 … |
import traceback
_load_app bodyThe 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
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.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
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
Add done:true, to the
{area:"dx", … title:"Sharper `modulearn run` errors"} item in
docs/roadmap.html.