Roadmap · Developer UX · P1 · M

modulearn init scaffold

A build-it-yourself guide. You assembled ~/modTest by hand — a directory, a venv, an app.py you pasted from the README. This adds a third subcommand that does it in one line, so a newcomer goes from pip install modulearn to a running editor without copying anything.

Done when: modulearn init myproj writes a runnable starter project, modulearn run myproj/app.py serves it immediately, and a second init over an existing project refuses rather than clobbering it.
0 / 0 steps

Where it goes

Everything lives in one file — modulearn/cli.py — which already has the demo and run subcommands. You're adding a third the same way: templates + an _init() function + a subparser + one dispatch line. Because the package installs as a console script from source, an editable install (pip install -e .) picks up your edits with no reinstall.

Verified. Every block below was run against the real cli.py: --help lists {demo,run,init}, both init myproj and bare init scaffold correctly, the generated app serves (6 nodes) and trains to completion, and two pytest cases pass.

Steps

  1. Add the file templates
    modulearn/cli.py — after the imports, before _load_app

    The starter app is a toy loss curve so modulearn run works with zero extra deps — the user swaps on_train for the real thing. __TITLE__ is replaced with the project name (brace-safe, unlike str.format).

    # The starter app the scaffold writes. __TITLE__ is replaced with the project
    # name. A toy loss curve so `modulearn run app.py` works with zero extra deps.
    _APP_TEMPLATE = '''\
    """__TITLE__ — a ModuLearn project.
    
    Serve it:
        modulearn run app.py         # open http://localhost:8000
        python app.py                # same thing, directly
    """
    import math
    import random
    import time
    
    from modulearn import Registry, Param, create_app
    
    reg = Registry()
    reg.add_dataset("demo", title="Demo data", kind="tabular",
                    features=["x1", "x2", "x3"], targets=["y"])
    reg.add_model("mlp", title="MLP", requires_kind="tabular",
                  params=[Param("hidden", "hidden layers", "int_list", [32, 16])])
    reg.add_hyperparameter("lr", label="learning rate", default=1e-3, min=1e-6, max=1.0)
    reg.add_hyperparameter("epochs", label="epochs", kind="int", default=50, min=1, max=1000)
    reg.add_loss([Param("loss", "kind", "enum", "mse", choices=["mse", "cross_entropy"])])
    
    
    def on_train(compiled, reporter):
        epochs = int(compiled.hyperparameters.get("epochs", 50))
        lr = float(compiled.hyperparameters.get("lr", 1e-3))
        reporter.state(epochs=epochs)
        best = float("inf")
        for e in range(epochs):
            t = e / max(1, epochs - 1)
            train = 0.2 + 1.3 * math.exp(-4 * lr * 100 * t) + random.uniform(-0.02, 0.02)
            val = train + 0.05 + random.uniform(0, 0.03)
            best = min(best, val)
            reporter.metric(epoch=e, train=round(train, 4), val=round(val, 4))
            reporter.state(epoch=e, best_val=round(best, 4))
            time.sleep(0.03)
        reporter.state(phase="done", test_score=round(best + 0.02, 4))
    
    
    app = create_app(reg, on_train, title="__TITLE__")
    
    if __name__ == "__main__":
        import uvicorn
        uvicorn.run(app, host="127.0.0.1", port=8000)
    '''
    
    _README_TEMPLATE = '''\
    # __TITLE__
    
    A [ModuLearn](https://pypi.org/project/modulearn/) project.
    
    ```bash
    pip install modulearn
    modulearn run app.py     # open http://localhost:8000
    ```
    
    Edit `app.py`: declare datasets/models/hyperparameters in the `Registry`, and put
    your training loop in `on_train`.
    '''
    
    _GITIGNORE = "runs/\n__pycache__/\n*.pyc\n.venv/\n"
    Why __TITLE__ and not {title}: the app template is full of {} — dict literals, no less — so str.format would choke. A plain .replace() sidesteps it.
  2. Add the _init() function
    modulearn/cli.py — right after the templates

    It creates the directory (or uses the current one for .), writes the three files, and — critically — refuses to overwrite an existing app.py. README and .gitignore are only written if absent, so re-running in a real project is safe.

    def _init(name: str):
        """Scaffold a starter project. ``name`` is a directory (created if needed);
        ``.`` scaffolds into the current directory. Never overwrites an existing app.py."""
        if name in (".", "./"):
            target = Path.cwd()
        else:
            target = Path(name).resolve()
            target.mkdir(parents=True, exist_ok=True)
        title = target.name
    
        app_py = target / "app.py"
        if app_py.exists():
            sys.exit(f"modulearn: {app_py} already exists — refusing to overwrite")
        app_py.write_text(_APP_TEMPLATE.replace("__TITLE__", title))
    
        for fname, body in (("README.md", _README_TEMPLATE.replace("__TITLE__", title)),
                            (".gitignore", _GITIGNORE)):
            p = target / fname
            if not p.exists():          # respect files the user already has
                p.write_text(body)
    
        where = "." if target == Path.cwd() else name
        print(f"Created ModuLearn project in {where}/")
        print("Next:")
        if where != ".":
            print(f"  cd {where}")
        print("  modulearn run app.py      # open http://localhost:8000")
  3. Register the subparser
    modulearn/cli.py — in main(), after the run subparser

    name is optional and defaults to ., so a bare modulearn init scaffolds into the current directory.

        p_init = sub.add_parser("init", help="scaffold a new ModuLearn project")
        p_init.add_argument("name", nargs="?", default=".",
                            help="project directory to create (default: current dir)")
  4. Add the dispatch line
    modulearn/cli.py — in main(), alongside the other commands
        elif args.command == "init":
            _init(args.name)
  5. Verify it end-to-end

    An editable install means no reinstall — the command reflects your edits immediately.

    modulearn --help                 # {demo,run,init}
    modulearn init /tmp/mltest       # scaffolds the project
    modulearn run /tmp/mltest/app.py # serves at http://localhost:8000
    modulearn init /tmp/mltest       # run again → refuses to overwrite

    Open the served editor, drop Demo data → MLP → Train, and hit ▶ to confirm the scaffolded app actually trains.

  6. Add a test
    tests/test_cli_init.py

    tmp_path keeps it hermetic — no files touched outside the test.

    """Test the init scaffold: it writes a runnable app and won't clobber existing files."""
    import pytest
    from modulearn.cli import main
    
    
    def test_init_scaffolds(tmp_path):
        proj = tmp_path / "myproj"
        main(["init", str(proj)])
        assert (proj / "app.py").is_file()
        assert (proj / "README.md").is_file()
        assert 'title="myproj"' in (proj / "app.py").read_text()
    
    
    def test_init_refuses_overwrite(tmp_path):
        proj = tmp_path / "p2"
        main(["init", str(proj)])
        with pytest.raises(SystemExit):
            main(["init", str(proj)])       # app.py already exists
    pytest tests/test_cli_init.py -q     # 2 passed
    pytest -q                            # whole suite
  7. Update the docs

    Two small touches so the feature is discoverable: mention modulearn init in the cli.py module docstring at the top, and add a line to the README's install section — modulearn init myproject as the fastest way to start a real app.

  8. Commit, push, confirm CI
    git add modulearn/cli.py tests/test_cli_init.py README.md
    git commit -m "Add `modulearn init` project scaffold"
    git push
    gh run list --branch main --limit 1   # expect: success
  9. Mark it shipped on the roadmap

    In docs/roadmap.html, add done:true, to the {area:"dx", … title:"modulearn init scaffold"} item. Developer UX ticks to 1/4.

One design call worth knowing

The scaffold is dependency-free on purpose. The generated on_train is a toy curve, not scikit-learn, so init → run → ▶ Train works the instant it's created, with nothing to install. A newcomer sees a live learning curve in under a minute; the comment tells them exactly which function to replace. If you'd rather ship a real-model starter, add a --template regression flag later — but the zero-friction default is the right first impression.
ModuLearn · docs/init-scaffold-guide.html · companion to roadmap.html · code verified against the live cli.py · step state is local to this browser.