Skip to content

Commit fc722a7

Browse files
committed
Add ability to mark jobs as discarded
1 parent 78e9ea7 commit fc722a7

12 files changed

Lines changed: 214 additions & 14 deletions

File tree

api/localprovider_pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"soundfile==0.13.1",
3333
"tensorboardX==2.6.2.2",
3434
"timm==1.0.15",
35-
"transformerlab==0.1.28",
35+
"transformerlab==0.1.29",
3636
"transformerlab-inference==0.2.52",
3737
"transformers==4.57.1",
3838
"wandb==0.23.1",

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ dependencies = [
2525
"python-dotenv==1.1.0",
2626
"python-multipart==0.0.20",
2727
"sqlalchemy[asyncio]==2.0.40",
28-
"transformerlab==0.1.28",
28+
"transformerlab==0.1.29",
2929
"transformerlab-inference==0.2.52",
3030
"uvicorn==0.35.0",
3131
"watchfiles==1.0.5",

api/transformerlab/routers/experiment/jobs.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,21 @@ async def job_delete(job_id: str, experimentId: str):
131131

132132
@router.put("/{job_id}/job_data")
133133
async def job_update_job_data(job_id: str, experimentId: str, body: dict = Body(...)):
134-
"""Update user-facing metadata fields in job_data (favorite, hidden, tags)."""
134+
"""Update user-facing metadata fields in job_data (favorite, hidden, tags, discard)."""
135135
updates = body.get("updates", {})
136-
ALLOWED_KEYS = {"favorite", "hidden", "tags"}
137-
filtered = {k: v for k, v in updates.items() if k in ALLOWED_KEYS}
136+
allowed_keys = {"favorite", "hidden", "tags", "discard"}
137+
filtered = {k: v for k, v in updates.items() if k in allowed_keys}
138+
139+
# Keep discard under job_data.score.discard to avoid introducing a new top-level field.
140+
if "discard" in filtered:
141+
discard_value = bool(filtered.pop("discard"))
142+
existing_job = await job_service.job_get_cached(job_id, experiment_id=experimentId)
143+
existing_job_data = (existing_job or {}).get("job_data", {}) if isinstance(existing_job, dict) else {}
144+
existing_score = existing_job_data.get("score", {}) if isinstance(existing_job_data, dict) else {}
145+
merged_score = dict(existing_score) if isinstance(existing_score, dict) else {}
146+
merged_score["discard"] = discard_value
147+
filtered["score"] = merged_score
148+
138149
if not filtered:
139150
return {"message": "No valid keys to update"}
140151
await job_service.job_update_job_data_insert_key_values(job_id, filtered, experimentId)

cli/pyproject.toml

Lines changed: 1 addition & 1 deletion
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.44"
7+
version = "0.0.45"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]

cli/src/transformerlab_cli/commands/job.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ def _format_score(score: dict) -> str:
259259
return text
260260

261261

