Skip to content
65 changes: 63 additions & 2 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,65 @@ def stop_version_training(api_key: str, workspace_url: str, project_url: str, ve
return response.json() if response.content else {"success": True}


def resolve_version_training_id(
api_key: str,
workspace_url: str,
project_url: str,
version: str,
training_id: Optional[str] = None,
) -> str:
"""Resolve the training run a version-scoped call targets.

A supplied id is returned as-is (blank → ``ValueError``). When omitted,
the version's sole run is resolved via ``list_trainings_for_version``;
zero or multiple runs raise with the run ids so the caller can pick one.
"""
if training_id is not None:
if not str(training_id).strip():
raise ValueError("training_id must be a non-empty string when provided")
return training_id
trainings = list_trainings_for_version(api_key, workspace_url, project_url, version)
if not trainings:
raise RoboflowError(f"No training runs found for {project_url}/{version}.")
if len(trainings) > 1:
ids = ", ".join(str(t.get("id")) for t in trainings)
raise RoboflowError(
f"MULTIPLE_TRAININGS: version {project_url}/{version} owns several runs ({ids}); pass training_id."
)
return str(trainings[0].get("id"))


def delete_version_training(
api_key: str,
workspace_url: str,
project_url: str,
version: str,
*,
training_id: str,
):
"""Move a terminal training run to the workspace Trash (soft delete).

DELETE /{workspace}/{project}/{version}/v2/trainings/{training_id} — the
same resource-DELETE pattern as project/version/workflow deletion, and the
same ``{deleted, type, ..., trash: true}`` response shape. The run and
every model it produced disappear from listings but stay restorable for
30 days via ``restore_trash_item(..., "training", ...)`` or the Trash UI,
after which they are permanently deleted. The server refuses in-flight
runs (stop or cancel first) and the run backing the version's registered
model. There is no permanent-delete option on the public API.

``training_id`` is required (it is the resource path). Use
``resolve_version_training_id`` to target a version's sole run.
"""
if not training_id or not str(training_id).strip():
raise ValueError("training_id is required")
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/{training_id}?api_key={api_key}"
response = requests.delete(url)
if not response.ok:
raise RoboflowError(response.text)
return response.json() if response.content else {"deleted": True}


def get_training_results(api_key: str, workspace_url: str, project_url: str, version: str):
"""Run-level training results bundle.

Expand Down Expand Up @@ -169,7 +228,7 @@ def list_trainings_for_version(api_key: str, workspace_url: str, project_url: st
GET /{ws}/{proj}/{version}/v2/trainings. MMPV versions return every run;
SMPV versions return a single entry synthesized from ``version.train``.
Returns the raw ``trainings`` array — each entry carries
``{trainingId, status, modelType, modelGroup, modelIds, start}``.
``{id, versionId, status, start, end, jobType, modelType, modelGroup, modelIds}``.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}"
response = requests.get(url)
Expand Down Expand Up @@ -1419,9 +1478,11 @@ def list_trash(api_key, workspace_url):
def restore_trash_item(api_key, workspace_url, item_type, item_id, parent_id=None):
"""POST /{workspace}/trash/restore — restore an item from Trash.

`item_type` must be one of "project", "version", "workflow".
`item_type` must be one of "project", "version", "workflow", "training".
`parent_id` is required when restoring a version (the parent project id).
"""
if not item_id or not str(item_id).strip():
raise ValueError("item_id is required")
url = f"{API_URL}/{workspace_url}/trash/restore?api_key={api_key}"
payload = {"type": item_type, "id": item_id}
if parent_id is not None:
Expand Down
195 changes: 195 additions & 0 deletions roboflow/cli/handlers/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,78 @@ def stop_training(
_stop(args)


@train_app.command("delete")
def delete_training(
ctx: typer.Context,
target: Annotated[
str,
typer.Argument(help="Training to delete as 'project/version'"),
],
training_id: Annotated[
Optional[str],
typer.Option(
"--training-id",
help=(
"Training id of the run to delete (versions can own several). Omit to target the version's sole run."
),
),
] = None,
) -> None:
"""Move a terminal training run to the workspace Trash (soft delete).

The run and every model it produced disappear from listings but stay
restorable for 30 days ('roboflow train restore' or the web Trash view),
after which they are permanently deleted. In-flight runs are refused —
stop or cancel first. If the run produced the version's currently served
model, that version's hosted inference endpoint stops serving until
another model is trained or the run is restored. Permanent deletion is
only available in the web UI's Trash view.
"""
args = ctx_to_args(ctx, target=target, training_id=training_id)
_delete(args)


@train_app.command("restore")
def restore_training(
ctx: typer.Context,
target: Annotated[
str,
typer.Argument(help="Version the trashed training belongs to, as 'project/version'"),
],
training_id: Annotated[
str,
typer.Option(
"--training-id",
help="Training id of the trashed run to restore (required).",
),
],
) -> None:
"""Restore a trashed training run (and its models) back into listings.

Fails while the parent project or version is itself in Trash — restore
those first ('roboflow trash list' shows what is trashed).
"""
args = ctx_to_args(ctx, target=target, training_id=training_id)
_restore(args)


@train_app.command("list")
def list_trainings(
ctx: typer.Context,
target: Annotated[
str,
typer.Argument(help="Version whose trainings to list, as 'project/version'"),
],
) -> None:
"""List a version's training runs with their ids.

A version may own several training runs; use the TRAINING_ID column with
'roboflow train delete/restore --training-id' or 'train cancel/stop'.
"""
args = ctx_to_args(ctx, target=target)
_list(args)


@train_app.command("results")
def training_results(
ctx: typer.Context,
Expand Down Expand Up @@ -553,6 +625,129 @@ def _stop(args): # noqa: ANN001
)


def _delete(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error

resolved = _resolve_train_target(args)
if resolved is None:
return
api_key, workspace_url, project_slug, version_str = resolved

try:
training_id = rfapi.resolve_version_training_id(
api_key,
workspace_url,
project_slug,
version_str,
getattr(args, "training_id", None),
)
result = rfapi.delete_version_training(
api_key,
workspace_url,
project_slug,
version_str,
training_id=training_id,
)
except ValueError as exc:
output_error(args, str(exc), hint="Pass a non-empty --training-id.", exit_code=2)
return
except rfapi.RoboflowError as exc:
msg = str(exc)
hint = None
if "in progress" in msg:
hint = "Stop or cancel the run first: 'roboflow train stop <project>/<version>'."
elif "MULTIPLE_TRAININGS" in msg:
hint = "This version owns several runs. Pass --training-id (see 'roboflow train list <project>/<version>')."
output_error(args, msg, hint=hint, exit_code=3)
return

alias_note = (
f" This run produced the version's served model, so inference at "
f"'{project_slug}/{version_str}' stops serving until another model is trained "
"or the run is restored."
if (result or {}).get("versionAliasDeleted")
else ""
)
output(
args,
{"status": "in_trash", "project": project_slug, "version": version_str, **(result or {})},
text=(
f"Training moved to Trash for {project_slug} version {version_str}. "
f"Restorable for 30 days via 'roboflow train restore'.{alias_note}"
),
)


def _restore(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error

resolved = _resolve_train_target(args)
if resolved is None:
return
api_key, workspace_url, project_slug, version_str = resolved

try:
result = rfapi.restore_trash_item(api_key, workspace_url, "training", args.training_id)
except ValueError as exc:
output_error(args, str(exc), hint="Pass a non-empty --training-id.", exit_code=2)
return
except rfapi.RoboflowError as exc:
msg = str(exc)
hint = None
# The shared trash route reports a non-trashed id as "not found in
# trash"; the service-level guard says "not in trash". Match both
# before the parent-blocked case, which also mentions "in trash".
if "not found in trash" in msg.lower() or "not in trash" in msg.lower():
hint = "Only trashed runs can be restored. 'roboflow trash list' shows what is trashed."
elif "in trash" in msg.lower():
hint = "Restore the parent project/version first ('roboflow trash list')."
output_error(args, msg, hint=hint, exit_code=3)
return

output(
args,
{"status": "restored", "project": project_slug, "version": version_str, **(result or {})},
text=f"Training restored for {project_slug} version {version_str}.",
)


def _list(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.cli._table import format_table

resolved = _resolve_train_target(args)
if resolved is None:
return
api_key, workspace_url, project_slug, version_str = resolved

try:
trainings = rfapi.list_trainings_for_version(api_key, workspace_url, project_slug, version_str)
except rfapi.RoboflowError as exc:
output_error(args, str(exc), exit_code=3)
return

rows = [
{
"trainingId": t.get("id", ""),
"status": t.get("status", ""),
"modelType": t.get("modelType", ""),
"models": len(t.get("modelIds") or []),
}
for t in trainings
]
table = format_table(
rows,
columns=["trainingId", "status", "modelType", "models"],
headers=["TRAINING_ID", "STATUS", "MODEL_TYPE", "MODELS"],
)
if not rows:
table = "(No trainings on this version)"
output(args, {"trainings": trainings}, text=table)


def _results(args): # noqa: ANN001

from roboflow.adapters import rfapi
Expand Down
19 changes: 19 additions & 0 deletions roboflow/core/training.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,25 @@ def stop(self):
self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id
)

def delete(self):
"""Move this run to the workspace Trash (soft delete).

The run and every model it produced disappear from listings but stay
restorable for 30 days via :meth:`restore` or the Trash UI. The server
refuses in-flight runs (stop or cancel first). If this run produced
the version's currently served model, that version's hosted inference
endpoint stops serving (the response reports
``versionAliasDeleted: true``) until another model is trained on the
version or this run is restored.
"""
return rfapi.delete_version_training(
self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id
)

def restore(self):
"""Restore this run from Trash via the shared workspace trash-restore route."""
return rfapi.restore_trash_item(self.__api_key, self.workspace, "training", self.training_id)

def __str__(self):
return json.dumps(
{
Expand Down
55 changes: 55 additions & 0 deletions roboflow/core/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,61 @@ def restore(self):
parent_id=match.get("parentId"),
)

def delete_training(self, training_id: Optional[str] = None):
"""
Move one of this version's training runs to Trash (soft delete).

The run and every model it produced disappear from listings but stay
restorable for 30 days via `Version.restore_training()` or the Trash
UI, after which they are permanently deleted. The server refuses
in-flight runs (stop or cancel first). If the run produced this
version's currently served model, the version's hosted inference
endpoint stops serving (the response reports
`versionAliasDeleted: True`) until another model is trained or the
run is restored — restoring puts the serving mapping back.

Args:
training_id: Training id of the run to delete (a version can own
several runs). Omit to target the version's sole run — resolved
client-side; several runs raise with their ids listed.

Returns:
dict: Server response with `{deleted: True, type: "training", ..., trash: True}`
(the same shape as project/version/workflow deletion).
"""
resolved_id = rfapi.resolve_version_training_id(
self.__api_key,
self.workspace,
self.project,
self.version,
training_id,
)
return rfapi.delete_version_training(
self.__api_key,
self.workspace,
self.project,
self.version,
training_id=resolved_id,
)

def restore_training(self, training_id: str):
"""
Restore one of this version's trashed training runs.

Goes through the shared workspace trash-restore route (the same one
project/version/workflow restores use) with `type: "training"`.

Args:
training_id: Training id of the trashed run (required — trashed
runs are invisible to the sole-run fallback).

Returns:
dict: Server response from the trash restore route.
"""
if not training_id or not str(training_id).strip():
raise ValueError("training_id is required")
return rfapi.restore_trash_item(self.__api_key, self.workspace, "training", training_id)

def __str__(self):
"""
String representation of version object.
Expand Down
Loading
Loading