Skip to content

Commit 7a6f2d5

Browse files
authored
Merge pull request #1819 from transformerlab/add/lab-task-init-cli
Add lab task init to CLI
2 parents d641fb7 + 5623317 commit 7a6f2d5

7 files changed

Lines changed: 386 additions & 2 deletions

File tree

.agents/skills/transformerlab-cli/SKILL.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ lab job download JOB_ID --file "*.csv" -o ./results
102102

103103
## Creating Tasks
104104

105+
### Scaffold a new task with `lab task init`
106+
107+
**When the user asks to create, initialize, or start a new task, always use `lab task init`** rather than writing `task.yaml` / `main.py` by hand. It scaffolds both files with sensible defaults in the current directory so the user has a working starting point.
108+
109+
```bash
110+
mkdir my-task && cd my-task
111+
lab task init # writes task.yaml + main.py with defaults (skips existing files)
112+
lab task init --interactive # prompts for name, CPUs, memory, setup, and run command
113+
```
114+
115+
- Default mode is non-interactive. It creates `task.yaml` (with `name`, `resources: {cpus: 2, memory: 4}`, and `run: python main.py`) and a starter `main.py`. Existing files are skipped, not overwritten.
116+
- `--interactive` writes only `task.yaml` (no `main.py`) and prompts for each field. In this mode `task.yaml` will prompt before overwrite.
117+
- After init, edit `main.py`, customize `task.yaml`, then run `lab task add .` to upload it.
118+
105119
### task.yaml Structure
106120

107121
Full docs: https://lab.cloud/for-teams/running-a-task/task-yaml-structure
@@ -291,6 +305,7 @@ This applies to launching jobs, fetching logs, checking cluster status, and ever
291305
| `lab version` | Show CLI version | No |
292306
| `lab task list` | List tasks in current experiment | Yes |
293307
| `lab task info <id>` | Get task details | Yes |
308+
| `lab task init` | Scaffold `task.yaml` + `main.py` in the current directory (`--interactive` to prompt) | No |
294309
| `lab task add [dir]` | Add task from directory or `--from-git` URL (`--no-interactive`, `--dry-run`) | Yes |
295310
| `lab task delete <id>` | Delete a task (`--no-interactive` to skip confirmation) | Yes |
296311
| `lab task queue <id>` | Queue task on compute provider | Yes |