262+
def _is_discarded(job_data: dict) -> bool:
263+
"""Return whether a job is marked discarded via score.discard."""
264+
if not isinstance(job_data, dict):
265+
return False
266+
score = job_data.get("score", {})
267+
if not isinstance(score, dict):
268+
return False
269+
return bool(score.get("discard", False))
270+
271+
262272
def _render_jobs(jobs) -> Table:
263273
"""Make a new table."""
264274
# Create a table to display job details
@@ -279,13 +289,16 @@ def _render_jobs(jobs) -> Table:
279289
# Truncate long descriptions for the table view
280290
if description and len(description) > 40:
281291
description = description[:37] + "…"
292+
completion_status = str(job_data.get("completion_status", "N/A"))
293+
if _is_discarded(job_data):
294+
completion_status = f"{completion_status} (discarded)"
282295
table.add_row(
283296
str(job.get("id", "N/A")),
284297
job.get("experiment_id", "N/A"),
285298
job_data.get("task_name", "N/A"),
286299
job.get("status", "N/A"),
287300
f"{job.get('progress', 0)}%",
288-
job_data.get("completion_status", "N/A"),
301+
completion_status,
289302
description or "",
290303
_format_score(job_data.get("score", {})),
291304
_compute_duration(job_data),
@@ -364,6 +377,7 @@ def _render_job(job) -> None:
364377
"End Time": job_data.get("end_time", "N/A"),
365378
"Duration": _compute_duration(job_data) or "N/A",
366379
"Completion Status": job_data.get("completion_status", "N/A"),
380+
"Discarded": "Yes" if _is_discarded(job_data) else "No",
367381
"Completion Details": job_data.get("completion_details", "N/A"),
368382
"Error": job_data.get("error_msg", ""),
369383
"Config": job_data.get("_config", {}),
@@ -413,6 +427,7 @@ def _sort_key(job):
413427
jobs = sorted(jobs, key=_sort_key)
414428

415429
if output_format == "json":
430+
jobs = [{**job, "discarded": _is_discarded(job.get("job_data", {}))} for job in jobs]
416431
print(json.dumps(jobs))
417432
else:
418433
table = _render_jobs(jobs)
@@ -439,7 +454,7 @@ def info_job(job_id: str, experiment_id: str):
439454

440455
if output_format == "json":
441456
files = _fetch_job_files(experiment_id, job_id)
442-
print(json.dumps({**job, "files": files}))
457+
print(json.dumps({**job, "files": files, "discarded": _is_discarded(job.get("job_data", {}))}))
443458
return
444459

445460
console.print(f"[bold success]Job Details for ID {job_id}:[/bold success]")
@@ -863,6 +878,44 @@ def command_job_logs(
863878
command_job_machine_logs(job_id, follow)
864879

865880

881+
@app.command("discard")
882+
def command_job_discard(
883+
job_id: str = typer.Argument(..., help="Job ID to mark as discarded"),
884+
undo: bool = typer.Option(False, "--undo", help="Unset discard and mark the job as not discarded"),
885+
):
886+
"""Toggle score.discard on a job."""
887+
current_experiment = require_current_experiment()
888+
discard_value = not undo
889+
response = api.put(
890+
f"/experiment/{current_experiment}/jobs/{job_id}/job_data",
891+
json={"updates": {"discard": discard_value}},
892+
)
893+
if response.status_code == 200:
894+
if cli_state.output_format == "json":
895+
print(json.dumps({"job_id": job_id, "discard": discard_value}))
896+
return
897+
if discard_value:
898+
console.print(f"[success]✓[/success] Job [bold]{job_id}[/bold] marked as discarded.")
899+
else:
900+
console.print(f"[success]✓[/success] Job [bold]{job_id}[/bold] unmarked as discarded.")
901+
return
902+
903+
try:
904+
detail = response.json().get("detail", response.text)
905+
except Exception:
906+
detail = response.text
907+
if cli_state.output_format == "json":
908+
print(json.dumps({"error": "Failed to update discard flag", "status_code": response.status_code, "detail": detail}))
909+
else:
910+
console.print(
911+
f"[error]Error:[/error] Failed to update discard flag for job {job_id}. "
912+
f"Status code: {response.status_code}"
913+
)
914+
if detail:
915+
console.print(f"[error]Detail:[/error] {detail}")
916+
raise typer.Exit(1)
917+
918+
866919
@app.command("list")
867920
def command_job_list(
868921
running: bool = typer.Option(

cli/tests/commands/test_job.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"task_name": "eval",
3030
"completion_status": "SUCCESS",
3131
"description": "Eval on test split",
32-
"score": {"eval/loss": 2.1, "accuracy": 0.95},
32+
"score": {"eval/loss": 2.1, "accuracy": 0.95, "discard": True},
3333
"start_time": "2026-04-24 10:00:00",
3434
"end_time": "2026-04-24 10:05:30",
3535
},
@@ -211,6 +211,16 @@ def test_job_list_shows_score(_mock_check, _mock_require, _mock_api):
211211
assert "Score" in out
212212

213213

214+
@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
215+
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
216+
def test_job_info_pretty_shows_discarded(_mock_require, _mock_api):
217+
"""job info pretty output should include discarded status."""
218+
result = runner.invoke(app, ["job", "info", "2"])
219+
assert result.exit_code == 0
220+
out = strip_ansi(result.output)
221+
assert "discarded" in out.lower()
222+
223+
214224
@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
215225
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
216226
@patch("transformerlab_cli.commands.job.check_configs")
@@ -257,6 +267,19 @@ def test_job_info_json_output(_mock_require, _mock_api):
257267
assert data["id"] == 1
258268
assert data["status"] == "RUNNING"
259269
assert data["files"] == [{"name": "out.log", "is_dir": False, "size": 42}]
270+
assert data["discarded"] is False
271+
272+
273+
@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
274+
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
275+
@patch("transformerlab_cli.commands.job.check_configs")
276+
def test_job_list_json_output_includes_discarded(_mock_check, _mock_require, _mock_api):
277+
"""job list --format json should include explicit discarded flag per job."""
278+
result = runner.invoke(app, ["--format", "json", "job", "list"])
279+
assert result.exit_code == 0
280+
data = json.loads(result.output.strip())
281+
assert isinstance(data, list)
282+
assert all("discarded" in job for job in data)
260283

261284

262285
@patch(
@@ -545,6 +568,41 @@ def test_deprecated_logs_still_works(_mock_require, _mock_fetch):
545568
assert "line1" in result.output
546569

547570

571+
# ---------------------------------------------------------------------------
572+
# Discard command tests
573+
# ---------------------------------------------------------------------------
574+
575+
576+
@patch("transformerlab_cli.commands.job.api.put")
577+
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
578+
def test_job_discard_sets_discard_true(_mock_require, mock_put):
579+
mock_resp = MagicMock()
580+
mock_resp.status_code = 200
581+
mock_put.return_value = mock_resp
582+
583+
result = runner.invoke(app, ["job", "discard", "42"])
584+
assert result.exit_code == 0
585+
mock_put.assert_called_once_with(
586+
"/experiment/exp1/jobs/42/job_data",
587+
json={"updates": {"discard": True}},
588+
)
589+
590+
591+
@patch("transformerlab_cli.commands.job.api.put")
592+
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
593+
def test_job_discard_undo_sets_discard_false(_mock_require, mock_put):
594+
mock_resp = MagicMock()
595+
mock_resp.status_code = 200
596+
mock_put.return_value = mock_resp
597+
598+
result = runner.invoke(app, ["job", "discard", "42", "--undo"])
599+
assert result.exit_code == 0
600+
mock_put.assert_called_once_with(
601+
"/experiment/exp1/jobs/42/job_data",
602+
json={"updates": {"discard": False}},
603+
)
604+
605+
548606
# ---------------------------------------------------------------------------
549607
# Publish command tests
550608
# ---------------------------------------------------------------------------

cli/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lab-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab"
7-
version = "0.1.28"
7+
version = "0.1.29"
88
description = "Python SDK for Transformer Lab"
99
readme = "README.md"
1010
requires-python = ">=3.10"

lab-sdk/src/lab/lab_facade.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -638,8 +638,11 @@ def finish(
638638
except Exception:
639639
# Never let optional Trackio integration break finish()
640640
logger.debug("Trackio integration failed during finish()", exc_info=True)
641-
if score is not None:
642-
_run_async(self._job.update_job_data_field("score", score)) # type: ignore[union-attr]
641+
resolved_score: Dict[str, Any] = {}
642+
if isinstance(score, dict):
643+
resolved_score.update(score)
644+
resolved_score.setdefault("discard", False)
645+
_run_async(self._job.update_job_data_field("score", resolved_score)) # type: ignore[union-attr]
643646
if additional_output_path is not None and additional_output_path.strip() != "":
644647
_run_async(self._job.update_job_data_field("additional_output_path", additional_output_path)) # type: ignore[union-attr]
645648
if plot_data_path is not None and plot_data_path.strip() != "":

lab-sdk/tests/test_lab_facade.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,27 @@ def test_lab_finish(tmp_path, monkeypatch):
204204
job_data = lab.get_job_data()
205205
assert job_data["completion_status"] == "success"
206206
assert job_data["completion_details"] == "Job completed"
207-
assert job_data["score"] == {"accuracy": 0.95}
207+
assert job_data["score"] == {"accuracy": 0.95, "discard": False}
208+
209+
210+
def test_lab_finish_preserves_explicit_discard_value(tmp_path, monkeypatch):
211+
_fresh(monkeypatch)
212+
home = tmp_path / ".tfl_home"
213+
ws = tmp_path / ".tfl_ws"
214+
home.mkdir()
215+
ws.mkdir()
216+
monkeypatch.setenv("TFL_HOME_DIR", str(home))
217+
monkeypatch.setenv("TFL_WORKSPACE_DIR", str(ws))
218+
219+
from lab.lab_facade import Lab
220+
221+
lab = Lab()
222+
lab.init(experiment_id="test_exp")
223+
224+
lab.finish(message="Job completed", score={"accuracy": 0.95, "discard": True})
225+
226+
job_data = lab.get_job_data()
227+
assert job_data["score"] == {"accuracy": 0.95, "discard": True}
208228

209229

210230
def test_lab_finish_with_paths(tmp_path, monkeypatch):

0 commit comments

Comments
 (0)