Skip to content
Merged
2 changes: 1 addition & 1 deletion api/localprovider_pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies = [
"soundfile==0.13.1",
"tensorboardX==2.6.2.2",
"timm==1.0.15",
"transformerlab==0.1.28",
"transformerlab==0.1.29",
"transformerlab-inference==0.2.52",
"transformers==4.57.1",
"wandb==0.23.1",
Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"python-dotenv==1.1.0",
"python-multipart==0.0.20",
"sqlalchemy[asyncio]==2.0.40",
"transformerlab==0.1.28",
"transformerlab==0.1.29",
"transformerlab-inference==0.2.52",
"uvicorn==0.35.0",
"watchfiles==1.0.5",
Expand Down
17 changes: 14 additions & 3 deletions api/transformerlab/routers/experiment/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,21 @@ async def job_delete(job_id: str, experimentId: str):

@router.put("/{job_id}/job_data")
async def job_update_job_data(job_id: str, experimentId: str, body: dict = Body(...)):
"""Update user-facing metadata fields in job_data (favorite, hidden, tags)."""
"""Update user-facing metadata fields in job_data (favorite, hidden, tags, discard)."""
updates = body.get("updates", {})
ALLOWED_KEYS = {"favorite", "hidden", "tags"}
filtered = {k: v for k, v in updates.items() if k in ALLOWED_KEYS}
allowed_keys = {"favorite", "hidden", "tags", "discard"}
filtered = {k: v for k, v in updates.items() if k in allowed_keys}

# Keep discard under job_data.score.discard to avoid introducing a new top-level field.
if "discard" in filtered:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically this is race. Not sure how important (not super worried about two people flipping discard on same job at the same time, but maybe UI and SDK fighting is possible).

OK I asked Claude for a simple fix and it said to create a function at service later and call from here if "discard" is passed. Something like this (note this is technically still a race, just narrower and cleaner):

async def job_update_job_data_score_field(job_id, score_key: str, value, experiment_id):
    """Merge a single key into job_data.score atomically."""
    resolved_id = await _resolve_full_job_id(str(job_id), str(experiment_id))
    actual_id = resolved_id or str(job_id)
    job = await Job.get(actual_id, experiment_id)

    # Read + merge + write within the same Job instance scope
    current = (await job.get_job_data()) or {}
    score = current.get("score") if isinstance(current, dict) else {}
    score = dict(score) if isinstance(score, dict) else {}
    score[score_key] = value
    await job.update_job_data_field("score", score)

    await cache.invalidate(f"job:{actual_id}")
    await cache.delete(_job_cache_key(actual_id))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed now based on what you recommended

discard_value = bool(filtered.pop("discard"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably fine but as a note "false" will evaluate as true I think.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay fixed this and also found similar things in CLI and frontend, fixed both

Comment thread
dadmobile marked this conversation as resolved.
Outdated
Comment thread
dadmobile marked this conversation as resolved.
Outdated
Comment thread
deep1401 marked this conversation as resolved.
Outdated
existing_job = await job_service.job_get_cached(job_id, experiment_id=experimentId)
existing_job_data = (existing_job or {}).get("job_data", {}) if isinstance(existing_job, dict) else {}
existing_score = existing_job_data.get("score", {}) if isinstance(existing_job_data, dict) else {}
merged_score = dict(existing_score) if isinstance(existing_score, dict) else {}
merged_score["discard"] = discard_value
filtered["score"] = merged_score

if not filtered:
return {"message": "No valid keys to update"}
await job_service.job_update_job_data_insert_key_values(job_id, filtered, experimentId)
Expand Down
2 changes: 1 addition & 1 deletion cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "transformerlab-cli"
version = "0.0.44"
version = "0.0.45"
description = "Transformer Lab CLI"
requires-python = ">=3.10"
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]
Expand Down
87 changes: 78 additions & 9 deletions cli/src/transformerlab_cli/commands/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,17 +248,36 @@ def _compute_duration(job_data: dict) -> str:
return ""


def _format_score(score: dict) -> str:
"""Format a score dict into a compact string for the table view."""
if not score or not isinstance(score, dict):
return ""
parts = [f"{k}={v}" for k, v in score.items() if v is not None]
def _score_items(score) -> list[tuple[str, object]]:
"""Normalize score payload to key/value pairs and hide internal flags."""
if score is None:
return []
if isinstance(score, dict):
return [
(str(key), value) for key, value in score.items() if value is not None and str(key).lower() != "discard"
]
return [("score", score)]


def _format_score(score) -> str:
"""Format score payload into a compact string for the table view."""
parts = [f"{k}={v}" for k, v in _score_items(score)]
text = ", ".join(parts)
if len(text) > 30:
text = text[:27] + "…"
return text


def _is_discarded(job_data: dict) -> bool:
"""Return whether a job is marked discarded via score.discard."""
if not isinstance(job_data, dict):
return False
score = job_data.get("score", {})
if not isinstance(score, dict):
return False
return bool(score.get("discard", False))


def _render_jobs(jobs) -> Table:
"""Make a new table."""
# Create a table to display job details
Expand All @@ -279,13 +298,16 @@ def _render_jobs(jobs) -> Table:
# Truncate long descriptions for the table view
if description and len(description) > 40:
description = description[:37] + "…"
completion_status = str(job_data.get("completion_status", "N/A"))
if _is_discarded(job_data):
completion_status = f"{completion_status} (discarded)"
table.add_row(
str(job.get("id", "N/A")),
job.get("experiment_id", "N/A"),
job_data.get("task_name", "N/A"),
job.get("status", "N/A"),
f"{job.get('progress', 0)}%",
job_data.get("completion_status", "N/A"),
completion_status,
description or "",
_format_score(job_data.get("score", {})),
_compute_duration(job_data),
Expand Down Expand Up @@ -364,6 +386,7 @@ def _render_job(job) -> None:
"End Time": job_data.get("end_time", "N/A"),
"Duration": _compute_duration(job_data) or "N/A",
"Completion Status": job_data.get("completion_status", "N/A"),
"Discarded": "Yes" if _is_discarded(job_data) else "No",
"Completion Details": job_data.get("completion_details", "N/A"),
"Error": job_data.get("error_msg", ""),
"Config": job_data.get("_config", {}),
Expand All @@ -376,8 +399,12 @@ def _render_job(job) -> None:
details["Config"] = f"\n{config_text}"

score = details.pop("Score")
score_text = "\n".join([f" {key}: {value}" for key, value in score.items()])
details["Score"] = f"\n{score_text}"
score_items = _score_items(score)
if score_items:
score_text = "\n".join([f" {key}: {value}" for key, value in score_items])
details["Score"] = f"\n{score_text}"
else:
details["Score"] = "N/A"

# Display details in a panel
detail_text = "\n".join([f"[bold]{key}:[/bold] {value}" for key, value in details.items()])
Expand Down Expand Up @@ -413,6 +440,7 @@ def _sort_key(job):
jobs = sorted(jobs, key=_sort_key)

if output_format == "json":
jobs = [{**job, "discarded": _is_discarded(job.get("job_data", {}))} for job in jobs]
print(json.dumps(jobs))
else:
table = _render_jobs(jobs)
Expand All @@ -439,7 +467,7 @@ def info_job(job_id: str, experiment_id: str):

if output_format == "json":
files = _fetch_job_files(experiment_id, job_id)
print(json.dumps({**job, "files": files}))
print(json.dumps({**job, "files": files, "discarded": _is_discarded(job.get("job_data", {}))}))
return

console.print(f"[bold success]Job Details for ID {job_id}:[/bold success]")
Expand Down Expand Up @@ -863,6 +891,47 @@ def command_job_logs(
command_job_machine_logs(job_id, follow)


@app.command("discard")
def command_job_discard(
job_id: str = typer.Argument(..., help="Job ID to mark as discarded"),
undo: bool = typer.Option(False, "--undo", help="Unset discard and mark the job as not discarded"),
):
"""Toggle score.discard on a job."""
current_experiment = require_current_experiment()
discard_value = not undo
response = api.put(
f"/experiment/{current_experiment}/jobs/{job_id}/job_data",
json={"updates": {"discard": discard_value}},
)
if response.status_code == 200:
if cli_state.output_format == "json":
print(json.dumps({"job_id": job_id, "discard": discard_value}))
return
if discard_value:
console.print(f"[success]✓[/success] Job [bold]{job_id}[/bold] marked as discarded.")
else:
console.print(f"[success]✓[/success] Job [bold]{job_id}[/bold] unmarked as discarded.")
return

try:
detail = response.json().get("detail", response.text)
except Exception:
detail = response.text
if cli_state.output_format == "json":
print(
json.dumps(
{"error": "Failed to update discard flag", "status_code": response.status_code, "detail": detail}
)
)
else:
console.print(
f"[error]Error:[/error] Failed to update discard flag for job {job_id}. Status code: {response.status_code}"
)
if detail:
console.print(f"[error]Detail:[/error] {detail}")
raise typer.Exit(1)


@app.command("list")
def command_job_list(
running: bool = typer.Option(
Expand Down
75 changes: 74 additions & 1 deletion cli/tests/commands/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"task_name": "eval",
"completion_status": "SUCCESS",
"description": "Eval on test split",
"score": {"eval/loss": 2.1, "accuracy": 0.95},
"score": {"eval/loss": 2.1, "accuracy": 0.95, "discard": True},
"start_time": "2026-04-24 10:00:00",
"end_time": "2026-04-24 10:05:30",
},
Expand Down Expand Up @@ -209,6 +209,31 @@ def test_job_list_shows_score(_mock_check, _mock_require, _mock_api):
assert "eval/" in out
# Job 1 (no score) should have empty score column — just verify Score header is present
assert "Score" in out
# discard flag should not be shown as a score metric
assert "discard" not in out.lower()


@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
def test_job_info_pretty_shows_discarded(_mock_require, _mock_api):
"""job info pretty output should include discarded status."""
result = runner.invoke(app, ["job", "info", "2"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "discarded" in out.lower()


@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
def test_job_info_pretty_shows_score_without_discard(_mock_require, _mock_api):
"""job info pretty output should show score metrics and hide score.discard."""
result = runner.invoke(app, ["job", "info", "2"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Score:" in out
assert "eval/loss" in out
assert "accuracy" in out
assert "discard: True" not in out


@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
Expand Down Expand Up @@ -257,6 +282,19 @@ def test_job_info_json_output(_mock_require, _mock_api):
assert data["id"] == 1
assert data["status"] == "RUNNING"
assert data["files"] == [{"name": "out.log", "is_dir": False, "size": 42}]
assert data["discarded"] is False


@patch("transformerlab_cli.commands.job.api.get", return_value=_mock_api_response(SAMPLE_JOBS))
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
@patch("transformerlab_cli.commands.job.check_configs")
def test_job_list_json_output_includes_discarded(_mock_check, _mock_require, _mock_api):
"""job list --format json should include explicit discarded flag per job."""
result = runner.invoke(app, ["--format", "json", "job", "list"])
assert result.exit_code == 0
data = json.loads(result.output.strip())
assert isinstance(data, list)
assert all("discarded" in job for job in data)


@patch(
Expand Down Expand Up @@ -545,6 +583,41 @@ def test_deprecated_logs_still_works(_mock_require, _mock_fetch):
assert "line1" in result.output


# ---------------------------------------------------------------------------
# Discard command tests
# ---------------------------------------------------------------------------


@patch("transformerlab_cli.commands.job.api.put")
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
def test_job_discard_sets_discard_true(_mock_require, mock_put):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_put.return_value = mock_resp

result = runner.invoke(app, ["job", "discard", "42"])
assert result.exit_code == 0
mock_put.assert_called_once_with(
"/experiment/exp1/jobs/42/job_data",
json={"updates": {"discard": True}},
)


@patch("transformerlab_cli.commands.job.api.put")
@patch("transformerlab_cli.commands.job.require_current_experiment", return_value="exp1")
def test_job_discard_undo_sets_discard_false(_mock_require, mock_put):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_put.return_value = mock_resp

result = runner.invoke(app, ["job", "discard", "42", "--undo"])
assert result.exit_code == 0
mock_put.assert_called_once_with(
"/experiment/exp1/jobs/42/job_data",
json={"updates": {"discard": False}},
)


# ---------------------------------------------------------------------------
# Publish command tests
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion cli/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lab-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "transformerlab"
version = "0.1.28"
version = "0.1.29"
description = "Python SDK for Transformer Lab"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
7 changes: 5 additions & 2 deletions lab-sdk/src/lab/lab_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,8 +638,11 @@ def finish(
except Exception:
# Never let optional Trackio integration break finish()
logger.debug("Trackio integration failed during finish()", exc_info=True)
if score is not None:
_run_async(self._job.update_job_data_field("score", score)) # type: ignore[union-attr]
resolved_score: Dict[str, Any] = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is OK but just double checking:

We don't set score anywhere earlier? I just noticed that if you call finish without a score I think it will overwrite if there's anythign in there already. But I think that's fine?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No score is only set by finish, nowhere before that

if isinstance(score, dict):
resolved_score.update(score)
resolved_score.setdefault("discard", False)
_run_async(self._job.update_job_data_field("score", resolved_score)) # type: ignore[union-attr]
if additional_output_path is not None and additional_output_path.strip() != "":
_run_async(self._job.update_job_data_field("additional_output_path", additional_output_path)) # type: ignore[union-attr]
if plot_data_path is not None and plot_data_path.strip() != "":
Expand Down
22 changes: 21 additions & 1 deletion lab-sdk/tests/test_lab_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,27 @@ def test_lab_finish(tmp_path, monkeypatch):
job_data = lab.get_job_data()
assert job_data["completion_status"] == "success"
assert job_data["completion_details"] == "Job completed"
assert job_data["score"] == {"accuracy": 0.95}
assert job_data["score"] == {"accuracy": 0.95, "discard": False}


def test_lab_finish_preserves_explicit_discard_value(tmp_path, monkeypatch):
_fresh(monkeypatch)
home = tmp_path / ".tfl_home"
ws = tmp_path / ".tfl_ws"
home.mkdir()
ws.mkdir()
monkeypatch.setenv("TFL_HOME_DIR", str(home))
monkeypatch.setenv("TFL_WORKSPACE_DIR", str(ws))

from lab.lab_facade import Lab

lab = Lab()
lab.init(experiment_id="test_exp")

lab.finish(message="Job completed", score={"accuracy": 0.95, "discard": True})

job_data = lab.get_job_data()
assert job_data["score"] == {"accuracy": 0.95, "discard": True}


def test_lab_finish_with_paths(tmp_path, monkeypatch):
Expand Down
Loading
Loading