From d757fcf1e0d5d81ff4ee547d1956d86c0834a1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sat, 7 Nov 2026 09:56:26 -0800 Subject: [PATCH] docs(readme): make the example runnable and safe to copy - the sync entrypoint called finetune.spawn() and exited, which leaves the task's pod running; use .remote(), which terminates the pod on return, and document spawn.aio() plus wait()/cancel() - finetune returned an undefined final_loss; give it a small real training loop that writes a checkpoint to the volume - chat returned vLLM RequestOutput objects, which are not JSON values; return the generated text - add the `rp secret add hf-token` step and volume cleanup - add tests that the example defines every name it uses, passes app discovery, and does not spawn tasks from a sync entrypoint --- README.md | 35 ++++++++++--- tests/test_readme_example.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 tests/test_readme_example.py diff --git a/README.md b/README.md index b3fa882c..cfc1a511 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ from runpod import App, Model, NetworkVolume, Secret app = App("inference") -models = NetworkVolume("models", size=100) +checkpoints = NetworkVolume("checkpoints", size=100) llama = Model("meta-llama/Llama-3.1-8B-Instruct") @@ -44,7 +44,6 @@ llama = Model("meta-llama/Llama-3.1-8B-Instruct") gpu="H100", workers=(0, 3), dependencies=["vllm"], - mounts={"/runpod-volume": models}, model=llama, env={"HF_TOKEN": Secret("hf-token")}, ) @@ -52,27 +51,49 @@ def chat(prompt: str): import vllm llm = vllm.LLM(model=str(llama.path)) # weights already on disk - return llm.generate(prompt) + result = llm.generate([prompt])[0] + return result.outputs[0].text # results must be JSON values # one ephemeral pod per call: provisions, runs to completion, terminates -@app.task(gpu="H100", gpu_count=2, mounts={"/models": models}) +@app.task(gpu="H100", mounts={"/checkpoints": checkpoints}) def finetune(steps: int = 1000): - ... - return {"loss": final_loss} + import torch + + model = torch.nn.Linear(1, 1).cuda() + optimizer = torch.optim.SGD(model.parameters(), lr=0.05) + x = torch.randn(1024, 1, device="cuda") + y = 3 * x + 1 + for _ in range(steps): + loss = ((model(x) - y) ** 2).mean() + optimizer.zero_grad() + loss.backward() + optimizer.step() + torch.save(model.state_dict(), checkpoints.path / "model.pt") + return {"loss": loss.item()} @runpod.local_entrypoint def main(): print(chat.remote("why is the sky blue?")) # blocks for the result - job = finetune.spawn(steps=500) # fire and forget -> Job + print(finetune.remote(steps=500)) # pod terminates on return ``` ```bash +rp secret add hf-token # store your Hugging Face token once rp flash dev main.py # live dev session: edit, re-run, logs stream back rp flash deploy # deploy production endpoints ``` +To start a task without blocking, use an async entrypoint: +`job = await finetune.spawn.aio(steps=500)`, then `await job.wait()`. +The task's pod keeps running until you wait on the job or call +`await job.cancel()`, even after your script exits. + +The `checkpoints` volume is kept between runs. When you no longer need it, +find its id with `rp volume list` and delete it with +`rp volume delete VOLUME_ID`. More examples are in [`examples/`](examples). + ## Contributing Pull requests and issues are welcome — see the [contributing guide](CONTRIBUTING.md) to get started. diff --git a/tests/test_readme_example.py b/tests/test_readme_example.py new file mode 100644 index 00000000..6074736d --- /dev/null +++ b/tests/test_readme_example.py @@ -0,0 +1,97 @@ +"""The README's Example section must stay valid code the SDK accepts. + +The sample is the first thing new users copy, so these tests check that it +references only names it defines, that app discovery accepts it, and that a +synchronous entrypoint never spawns a task it cannot wait on. +""" + +import ast +import builtins +import re +import symtable +from pathlib import Path + +from runpod.apps.discovery import discover_apps + +README = Path(__file__).resolve().parent.parent / "README.md" +EXAMPLE_HEADING = "## Example" +PYTHON_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL) + + +def _example_source() -> str: + text = README.read_text(encoding="utf-8") + _, _, section = text.partition(EXAMPLE_HEADING) + match = PYTHON_BLOCK.search(section) + assert match, "README Example section has no python code block" + return match.group(1) + + +def _module_bindings(table: symtable.SymbolTable) -> set: + return { + sym.get_name() + for sym in table.get_symbols() + if sym.is_assigned() or sym.is_imported() + } + + +def _undefined_globals(source: str) -> set: + top = symtable.symtable(source, "README example", "exec") + known = _module_bindings(top) | set(dir(builtins)) + undefined = set() + + def visit(table: symtable.SymbolTable) -> None: + for sym in table.get_symbols(): + if sym.is_referenced() and sym.is_global() and sym.get_name() not in known: + undefined.add(sym.get_name()) + for child in table.get_children(): + visit(child) + + visit(top) + return undefined + + +def _decorator_attr(node: ast.expr) -> str: + target = node.func if isinstance(node, ast.Call) else node + return target.attr if isinstance(target, ast.Attribute) else "" + + +def test_example_references_only_defined_names(): + assert _undefined_globals(_example_source()) == set() + + +def test_example_is_accepted_by_app_discovery(tmp_path): + sample = tmp_path / "main.py" + sample.write_text(_example_source(), encoding="utf-8") + + apps = discover_apps(sample) + + assert len(apps) == 1 + + +def test_sync_entrypoint_does_not_spawn_tasks(): + """A spawned task keeps its pod until awaited, which sync code cannot do.""" + tree = ast.parse(_example_source()) + functions = [n for n in tree.body if isinstance(n, ast.FunctionDef)] + tasks = { + fn.name + for fn in functions + if any(_decorator_attr(d) == "task" for d in fn.decorator_list) + } + entrypoints = [ + fn + for fn in functions + if any(_decorator_attr(d) == "local_entrypoint" for d in fn.decorator_list) + ] + + spawned = { + call.func.value.id + for fn in entrypoints + for call in ast.walk(fn) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "spawn" + and isinstance(call.func.value, ast.Name) + } + + assert tasks, "expected the example to define at least one task" + assert spawned & tasks == set()