diff --git a/CHANGELOG.md b/CHANGELOG.md index df7db79c..f76ed7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ All notable changes to this project will be documented in this file. +## 1.3.7 + +### Added — Soft-delete / Trash support + +Mirrors the soft-delete and Trash features added to the Roboflow web app +([roboflow/roboflow#11131](https://github.com/roboflow/roboflow/pull/11131)). +Deleting a project, version, or workflow now moves it to Trash with a +30-day retention window (and cancels any in-flight training jobs); items +can be restored within that window. Companion docs: +[roboflow-dev-reference#5](https://github.com/roboflow/roboflow-dev-reference/pull/5). + +**SDK (`roboflow/`):** +- `Project.delete()` / `Project.restore()` — soft-delete and restore by slug. +- `Version.delete()` / `Version.restore()` — same shape on a version handle. +- `Workspace.trash()` — list everything currently in a workspace's Trash, grouped by `projects` / `versions` / `workflows`. +- `Workspace.restore_from_trash(item_type, item_id, parent_id=None)` — restore an item by id when you don't have a live SDK handle (or for workflows, which don't have a first-class object yet). + +**CLI (`roboflow/cli/`):** +- `roboflow project delete` / `roboflow project restore` +- `roboflow version delete` / `roboflow version restore` +- `roboflow workflow delete` / `roboflow workflow restore` +- `roboflow trash list` + +Destructive commands prompt for confirmation interactively and accept +`--yes` / `-y` for scripted use. Every command supports `--json` for +structured output and emits actionable error hints with stable exit codes. + +**Low-level (`roboflow.adapters.rfapi`):** +- `delete_project`, `delete_version`, `delete_workflow`, `list_trash`, `restore_trash_item`. +- `RoboflowError` messages now extract the `error` field from JSON response bodies (e.g. "Not authorized to view trash") instead of the raw response text. + +**Permanent deletion is intentionally web-UI-only.** Emptying Trash or +immediately deleting a single Trash item destroys data irrecoverably, so +those actions are not exposed on the SDK or CLI — they live only in the +Roboflow app's Trash view, which has an explicit confirmation dialog. +Items left in Trash are cleaned up automatically after 30 days. + +### Changed — Image upload no longer re-encodes images + +`upload_image` now uploads original image bytes instead of re-encoding to +JPEG client-side ([#464](https://github.com/roboflow/roboflow-python/pull/464)). + +### Backward compatibility + +Purely additive on the public API surface. The new endpoints require +`project:update`, `version:update`, or `workflow:update` scopes — most +existing keys already have these. + ## 1.1.50 - Added support for Palligema2 model uploads via `upload_model` command with the following model types: diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index fbd155ce..a733a840 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -101,6 +101,34 @@ roboflow workflow fork other-ws/their-workflow roboflow version create -p my-project --settings settings.json ``` +### Delete and restore (soft delete / Trash) + +```bash +# Move a project to Trash — any in-flight training jobs are cancelled automatically. +# Items stay in Trash for 30 days, then are permanently cleaned up. +roboflow project delete my-workspace/my-project +roboflow project restore my-workspace/my-project + +# Same flow for versions (also cancels in-flight training on the version). +roboflow version delete my-workspace/my-project/3 +roboflow version restore my-workspace/my-project/3 + +# Same flow for workflows. +roboflow workflow delete my-workflow +roboflow workflow restore my-workflow + +# Inspect what's currently in Trash. +roboflow trash list + +# Skip the confirmation prompt for scripts. +roboflow project delete my-workspace/my-project --yes +``` + +Permanent deletion (emptying Trash or skipping the retention window for a +single item) is intentionally not available from the SDK or CLI — those +actions destroy data irrecoverably and live only in the web UI's Trash +view. Items left in Trash are cleaned up automatically after 30 days. + ### Workspace stats and billing ```bash @@ -177,6 +205,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between | `workflow` | Manage workflows | | `folder` | Manage workspace folders | | `annotation` | Annotation batches and jobs | +| `trash` | List items in Trash | | `universe` | Search Roboflow Universe | | `video` | Video inference | | `batch` | Batch processing jobs *(coming soon)* | diff --git a/roboflow/__init__.py b/roboflow/__init__.py index 1783244c..cb6c78d3 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -21,7 +21,7 @@ CLIPModel = None # type: ignore[assignment,misc] GazeModel = None # type: ignore[assignment,misc] -__version__ = "1.3.6" +__version__ = "1.3.7" def check_key(api_key, model, notebook, num_retries=0): diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index dd1c36d2..e56c0308 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -849,3 +849,102 @@ def search_universe(query, *, api_key=None, project_type=None, limit=12, page=1) if response.status_code != 200: raise RoboflowError(response.text) return response.json() + + +# --------------------------------------------------------------------------- +# Soft-delete / Trash operations +# --------------------------------------------------------------------------- + + +def _raise_for_trash_response(response): + """Raise RoboflowError with the cleanest message available. + + Backend trash endpoints return `{"error": "..."}` JSON on non-2xx. + Surface that string to the caller instead of the raw response body so + error messages are agent-friendly. Falls back to the raw text if the + body isn't JSON or doesn't contain an `error` field. + + The single `raise` at the end means we can't accidentally swallow the + intended error if a future refactor widens the except clause. + """ + msg = None + try: + body = response.json() + if isinstance(body, dict): + msg = body.get("error") + except ValueError: + # Body wasn't JSON — fall through to response.text. + pass + raise RoboflowError(msg or response.text) + + +def delete_project(api_key, workspace_url, project_url): + """DELETE /{workspace}/{project} — move a project to Trash (30-day retention). + + Any in-flight training jobs for the project will be cancelled automatically. + The project can be restored via `restore_trash_item` within the retention + window; after 30 days the cleanup cron permanently removes it. + """ + url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def delete_version(api_key, workspace_url, project_url, version): + """DELETE /{workspace}/{project}/{version} — move a version to Trash. + + Any in-flight training on the version will be cancelled automatically. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def delete_workflow(api_key, workspace_url, workflow_url): + """DELETE /{workspace}/workflows/{workflowUrl} — move a workflow to Trash + (30-day retention). Restore via `restore_trash_item(..., "workflow", ...)`. + """ + url = f"{API_URL}/{workspace_url}/workflows/{workflow_url}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def list_trash(api_key, workspace_url): + """GET /{workspace}/trash — list items currently in Trash. + + Returns a dict with `items` (flat list) and `sections` (grouped by type: + `datasets`, `versions`, `workflows`). Each item includes `id`, `type`, + `name`, `deletedAt`, `scheduledCleanupAt`, and (for versions) `parentId`. + """ + url = f"{API_URL}/{workspace_url}/trash?api_key={api_key}" + response = requests.get(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +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". + `parent_id` is required when restoring a version (the parent project id). + """ + 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: + payload["parentId"] = parent_id + response = requests.post(url, json=payload) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +# Note: permanent-delete from Trash (deleteImmediately / empty) is +# intentionally not exposed on the public API — those actions destroy data +# irrecoverably and are only available through the web UI's Trash view. diff --git a/roboflow/cli/__init__.py b/roboflow/cli/__init__.py index 45d4cce8..99604acb 100644 --- a/roboflow/cli/__init__.py +++ b/roboflow/cli/__init__.py @@ -182,6 +182,7 @@ def _walk(group: Any, prefix: str = "") -> None: from roboflow.cli.handlers.project import project_app # noqa: E402 from roboflow.cli.handlers.search import search_command # noqa: E402 from roboflow.cli.handlers.train import train_app # noqa: E402 +from roboflow.cli.handlers.trash import trash_app # noqa: E402 from roboflow.cli.handlers.universe import universe_app # noqa: E402 from roboflow.cli.handlers.version import version_app # noqa: E402 from roboflow.cli.handlers.video import video_app # noqa: E402 @@ -208,6 +209,7 @@ def _walk(group: Any, prefix: str = "") -> None: search_command(app) app.add_typer(train_app, name="train") +app.add_typer(trash_app, name="trash") app.add_typer(universe_app, name="universe") app.add_typer(version_app, name="version") app.add_typer(video_app, name="video") diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py index c636e123..0b9d6125 100644 --- a/roboflow/cli/handlers/project.py +++ b/roboflow/cli/handlers/project.py @@ -57,6 +57,27 @@ def create_project( _create_project(args) +@project_app.command("delete") +def delete_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a project to Trash (30-day retention; cancels in-flight trainings).""" + args = ctx_to_args(ctx, project_id=project_id, yes=yes) + _delete_project(args) + + +@project_app.command("restore") +def restore_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], +) -> None: + """Restore a project from Trash.""" + args = ctx_to_args(ctx, project_id=project_id) + _restore_project(args) + + # --------------------------------------------------------------------------- # Business logic (unchanged from argparse version) # --------------------------------------------------------------------------- @@ -198,3 +219,121 @@ def _create_project(args): # noqa: ANN001 "type": project.type, } output(args, data, text=f"Created project: {project.name} ({project.id})") + + +def _delete_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + if not getattr(args, "yes", False) and not getattr(args, "json", False): + import typer + + confirmed = typer.confirm( + f"Move '{workspace_url}/{project_slug}' to Trash? " + "(Retained for 30 days. Any in-flight trainings will be cancelled.)", + default=False, + ) + if not confirmed: + output(args, {"cancelled": True}, text="Cancelled.") + return + + try: + data = rfapi.delete_project(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:update' scope on this workspace.", + exit_code=3, + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{project_slug} to Trash (30-day retention).", + ) + + +def _restore_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:read' scope on this workspace.", + exit_code=3, + ) + return + + projects = trash.get("sections", {}).get("projects", []) + match = next((p for p in projects if p.get("url") == project_slug), None) + if not match: + output_error( + args, + f"Project '{workspace_url}/{project_slug}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item(api_key, workspace_url, "project", match["id"]) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:update' scope on this workspace.", + exit_code=3, + ) + return + + output(args, data, text=f"Restored {workspace_url}/{project_slug} from Trash.") diff --git a/roboflow/cli/handlers/trash.py b/roboflow/cli/handlers/trash.py new file mode 100644 index 00000000..7c7a3815 --- /dev/null +++ b/roboflow/cli/handlers/trash.py @@ -0,0 +1,98 @@ +"""Trash management commands. + +Only `list` is exposed here — permanent-delete actions (empty Trash, delete a +single Trash item immediately) destroy data irrecoverably and are available +only through the web UI's Trash view. Items left in Trash are cleaned up +automatically after 30 days. +""" + +from __future__ import annotations + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +trash_app = typer.Typer(cls=SortedGroup, help="Manage items in Trash", no_args_is_help=True) + + +@trash_app.command("list") +def list_trash_cmd(ctx: typer.Context) -> None: + """List projects, versions, and workflows currently in Trash.""" + args = ctx_to_args(ctx) + _list_trash(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _resolve_workspace(args): + from roboflow.cli._output import output_error + from roboflow.cli._resolver import resolve_default_workspace + from roboflow.config import load_roboflow_api_key + + workspace_url = args.workspace or resolve_default_workspace(api_key=args.api_key) + if not workspace_url: + output_error(args, "No workspace specified.", hint="Use --workspace or run 'roboflow auth login'.") + return None, None + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return None, None + + return workspace_url, api_key + + +def _list_trash(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + workspace_url, api_key = _resolve_workspace(args) + if not workspace_url: + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:read' scope on this workspace.", + exit_code=3, + ) + return + + items = trash.get("items", []) + rows = [] + for item in items: + name = item.get("name", "") + if item.get("type") == "version": + parent = item.get("parentName") or item.get("parentUrl") or "" + name = f"{parent} — {name} (v{item.get('id', '')})" + rows.append( + { + "type": item.get("type", ""), + "id": item.get("id", ""), + "name": name, + "deletedAt": item.get("deletedAt", ""), + "scheduledCleanupAt": item.get("scheduledCleanupAt", ""), + "deletedBy": item.get("deletedByName") or item.get("deletedBy", ""), + } + ) + + table = format_table( + rows, + columns=["type", "id", "name", "deletedAt", "scheduledCleanupAt", "deletedBy"], + headers=["TYPE", "ID", "NAME", "DELETED", "CLEANUP_AT", "BY"], + ) + if not rows: + table = "(Trash is empty)" + output(args, trash, text=table) diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index 5888c42d..275bc6ce 100644 --- a/roboflow/cli/handlers/version.py +++ b/roboflow/cli/handlers/version.py @@ -78,6 +78,33 @@ def create( _create(args) +@version_app.command("delete") +def delete_version( + ctx: typer.Context, + version_ref: Annotated[ + str, + typer.Argument(help="Version shorthand (e.g. ws/project/3 or project/3)"), + ], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a version to Trash (30-day retention; cancels its in-flight training).""" + args = ctx_to_args(ctx, version_ref=version_ref, yes=yes) + _delete_version(args) + + +@version_app.command("restore") +def restore_version_cmd( + ctx: typer.Context, + version_ref: Annotated[ + str, + typer.Argument(help="Version shorthand (e.g. ws/project/3 or project/3)"), + ], +) -> None: + """Restore a version from Trash (parent project must be active).""" + args = ctx_to_args(ctx, version_ref=version_ref) + _restore_version(args) + + # --------------------------------------------------------------------------- # Business logic (unchanged from argparse version) # --------------------------------------------------------------------------- @@ -325,3 +352,152 @@ def _create(args): # noqa: ANN001 data = {"status": "created", "project": project_slug, "version": version_num} output(args, data, text=f"Created version {version_num} for project {project_slug}") + + +def _delete_version(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'workspace/project/3' or 'project/3' (version must be a number).", + ) + return + + if version_num is None: + output_error( + args, + "Version number is required.", + hint="Pass 'project/3' or 'workspace/project/3' — the trailing segment must be the numeric version id.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + if not getattr(args, "yes", False) and not getattr(args, "json", False): + import typer + + confirmed = typer.confirm( + f"Move version '{workspace_url}/{project_slug}/{version_num}' to Trash? " + "(Retained for 30 days. Any in-flight training will be cancelled.)", + default=False, + ) + if not confirmed: + output(args, {"cancelled": True}, text="Cancelled.") + return + + try: + data = rfapi.delete_version(api_key, workspace_url, project_slug, version_num) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'version:update' scope and the version exists.", + exit_code=3, + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{project_slug}/{version_num} to Trash.", + ) + + +def _restore_version(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'workspace/project/3' or 'project/3' (version must be a number).", + ) + return + + if version_num is None: + output_error( + args, + "Version number is required.", + hint="Pass 'project/3' or 'workspace/project/3' — the trailing segment must be the numeric version id.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:read' scope on this workspace.", + exit_code=3, + ) + return + + versions = trash.get("sections", {}).get("versions", []) + target = str(version_num) + match = next( + (v for v in versions if str(v.get("id")) == target and v.get("parentUrl") == project_slug), + None, + ) + if not match: + output_error( + args, + f"Version '{workspace_url}/{project_slug}/{version_num}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored. " + "If the parent project is also in Trash, restore the project first.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item( + api_key, + workspace_url, + "version", + match["id"], + parent_id=match.get("parentId"), + ) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'version:update' scope on this workspace.", + exit_code=3, + ) + return + + output( + args, + data, + text=f"Restored {workspace_url}/{project_slug}/{version_num} from Trash.", + ) diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py index 82085dca..cd0be353 100644 --- a/roboflow/cli/handlers/workflow.py +++ b/roboflow/cli/handlers/workflow.py @@ -109,6 +109,27 @@ def deploy_workflow( _stub_deploy(args) +@workflow_app.command("delete") +def delete_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a workflow to Trash (30-day retention).""" + args = ctx_to_args(ctx, workflow_url=workflow_url, yes=yes) + _delete_workflow(args) + + +@workflow_app.command("restore") +def restore_workflow_cmd( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Restore a workflow from Trash.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _restore_workflow(args) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -403,3 +424,88 @@ def _stub_deploy(args) -> None: # noqa: ANN001 "This command is not yet implemented.", hint="Coming in a future release.", ) + + +def _delete_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + if not getattr(args, "yes", False) and not getattr(args, "json", False): + confirmed = typer.confirm( + f"Move workflow '{workspace_url}/{args.workflow_url}' to Trash? (Retained for 30 days.)", + default=False, + ) + if not confirmed: + output(args, {"cancelled": True}, text="Cancelled.") + return + + try: + data = rfapi.delete_workflow(api_key, workspace_url, args.workflow_url) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'workflow:update' scope on this workspace.", + exit_code=3, + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{args.workflow_url} to Trash (30-day retention).", + ) + + +def _restore_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'project:read' scope on this workspace.", + exit_code=3, + ) + return + + workflows = trash.get("sections", {}).get("workflows", []) + # Match on URL first, fall back to id for callers who pass a Firestore id. + match = next( + (w for w in workflows if w.get("url") == args.workflow_url or w.get("id") == args.workflow_url), + None, + ) + if not match: + output_error( + args, + f"Workflow '{workspace_url}/{args.workflow_url}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item(api_key, workspace_url, "workflow", match["id"]) + except rfapi.RoboflowError as exc: + output_error( + args, + str(exc), + hint="Check your API key has 'workflow:update' scope on this workspace.", + exit_code=3, + ) + return + + output(args, data, text=f"Restored {workspace_url}/{args.workflow_url} from Trash.") diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 8a287fc4..a1acd55a 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1041,3 +1041,46 @@ def delete_images(self, image_ids: List[str]): raise RuntimeError(response.text) except ValueError: raise RuntimeError(f"Failed to delete images: {response.text}") + + def delete(self): + """ + Move this project to Trash (soft delete). + + The project is hidden from the workspace but retained for 30 days. Any + in-flight training jobs for the project are cancelled. Within the 30-day + window you can restore it via `Project.restore()` or from the Trash UI. + + Returns: + dict: Server response with `{deleted: True, type: "project", ...}`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.delete() + """ + return rfapi.delete_project(self.__api_key, self.__workspace, self.__project_name) + + def restore(self): + """ + Restore this project from Trash. + + Looks up the project in the workspace Trash by its slug. Raises + RuntimeError if the project isn't currently in Trash. + + Returns: + dict: Server response with `{restored: True, type: "project", ...}`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.delete() + >>> project.restore() + """ + trash = rfapi.list_trash(self.__api_key, self.__workspace) + projects = trash.get("sections", {}).get("projects", []) + match = next((p for p in projects if p.get("url") == self.__project_name), None) + if not match: + raise RuntimeError(f"Project '{self.__project_name}' is not in Trash — nothing to restore.") + return rfapi.restore_trash_item(self.__api_key, self.__workspace, "project", match["id"]) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 92817bff..fd4a3f5f 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -680,6 +680,50 @@ def data_yaml_callback(content: dict) -> dict: if format in ["yolov5pytorch", "mt-yolov6", "yolov7pytorch", "yolov8", "yolov9"]: amend_data_yaml(path=data_path, callback=data_yaml_callback) + def delete(self): + """ + Move this version to Trash (soft delete). + + Any in-flight training job on the version is cancelled. The version is + retained for 30 days and can be restored via `Version.restore()` or the + Trash UI. + + Returns: + dict: Server response with `{deleted: True, type: "version", ...}`. + """ + return rfapi.delete_version(self.__api_key, self.workspace, self.project, self.version) + + def restore(self): + """ + Restore this version from Trash. + + Looks up the version in the workspace Trash by (project, version id). + Raises RuntimeError if it isn't currently in Trash. The parent project + must not itself be in Trash. + + Returns: + dict: Server response with `{restored: True, type: "version", ...}`. + """ + trash = rfapi.list_trash(self.__api_key, self.workspace) + versions = trash.get("sections", {}).get("versions", []) + # `self.project` is the project URL slug (set by Project at init time + # from `a_project["id"].rsplit("/")[1]`), so we match against + # `parentUrl`. The trash payload's `parentId` is the Firestore doc id, + # which the SDK never holds — no need for a fallback. + match = next( + (v for v in versions if str(v.get("id")) == str(self.version) and v.get("parentUrl") == self.project), + None, + ) + if not match: + raise RuntimeError(f"Version '{self.project}/{self.version}' is not in Trash — nothing to restore.") + return rfapi.restore_trash_item( + self.__api_key, + self.workspace, + "version", + match["id"], + parent_id=match.get("parentId"), + ) + def __str__(self): """ String representation of version object. diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index 31f9e5a6..965b51b7 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1333,6 +1333,45 @@ def upload_vision_event_image( metadata=metadata, ) + def trash(self) -> dict: + """ + List items currently in the workspace Trash. + + Returns a dict with: + - `items`: flat list of everything in Trash + - `sections`: grouped by `projects`, `versions`, `workflows` + Each item includes `id`, `type`, `name`, `deletedAt`, + `scheduledCleanupAt`, and — for versions — `parentId` / `parentUrl`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> ws = rf.workspace() + >>> trash = ws.trash() + >>> for item in trash["items"]: + ... print(item["type"], item["name"]) + """ + return rfapi.list_trash(self.__api_key, self.url) + + def restore_from_trash(self, item_type: str, item_id: str, parent_id: Optional[str] = None): + """ + Restore an item from Trash. + + Args: + item_type: one of "project", "version", "workflow" + item_id: the item's Firestore id (found via `trash()`) + parent_id: required when restoring a version — the parent project id + + Returns: + dict: Server response with `{restored: True, type, id}`. + """ + return rfapi.restore_trash_item(self.__api_key, self.url, item_type, item_id, parent_id) + + # Permanent-delete actions (empty trash / delete a single trash item + # immediately) are intentionally not exposed in the SDK — they destroy + # data irrecoverably and are only available through the web UI's Trash + # view. Items left in Trash are cleaned up automatically after 30 days. + def __str__(self): projects = self.projects() json_value = {"name": self.name, "url": self.url, "projects": projects} diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py index e217a608..628ba473 100644 --- a/tests/cli/test_project_handler.py +++ b/tests/cli/test_project_handler.py @@ -30,12 +30,98 @@ def test_project_create_exists(self) -> None: self.assertEqual(result.exit_code, 0) self.assertIn("type", result.output.lower()) + def test_project_delete_exists(self) -> None: + result = runner.invoke(app, ["project", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_project_restore_exists(self) -> None: + result = runner.invoke(app, ["project", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + def test_subcommands_visible(self) -> None: result = runner.invoke(app, ["project", "--help"]) self.assertEqual(result.exit_code, 0) self.assertIn("list", result.output) self.assertIn("get", result.output) self.assertIn("create", result.output) + self.assertIn("delete", result.output) + self.assertIn("restore", result.output) + + +class TestProjectDeleteHandler(unittest.TestCase): + """project delete calls rfapi.delete_project and honors --yes.""" + + def _args(self, project_id="my-ws/my-proj"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + project_id=project_id, + yes=True, + ) + + def test_delete_calls_rfapi(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _delete_project + + with patch("roboflow.adapters.rfapi.delete_project", return_value={"deleted": True}) as mock_del: + _delete_project(self._args()) + mock_del.assert_called_once_with("fake-key", "my-ws", "my-proj") + + +class TestProjectRestoreHandler(unittest.TestCase): + """project restore looks up the item in Trash by URL, then restores.""" + + def _args(self, project_id="my-ws/my-proj"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + project_id=project_id, + ) + + def test_restore_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _restore_project + + trash = {"sections": {"projects": [{"id": "abc123", "url": "my-proj", "name": "My Project"}]}} + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True, "type": "project", "id": "abc123"}, + ) as mock_restore, + ): + _restore_project(self._args()) + mock_restore.assert_called_once_with("fake-key", "my-ws", "project", "abc123") + + def test_restore_not_in_trash(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _restore_project + + # Trash doesn't contain this project — handler should error without + # calling restore_trash_item. + with ( + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"sections": {"projects": []}}, + ), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + patch("sys.exit"), + ): + _restore_project(self._args()) + mock_restore.assert_not_called() if __name__ == "__main__": diff --git a/tests/cli/test_trash_handler.py b/tests/cli/test_trash_handler.py new file mode 100644 index 00000000..996508aa --- /dev/null +++ b/tests/cli/test_trash_handler.py @@ -0,0 +1,141 @@ +"""Tests for the trash CLI handler.""" + +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestTrashRegistration(unittest.TestCase): + """Verify trash handler registers expected subcommands.""" + + def test_trash_app_exists(self) -> None: + from roboflow.cli.handlers.trash import trash_app + + self.assertIsNotNone(trash_app) + + def test_trash_list_exists(self) -> None: + result = runner.invoke(app, ["trash", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_permanent_delete_commands_not_exposed(self) -> None: + # empty / delete immediately are intentionally not available on the + # SDK/CLI — they exist only in the web UI. Guard against regression. + empty_result = runner.invoke(app, ["trash", "empty", "--help"]) + self.assertNotEqual(empty_result.exit_code, 0) + delete_result = runner.invoke(app, ["trash", "delete", "--help"]) + self.assertNotEqual(delete_result.exit_code, 0) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["trash", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + # empty / delete should NOT appear in the command group + self.assertNotIn("empty", result.output.lower()) + + +def _args(**overrides): + base = { + "json": False, + "workspace": None, + "api_key": "fake-key", + "quiet": False, + } + base.update(overrides) + return Namespace(**base) + + +class TestTrashListHandler(unittest.TestCase): + """trash list calls rfapi.list_trash and formats the output.""" + + def test_list_text_output(self) -> None: + from roboflow.cli.handlers.trash import _list_trash + + trash_response = { + "items": [ + { + "type": "project", + "id": "d1", + "name": "My Project", + "deletedAt": "2026-04-01", + "scheduledCleanupAt": "2026-05-01", + "deletedByName": "Alice", + }, + { + "type": "version", + "id": "3", + "name": "v3", + "parentName": "My Project", + "parentUrl": "my-proj", + "deletedAt": "2026-04-02", + "scheduledCleanupAt": "2026-05-02", + "deletedByName": "Bob", + }, + ], + "sections": {}, + } + with ( + patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws"), + patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"), + patch("roboflow.adapters.rfapi.list_trash", return_value=trash_response) as mock_list, + patch("builtins.print") as mock_print, + ): + _list_trash(_args()) + mock_list.assert_called_once_with("fake-key", "test-ws") + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + self.assertIn("My Project", printed) + self.assertIn("v3", printed) + + def test_list_empty(self) -> None: + from roboflow.cli.handlers.trash import _list_trash + + with ( + patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws"), + patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"), + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"items": [], "sections": {}}, + ), + patch("builtins.print") as mock_print, + ): + _list_trash(_args()) + printed = mock_print.call_args[0][0] + self.assertIn("empty", printed.lower()) + + +class TestRfapiSurface(unittest.TestCase): + """Guard: rfapi must not expose permanent-delete wrappers.""" + + def test_no_trash_delete_immediately(self) -> None: + from roboflow.adapters import rfapi + + self.assertFalse(hasattr(rfapi, "trash_delete_immediately")) + + def test_no_empty_trash(self) -> None: + from roboflow.adapters import rfapi + + self.assertFalse(hasattr(rfapi, "empty_trash")) + + +class TestWorkspaceSurface(unittest.TestCase): + """Guard: Workspace must not expose permanent-delete helpers.""" + + def test_no_delete_from_trash(self) -> None: + from roboflow.core.workspace import Workspace + + self.assertFalse(hasattr(Workspace, "delete_from_trash")) + + def test_no_empty_trash(self) -> None: + from roboflow.core.workspace import Workspace + + self.assertFalse(hasattr(Workspace, "empty_trash")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_version_handler.py b/tests/cli/test_version_handler.py index 0bfcb697..068c608e 100644 --- a/tests/cli/test_version_handler.py +++ b/tests/cli/test_version_handler.py @@ -113,5 +113,112 @@ def test_no_version(self) -> None: self.assertIsNone(v) +class TestVersionDeleteRestoreRegistration(unittest.TestCase): + """Verify delete/restore commands register under `version`.""" + + def test_version_delete_exists(self) -> None: + result = runner.invoke(app, ["version", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_version_restore_exists(self) -> None: + result = runner.invoke(app, ["version", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + +class TestVersionDeleteHandler(unittest.TestCase): + """version delete calls rfapi.delete_version and honors --yes.""" + + def _args(self, version_ref="my-ws/my-proj/3"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + version_ref=version_ref, + yes=True, + ) + + def test_delete_calls_rfapi(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _delete_version + + with patch("roboflow.adapters.rfapi.delete_version", return_value={"deleted": True}) as mock_del: + _delete_version(self._args()) + mock_del.assert_called_once_with("fake-key", "my-ws", "my-proj", 3) + + +class TestVersionRestoreHandler(unittest.TestCase): + """version restore looks up by (parentUrl, version id) in Trash.""" + + def _args(self, version_ref="my-ws/my-proj/3"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + version_ref=version_ref, + ) + + def test_restore_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _restore_version + + trash = { + "sections": { + "versions": [ + { + "id": "3", + "parentId": "proj-id-123", + "parentUrl": "my-proj", + "name": "v3", + } + ] + } + } + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True, "type": "version", "id": "3"}, + ) as mock_restore, + ): + _restore_version(self._args()) + mock_restore.assert_called_once_with("fake-key", "my-ws", "version", "3", parent_id="proj-id-123") + + def test_restore_wrong_project_not_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _restore_version + + # version id matches but parentUrl doesn't — must not restore. + trash = { + "sections": { + "versions": [ + { + "id": "3", + "parentId": "other-id", + "parentUrl": "other-proj", + "name": "v3", + } + ] + } + } + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + patch("sys.exit"), + ): + _restore_version(self._args()) + mock_restore.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/cli/test_workflow_handler.py b/tests/cli/test_workflow_handler.py index 0209cf62..128fb377 100644 --- a/tests/cli/test_workflow_handler.py +++ b/tests/cli/test_workflow_handler.py @@ -53,6 +53,16 @@ def test_workflow_fork_exists(self) -> None: result = runner.invoke(app, ["workflow", "fork", "--help"]) self.assertEqual(result.exit_code, 0) + def test_workflow_delete_exists(self) -> None: + result = runner.invoke(app, ["workflow", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_workflow_restore_exists(self) -> None: + result = runner.invoke(app, ["workflow", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + def test_workflow_build_exists(self) -> None: result = runner.invoke(app, ["workflow", "build", "--help"]) self.assertEqual(result.exit_code, 0) @@ -386,5 +396,68 @@ def test_list_no_workspace(self, _mock_resolve): self.assertEqual(ctx.exception.code, 2) +class TestWorkflowDeleteHandler(unittest.TestCase): + """workflow delete calls rfapi.delete_workflow and honors --yes.""" + + def test_delete_calls_rfapi(self) -> None: + from roboflow.cli.handlers.workflow import _delete_workflow + + args = _make_args(workflow_url="slow-webhooks", yes=True) + with patch("roboflow.adapters.rfapi.delete_workflow", return_value={"deleted": True}) as mock_del: + _delete_workflow(args) + mock_del.assert_called_once_with("test-key", "test-ws", "slow-webhooks") + + +class TestWorkflowRestoreHandler(unittest.TestCase): + """workflow restore looks up by URL (or id) in Trash, then restores.""" + + def test_restore_found_by_url(self) -> None: + from roboflow.cli.handlers.workflow import _restore_workflow + + trash = {"sections": {"workflows": [{"id": "wf_abc123", "url": "slow-webhooks", "name": "Slow Webhooks"}]}} + args = _make_args(workflow_url="slow-webhooks") + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as mock_restore, + ): + _restore_workflow(args) + mock_restore.assert_called_once_with("test-key", "test-ws", "workflow", "wf_abc123") + + def test_restore_found_by_id(self) -> None: + # Callers who pass a Firestore id (e.g. copy/paste from `trash list`) + # still resolve, via the id fallback. + from roboflow.cli.handlers.workflow import _restore_workflow + + trash = {"sections": {"workflows": [{"id": "wf_abc123", "url": "slow-webhooks", "name": "Slow Webhooks"}]}} + args = _make_args(workflow_url="wf_abc123") + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as mock_restore, + ): + _restore_workflow(args) + mock_restore.assert_called_once_with("test-key", "test-ws", "workflow", "wf_abc123") + + def test_restore_not_in_trash(self) -> None: + from roboflow.cli.handlers.workflow import _restore_workflow + + args = _make_args(workflow_url="slow-webhooks") + with ( + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"sections": {"workflows": []}}, + ), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + ): + with self.assertRaises(SystemExit): + _restore_workflow(args) + mock_restore.assert_not_called() + + if __name__ == "__main__": unittest.main()