diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index bdf8067e..e2631122 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -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. @@ -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) @@ -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: diff --git a/roboflow/cli/handlers/train.py b/roboflow/cli/handlers/train.py index 8dd7c229..f2a69651 100644 --- a/roboflow/cli/handlers/train.py +++ b/roboflow/cli/handlers/train.py @@ -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. The version's hosted endpoint always serves the + oldest remaining run's model, so deleting the serving run switches + serving to the next-oldest run, or stops it when none survives. + 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, @@ -553,6 +625,135 @@ 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 /'." + elif "MULTIPLE_TRAININGS" in msg: + hint = "This version owns several runs. Pass --training-id (see 'roboflow train list /')." + output_error(args, msg, hint=hint, exit_code=3) + return + + alias_action = (result or {}).get("versionAliasAction") + if alias_action == "repointed": + alias_note = ( + f" Serving for '{project_slug}/{version_str}' switched to " + f"'{(result or {}).get('versionAliasTarget', 'the next-oldest model')}'." + ) + elif alias_action == "deleted": + alias_note = ( + f" No other model remains, so '{project_slug}/{version_str}' stops serving " + "until a new training completes or this run is restored." + ) + else: + alias_note = "" + 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 diff --git a/roboflow/core/training.py b/roboflow/core/training.py index 66880ddc..96e02f7f 100644 --- a/roboflow/core/training.py +++ b/roboflow/core/training.py @@ -261,6 +261,26 @@ 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). The version's hosted + endpoint always serves the oldest remaining run's model: if this run + was serving, serving switches to the next-oldest run (the response + reports ``versionAliasAction: "repointed"`` and the new target) or + stops when no other run survives (``"deleted"``); restoring the + oldest run hands serving back. + """ + 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( { diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 4f5b940c..dee2cce2 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -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). The version's hosted endpoint + always serves the oldest remaining run's model: deleting the serving + run switches serving to the next-oldest run (`versionAliasAction: + "repointed"`) or stops it when no other run survives (`"deleted"`); + restoring the oldest run hands serving 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. diff --git a/tests/cli/test_train_handler.py b/tests/cli/test_train_handler.py index 32503de0..fcb1517b 100644 --- a/tests/cli/test_train_handler.py +++ b/tests/cli/test_train_handler.py @@ -483,6 +483,19 @@ def test_results_help(self) -> None: result = runner.invoke(app, ["train", "results", "--help"]) self.assertEqual(result.exit_code, 0) + def test_delete_help(self) -> None: + result = runner.invoke(app, ["train", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_list_help(self) -> None: + result = runner.invoke(app, ["train", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_restore_help(self) -> None: + result = runner.invoke(app, ["train", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + class TestTrainCancelStopResults(unittest.TestCase): """_cancel / _stop / _results business logic.""" @@ -553,6 +566,158 @@ def test_stop_success(self, mock_stop: MagicMock) -> None: result = json.loads(out) self.assertEqual(result["status"], "stop_requested") + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_success(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = {"deleted": True, "type": "training", "trainingId": "t-1", "trash": True} + out = self._capture_stdout(_delete, self._args(training_id="t-1")) + + mock_delete.assert_called_once_with("test-key", "test-ws", "my-project", "3", training_id="t-1") + result = json.loads(out) + self.assertEqual(result["status"], "in_trash") + self.assertTrue(result["trash"]) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_in_progress_surfaces_hint(self, mock_delete: MagicMock) -> None: + from roboflow.adapters import rfapi + from roboflow.cli.handlers.train import _delete + + mock_delete.side_effect = rfapi.RoboflowError( + "This training is still in progress. Stop or cancel it before deleting it." + ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _delete(self._args(training_id="t-1")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 3) + err = json.loads(buf.getvalue()) + self.assertIn("in progress", err["error"]["message"]) + self.assertIn("train stop", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.delete_version_training") + @patch("roboflow.adapters.rfapi.list_trainings_for_version") + def test_delete_multiple_trainings_hint_points_to_train_list( + self, mock_list: MagicMock, mock_delete: MagicMock + ) -> None: + from roboflow.cli.handlers.train import _delete + + # The sole-run resolution is client-side now: several runs abort the + # delete before any request is made, with the train-list hint. + mock_list.return_value = [{"id": "t-1"}, {"id": "t-2"}] + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _delete(self._args(training_id=None)) + finally: + sys.stderr = old + mock_delete.assert_not_called() + err = json.loads(buf.getvalue()) + self.assertIn("roboflow train list", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.list_trainings_for_version") + def test_list_enumerates_training_ids(self, mock_list: MagicMock) -> None: + from roboflow.cli.handlers.train import _list + + mock_list.return_value = [ + {"id": "t-1", "status": "finished", "modelType": "yolo26n", "modelIds": ["m1"]}, + {"id": "t-2", "status": "stopped", "modelType": "yolo26n", "modelIds": []}, + ] + out = self._capture_stdout(_list, self._args()) + + mock_list.assert_called_once_with("test-key", "test-ws", "my-project", "3") + result = json.loads(out) + self.assertEqual([t["id"] for t in result["trainings"]], ["t-1", "t-2"]) + self.assertIn("t-1", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_notes_the_serving_switch(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = { + "deleted": True, + "type": "training", + "trainingId": "t-1", + "trash": True, + "versionAliasAction": "repointed", + "versionAliasTarget": "test-ws/next-oldest-model", + } + args = self._args(training_id="t-1") + args.json = False + out = self._capture_stdout(_delete, args) + + self.assertIn("switched to", out) + self.assertIn("next-oldest-model", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_notes_the_serving_stop_when_no_model_remains(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = { + "deleted": True, + "type": "training", + "trainingId": "t-1", + "trash": True, + "versionAliasAction": "deleted", + } + args = self._args(training_id="t-1") + args.json = False + out = self._capture_stdout(_delete, args) + + self.assertIn("stops serving", out) + self.assertIn("my-project/3", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_blank_training_id_is_a_structured_usage_error(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _delete(self._args(training_id=" ")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 2) + mock_delete.assert_not_called() + err = json.loads(buf.getvalue()) + self.assertIn("non-empty", err["error"]["message"]) + self.assertIn("--training-id", err["error"].get("hint", "")) + + def test_restore_blank_training_id_is_a_structured_usage_error(self) -> None: + from roboflow.cli.handlers.train import _restore + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _restore(self._args(training_id=" ")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 2) + err = json.loads(buf.getvalue()) + self.assertIn("required", err["error"]["message"]) + self.assertIn("--training-id", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.restore_trash_item") + def test_restore_success(self, mock_restore: MagicMock) -> None: + from roboflow.cli.handlers.train import _restore + + mock_restore.return_value = {"restored": True} + out = self._capture_stdout(_restore, self._args(training_id="t-1")) + + mock_restore.assert_called_once_with("test-key", "test-ws", "training", "t-1") + result = json.loads(out) + self.assertEqual(result["status"], "restored") + @patch("roboflow.adapters.rfapi.get_training_results") def test_results_nas_run(self, mock_get: MagicMock) -> None: from roboflow.cli.handlers.train import _results diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index 30acf5cd..d0106312 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -9,9 +9,12 @@ from roboflow.adapters.rfapi import ( RoboflowError, create_training_v2, + delete_version_training, get_train_recipe, get_training, list_trainings_for_version, + resolve_version_training_id, + restore_trash_item, upload_image, ) from roboflow.config import API_URL, DEFAULT_BATCH_NAME @@ -305,7 +308,7 @@ def test_create_training_v2_raises_on_error(self): @responses.activate def test_list_trainings_for_version_unwraps_trainings_key(self): - payload = {"trainings": [{"trainingId": "t-1"}, {"trainingId": "t-2"}]} + payload = {"trainings": [{"id": "t-1"}, {"id": "t-2"}]} responses.add(responses.GET, self.BASE_URL, json=payload, status=200) result = list_trainings_for_version(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) @@ -348,5 +351,86 @@ def test_get_training_raises_on_error(self): get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="missing") +class TestTrainingTrash(unittest.TestCase): + API_KEY = "test_api_key" + WORKSPACE = "test-ws" + PROJECT = "test-project" + VERSION = "3" + + @responses.activate + def test_delete_version_training_deletes_the_training_resource(self): + expected_url = ( + f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings/t-1?api_key={self.API_KEY}" + ) + payload = { + "deleted": True, + "type": "training", + "workspace": self.WORKSPACE, + "project": self.PROJECT, + "projectId": "ds-1", + "version": self.VERSION, + "trainingId": "t-1", + "trash": True, + } + responses.add(responses.DELETE, expected_url, json=payload, status=200) + + result = delete_version_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="t-1") + + self.assertEqual(result, payload) + + def test_delete_version_training_rejects_blank_training_id(self): + for blank in ["", " "]: + with self.assertRaises(ValueError): + delete_version_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id=blank) + + def test_resolve_version_training_id_returns_supplied_id_without_listing(self): + resolved = resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, "t-9") + self.assertEqual(resolved, "t-9") + + def test_resolve_version_training_id_rejects_blank_id(self): + for blank in ["", " "]: + with self.assertRaises(ValueError): + resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, blank) + + @responses.activate + def test_resolve_version_training_id_resolves_the_sole_run(self): + list_url = f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings?api_key={self.API_KEY}" + responses.add(responses.GET, list_url, json={"trainings": [{"id": "t-1"}]}, status=200) + + resolved = resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(resolved, "t-1") + + @responses.activate + def test_resolve_version_training_id_raises_when_the_version_owns_several_runs(self): + list_url = f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings?api_key={self.API_KEY}" + responses.add( + responses.GET, + list_url, + json={"trainings": [{"id": "t-1"}, {"id": "t-2"}]}, + status=200, + ) + + with self.assertRaises(RoboflowError) as ctx: + resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + self.assertIn("MULTIPLE_TRAININGS", str(ctx.exception)) + self.assertIn("t-2", str(ctx.exception)) + + def test_restore_trash_item_rejects_blank_ids(self): + for blank in ["", " ", None]: + with self.assertRaises(ValueError): + restore_trash_item(self.API_KEY, self.WORKSPACE, "training", blank) + + @responses.activate + def test_restore_trash_item_restores_a_training(self): + expected_url = f"{API_URL}/{self.WORKSPACE}/trash/restore?api_key={self.API_KEY}" + responses.add(responses.POST, expected_url, json={"restored": True}, status=200) + + result = restore_trash_item(self.API_KEY, self.WORKSPACE, "training", "t-1") + + self.assertEqual(json.loads(responses.calls[0].request.body), {"type": "training", "id": "t-1"}) + self.assertEqual(result, {"restored": True}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_training.py b/tests/test_training.py index 89420c1d..b8f32e89 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -117,5 +117,37 @@ def test_models_are_cached_until_refresh(self): self.assertEqual(get_training.call_count, 3) +class TestTrainingTrash(unittest.TestCase): + def test_delete_moves_run_to_trash(self): + training = Training("key", "ws", "proj", "1", {"trainingId": "training-1"}) + + with patch( + "roboflow.core.training.rfapi.delete_version_training", + return_value={"deleted": True, "type": "training", "trainingId": "training-1", "trash": True}, + ) as delete: + result = training.delete() + + delete.assert_called_once_with("key", "ws", "proj", "1", training_id="training-1") + self.assertTrue(result["trash"]) + + def test_restore_rejects_a_blank_training_id(self): + training = Training("key", "ws", "proj", "1", {"trainingId": " "}) + + with self.assertRaises(ValueError): + training.restore() + + def test_restore_goes_through_the_shared_trash_route(self): + training = Training("key", "ws", "proj", "1", {"trainingId": "training-1"}) + + with patch( + "roboflow.core.training.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as restore: + result = training.restore() + + restore.assert_called_once_with("key", "ws", "training", "training-1") + self.assertTrue(result["restored"]) + + if __name__ == "__main__": unittest.main()