.agents/skills/transformerlab-cli/references/commands.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ Get details for a specific task.
9898
{"id": 1, "name": "my-task", "type": "TRAINING", "config": {...}, ...}
9999
```
100100

101+
### `task init`
102+
103+
Scaffold a new task in the current directory. **Prefer this over writing `task.yaml` by hand.** Does not require an experiment to be set.
104+
105+
| Option | Description |
106+
|---|---|
107+
| (default, no flag) | Non-interactive. Creates `task.yaml` with defaults (`name` = folder name, `cpus: 2`, `memory: 4`, `run: python main.py`) and a starter `main.py`. Existing files are skipped, not overwritten. |
108+
| `--interactive` | Prompts for task name, CPUs, memory, accelerators, setup, and run command. Writes only `task.yaml` (no `main.py`). Prompts before overwriting an existing `task.yaml`. |
109+
110+
**JSON output (default):** `{"created": ["task.yaml", "main.py"], "skipped": [], "path": "/abs/dir"}`
111+
101112
### `task add [directory]`
102113

103114
Add a new task from a local directory containing `task.yaml`, or from a Git repository.

cli/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab-cli"
7-
version = "0.0.15"
7+
version = "0.0.16"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]
@@ -43,7 +43,7 @@ lab = "transformerlab_cli.main:app"
4343
where = ["src"]
4444

4545
[tool.setuptools.package-data]
46-
"*" = ["*.tcss", "*.css"]
46+
"*" = ["*.tcss", "*.css", "templates/task_init/*"]
4747

4848
[dependency-groups]
4949
dev = ["pytest", "textual>=6.11.0", "typer[testing]"]

cli/src/transformerlab_cli/commands/task.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import os
44
import zipfile
5+
from pathlib import Path
56

67
import httpx
78
import typer
@@ -234,6 +235,159 @@ def command_task_list():
234235
list_tasks(output_format=cli_state.output_format, experiment_id=current_experiment)
235236

236237

238+
TASK_INIT_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" / "task_init"
239+
240+
241+
def _render_task_yaml_template(task_name: str) -> str:
242+
template = (TASK_INIT_TEMPLATES_DIR / "task.yaml").read_text(encoding="utf-8")
243+
return template.replace("{{TASK_NAME}}", task_name)
244+
245+
246+
def _main_py_template() -> str:
247+
return (TASK_INIT_TEMPLATES_DIR / "main.py").read_text(encoding="utf-8")
248+
249+
250+
def _write_task_yaml(path: str, data: dict) -> None:
251+
yaml_text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
252+
with open(path, "w", encoding="utf-8", newline="\n") as f:
253+
f.write(yaml_text)
254+
255+
256+
def _print_next_steps(include_main_py: bool) -> None:
257+
console.print("\nNext steps:")
258+
if include_main_py:
259+
console.print("- Edit [bold]main.py[/bold] with your task code")
260+
console.print("- Customize [bold]task.yaml[/bold] (resources, setup, parameters)")
261+
console.print("- Run: [bold]lab task add .[/bold]")
262+
console.print("- Docs: https://lab.cloud/for-teams/running-a-task/task-yaml-structure")
263+
264+
265+
def _task_init_default(task_yaml_path: str, main_py_path: str, folder_name: str) -> None:
266+
if os.path.exists(task_yaml_path):
267+
if cli_state.output_format == "json":
268+
print(json.dumps({"error": "task.yaml already exists"}))
269+
else:
270+
console.print(
271+
f"[error]Error:[/error] [bold]{task_yaml_path}[/bold] already exists. "
272+
"Refusing to overwrite. Remove it first or run `lab task init` in an empty directory."
273+
)
274+
raise typer.Exit(1)
275+
276+
with open(task_yaml_path, "w", encoding="utf-8", newline="\n") as f:
277+
f.write(_render_task_yaml_template(folder_name))
278+
279+
main_py_existed = os.path.exists(main_py_path)
280+
if not main_py_existed:
281+
with open(main_py_path, "w", encoding="utf-8", newline="\n") as f:
282+
f.write(_main_py_template())
283+
284+
if cli_state.output_format == "json":
285+
created = ["task.yaml"] if main_py_existed else ["task.yaml", "main.py"]
286+
skipped = ["main.py"] if main_py_existed else []
287+
print(json.dumps({"created": created, "skipped": skipped, "path": os.path.dirname(task_yaml_path)}))
288+
return
289+
290+
console.print("[success]✓[/success] Created [bold]task.yaml[/bold]")
291+
if main_py_existed:
292+
console.print("[warning]•[/warning] Skipped [bold]main.py[/bold] (already exists)")
293+
else:
294+
console.print("[success]✓[/success] Created [bold]main.py[/bold]")
295+
296+
console.print(f"\nLocation: [bold]{os.path.dirname(task_yaml_path)}[/bold]")
297+
_print_next_steps(include_main_py=not main_py_existed)
298+
299+
300+
def _task_init_interactive(task_yaml_path: str, folder_name: str) -> None:
301+
if os.path.exists(task_yaml_path):
302+
if cli_state.output_format == "json":
303+
print(json.dumps({"error": "task.yaml already exists"}))
304+
raise typer.Exit(1)
305+
should_overwrite = typer.confirm("task.yaml already exists. Overwrite?", default=False)
306+
if not should_overwrite:
307+
console.print("[warning]Cancelled.[/warning]")
308+
raise typer.Exit(0)
309+
310+
task_name = typer.prompt("Task name", default=folder_name).strip() or folder_name
311+
312+
cpus = typer.prompt("CPUs", default="2").strip()
313+
memory = typer.prompt("Memory (GB)", default="4").strip()
314+
accelerators = typer.prompt("Accelerators (optional)", default="", show_default=False).strip()
315+
316+
setup = ""
317+
run = ""
318+
319+
if cli_state.output_format != "json" and os.isatty(0) and os.isatty(1):
320+
edited = typer.edit(
321+
"\n".join(
322+
[
323+
"# Define the commands for your task below.",
324+
"# This YAML snippet will be parsed and merged into task.yaml.",
325+
"",
326+
"setup: |",
327+
" # Optional: install deps, download data, etc.",
328+
" ",
329+
"run: |",
330+
" # Required: the main command to execute",
331+
" ",
332+
"",
333+
]
334+
)
335+
)
336+
if edited:
337+
try:
338+
edited_obj = yaml.safe_load(edited)
339+
if isinstance(edited_obj, dict):
340+
setup_val = edited_obj.get("setup")
341+
run_val = edited_obj.get("run")
342+
if isinstance(setup_val, str):
343+
setup = setup_val.rstrip()
344+
if isinstance(run_val, str):
345+
run = run_val.rstrip()
346+
except yaml.YAMLError:
347+
pass
348+
349+
if not setup.strip():
350+
setup = typer.prompt("Setup command (optional)", default="", show_default=False).rstrip()
351+
352+
while not run.strip():
353+
run = typer.prompt("Run command", default="", show_default=False).rstrip()
354+
355+
task_yaml: dict = {
356+
"name": task_name,
357+
"resources": {"cpus": cpus, "memory": memory},
358+
"run": run,
359+
}
360+
if accelerators:
361+
task_yaml["resources"]["accelerators"] = accelerators
362+
if setup.strip():
363+
task_yaml["setup"] = setup
364+
365+
_write_task_yaml(task_yaml_path, task_yaml)
366+
367+
if cli_state.output_format == "json":
368+
print(json.dumps({"path": task_yaml_path}))
369+
return
370+
371+
console.print(f"[success]✓[/success] Wrote [bold]{task_yaml_path}[/bold]")
372+
_print_next_steps(include_main_py=False)
373+
374+
375+
@app.command("init")
376+
def command_task_init(
377+
interactive: bool = typer.Option(False, "--interactive", help="Prompt for task settings instead of using defaults"),
378+
):
379+
"""Initialize a task.yaml and main.py in the current directory."""
380+
cwd = os.getcwd()
381+
task_yaml_path = os.path.join(cwd, "task.yaml")
382+
main_py_path = os.path.join(cwd, "main.py")
383+
folder_name = os.path.basename(cwd).strip() or "my-task"
384+
385+
if interactive:
386+
_task_init_interactive(task_yaml_path, folder_name)
387+
else:
388+
_task_init_default(task_yaml_path, main_py_path, folder_name)
389+
390+
237391
@app.command("add")
238392
def command_task_add(
239393
task_directory: str = typer.Argument(None, help="Path to the task directory containing task.yaml"),
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Sample Transformer Lab task.
2+
3+
This file demonstrates the core Lab SDK calls you will use in a real task.
4+
Replace the sleep loop with your actual training / inference / eval code.
5+
6+
Common SDK calls:
7+
- lab.init() attach this process to the job
8+
- lab.log(message) stream a line to the task log
9+
- lab.update_progress(n) set job progress (0..100)
10+
- lab.get_config() read `parameters:` from task.yaml
11+
- lab.save_artifact(path, name) attach a file to the job (summaries,
12+
plots, CSVs, etc.) for later download
13+
- lab.save_checkpoint(dir, name) persist a training checkpoint
14+
- lab.save_model(dir, name=...) register a final trained model
15+
- lab.finish(message) mark the job SUCCESS
16+
- lab.error(message) mark the job FAILED
17+
18+
Docs: https://lab.cloud/for-teams/running-a-task/lab-sdk
19+
"""
20+
21+
import json
22+
import time
23+
from pathlib import Path
24+
25+
from lab import lab
26+
27+
28+
def main():
29+
# Always call init() first. Without it, log / progress / artifact
30+
# calls will not be associated with the job.
31+
lab.init()
32+
33+
try:
34+
# Read parameters defined under `parameters:` in task.yaml.
35+
# Returns a dict; use .get() with a default so the task still
36+
# runs when a parameter is missing.
37+
config = lab.get_config() or {}
38+
total_steps = int(config.get("total_steps", 5))
39+
40+
lab.log(f"Starting task with config: {config}")
41+
lab.update_progress(5)
42+
43+
# ----- Fake training loop -----
44+
# Replace this block with your real work. In a real task you
45+
# would typically also call:
46+
# lab.save_checkpoint(checkpoint_dir, f"checkpoint-{step}")
47+
# every N steps so the job can resume on retry.
48+
for step in range(1, total_steps + 1):
49+
time.sleep(1) # pretend we are doing something expensive
50+
progress = 5 + int(step / total_steps * 90) # 5..95
51+
lab.log(f"step {step}/{total_steps}")
52+
lab.update_progress(progress)
53+
54+
# ----- Save an artifact -----
55+
# Artifacts are any files you want attached to the job for later
56+
# download (metrics, plots, CSVs, etc.). The second argument is
57+
# the name the artifact appears under in the UI / CLI.
58+
summary_path = Path("training_summary.json")
59+
summary_path.write_text(json.dumps({"steps": total_steps, "ok": True}, indent=2))
60+
lab.save_artifact(str(summary_path), "training_summary.json")
61+
62+
# If you trained a model, you would also call:
63+
# lab.save_model(model_dir, name="my_trained_model")
64+
65+
lab.update_progress(100)
66+
lab.finish("Task completed successfully")
67+
68+
except Exception as e:
69+
# Any unhandled exception should be reported via lab.error so
70+
# the job is marked FAILED instead of being stuck in RUNNING.
71+
lab.error(f"Task failed: {e}")
72+
raise
73+
74+
75+
if __name__ == "__main__":
76+
main()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Transformer Lab task configuration
2+
# Docs: https://lab.cloud/for-teams/running-a-task/task-yaml-structure
3+
4+
name: {{TASK_NAME}}
5+
6+
# Compute requested for this task. Memory and disk_space are in GB.
7+
resources:
8+
cpus: 2
9+
memory: 4
10+
# accelerators: "H100:1" # e.g. 1x H100 GPU; format is "TYPE:COUNT"
11+
# disk_space: 100
12+
# num_nodes: 1 # >1 for distributed training
13+
14+
# Commands that run on the remote machine BEFORE `run`. Use this for
15+
# installing deps, downloading data, etc.
16+
setup: |
17+
pip install transformerlab
18+
19+
# Main entry point. This is the command that executes your task.
20+
run: python main.py
21+
22+
# Parameters are available inside main.py via `lab.get_config()`.
23+
# parameters:
24+
# learning_rate: 0.001
25+
# batch_size: 32
26+
27+
# envs:
28+
# HF_TOKEN: "${HF_TOKEN}"

0 commit comments

Comments
 (0)