From 54867078b9072ad54d7caa294ad4839e51487de0 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 21:41:39 -0300 Subject: [PATCH 01/15] Soft-delete / Trash support: SDK + CLI Adds project and version lifecycle management mirroring the soft-delete and Trash features that just landed in the web app. Projects and versions are moved to Trash on delete (30-day retention) and can be restored within the retention window; any in-flight training jobs are cancelled automatically on the server. New rfapi functions: - delete_project, delete_version - list_trash, restore_trash_item - trash_delete_immediately, empty_trash New SDK methods: - Project.delete(), Project.restore() - Version.delete(), Version.restore() - Workspace.trash(), Workspace.restore_from_trash(), Workspace.delete_from_trash(), Workspace.empty_trash() New CLI commands: - roboflow project delete / restore - roboflow version delete / restore - roboflow trash list / empty / delete All existing tests pass; new commands have --yes / -y to skip the confirmation prompt for scripted use. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/adapters/rfapi.py | 86 +++++++++++++++ roboflow/cli/__init__.py | 2 + roboflow/cli/handlers/project.py | 120 +++++++++++++++++++++ roboflow/cli/handlers/trash.py | 177 +++++++++++++++++++++++++++++++ roboflow/cli/handlers/version.py | 152 ++++++++++++++++++++++++++ roboflow/core/project.py | 47 ++++++++ roboflow/core/version.py | 47 ++++++++ roboflow/core/workspace.py | 50 +++++++++ 8 files changed, 681 insertions(+) create mode 100644 roboflow/cli/handlers/trash.py diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index f08043e2..af09735d 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -848,3 +848,89 @@ 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 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, or permanently deleted via `trash_delete_immediately`. + """ + url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + raise RoboflowError(response.text) + 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 RoboflowError(response.text) + 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 RoboflowError(response.text) + 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 "dataset", "version", "workflow". + `parent_id` is required when restoring a version (the dataset 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 RoboflowError(response.text) + return response.json() + + +def trash_delete_immediately(api_key, workspace_url, item_type, item_id, parent_id=None): + """POST /{workspace}/trash/deleteImmediately — permanently delete a Trash + item (cannot be undone). The item must already be in Trash. + """ + url = f"{API_URL}/{workspace_url}/trash/deleteImmediately?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 RoboflowError(response.text) + return response.json() + + +def empty_trash(api_key, workspace_url): + """POST /{workspace}/trash/empty — dispatch async cleanup for everything + in workspace Trash. Returns immediately; cleanup happens in the background. + """ + url = f"{API_URL}/{workspace_url}/trash/empty?api_key={api_key}" + response = requests.post(url, json={}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() 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..177599c8 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,102 @@ 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)) + 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), 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)) + 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), exit_code=3) + return + + datasets = trash.get("sections", {}).get("datasets", []) + match = next((d for d in datasets if d.get("url") == project_slug), None) + if not match: + output_error( + args, + f"Project '{workspace_url}/{project_slug}' is not in Trash.", + hint="Use 'roboflow trash list' to see what can be restored.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item(api_key, workspace_url, "dataset", match["id"]) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), 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..048abd2e --- /dev/null +++ b/roboflow/cli/handlers/trash.py @@ -0,0 +1,177 @@ +"""Trash management commands: list, empty, delete-immediately.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +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) + + +@trash_app.command("empty") +def empty_trash_cmd( + ctx: typer.Context, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Permanently delete everything in Trash. Cannot be undone.""" + args = ctx_to_args(ctx, yes=yes) + _empty_trash(args) + + +@trash_app.command("delete") +def delete_immediately_cmd( + ctx: typer.Context, + item_type: Annotated[str, typer.Argument(help="dataset, version, or workflow")], + item_id: Annotated[str, typer.Argument(help="Firestore id of the item in Trash")], + parent_id: Annotated[ + Optional[str], + typer.Option("--parent-id", help="Parent dataset id (required for versions)."), + ] = None, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Permanently delete a single Trash item. Cannot be undone.""" + args = ctx_to_args(ctx, item_type=item_type, item_id=item_id, parent_id=parent_id, yes=yes) + _delete_immediately(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), 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) + + +def _empty_trash(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + workspace_url, api_key = _resolve_workspace(args) + if not workspace_url: + return + + if not getattr(args, "yes", False) and not getattr(args, "json", False): + import typer as _typer + + confirmed = _typer.confirm( + f"Permanently delete ALL items in '{workspace_url}' Trash? This cannot be undone.", + default=False, + ) + if not confirmed: + output(args, {"cancelled": True}, text="Cancelled.") + return + + try: + data = rfapi.empty_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + output( + args, + data, + text=f"Emptying Trash — {data.get('dispatched', 0)} cleanup tasks dispatched.", + ) + + +def _delete_immediately(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + workspace_url, api_key = _resolve_workspace(args) + if not workspace_url: + return + + if not getattr(args, "yes", False) and not getattr(args, "json", False): + import typer as _typer + + confirmed = _typer.confirm( + f"Permanently delete {args.item_type} '{args.item_id}'? This cannot be undone.", + default=False, + ) + if not confirmed: + output(args, {"cancelled": True}, text="Cancelled.") + return + + try: + data = rfapi.trash_delete_immediately( + api_key, workspace_url, args.item_type, args.item_id, args.parent_id + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + output(args, data, text=f"Permanently deleted {args.item_type} {args.item_id}.") diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index 5888c42d..c1691c94 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,128 @@ 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)) + return + + if version_num is None: + output_error(args, "Version number is required (e.g. project/3 or ws/project/3).") + 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), 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)) + return + + if version_num is None: + output_error(args, "Version number is required (e.g. project/3 or ws/project/3).") + 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), 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="Use 'roboflow trash list' to see what can be restored.", + 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), exit_code=3) + return + + output( + args, + data, + text=f"Restored {workspace_url}/{project_slug}/{version_num} from Trash.", + ) diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 8a287fc4..6c1117e8 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1041,3 +1041,50 @@ 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: "dataset", ...}`. + + 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) + datasets = trash.get("sections", {}).get("datasets", []) + match = next((d for d in datasets if d.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, "dataset", match["id"] + ) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 229f2ad9..34c7b6e9 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -668,6 +668,53 @@ 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", []) + match = next( + ( + v + for v in versions + if str(v.get("id")) == str(self.version) + and (v.get("parentUrl") == self.project or v.get("parentId") == 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..cdcd8eb7 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1333,6 +1333,56 @@ 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 `datasets`, `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 "dataset", "version", "workflow" + item_id: the item's Firestore id (found via `trash()`) + parent_id: required when restoring a version — the parent dataset 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) + + def delete_from_trash(self, item_type: str, item_id: str, parent_id: Optional[str] = None): + """ + Permanently delete a Trash item (cannot be undone). The item must + already be in Trash. + """ + return rfapi.trash_delete_immediately( + self.__api_key, self.url, item_type, item_id, parent_id + ) + + def empty_trash(self): + """ + Dispatch async cleanup for everything currently in the workspace + Trash. Returns immediately; cleanup happens in the background. + """ + return rfapi.empty_trash(self.__api_key, self.url) + def __str__(self): projects = self.projects() json_value = {"name": self.name, "url": self.url, "projects": projects} From 16f2916ed9ada80f7d3cbe11081f10c9482b45e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:24:24 +0000 Subject: [PATCH 02/15] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roboflow/cli/handlers/project.py | 8 ++------ roboflow/cli/handlers/trash.py | 4 +--- roboflow/cli/handlers/version.py | 14 +++----------- roboflow/core/project.py | 8 ++------ roboflow/core/version.py | 4 +--- roboflow/core/workspace.py | 4 +--- 6 files changed, 10 insertions(+), 32 deletions(-) diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py index 177599c8..36047d13 100644 --- a/roboflow/cli/handlers/project.py +++ b/roboflow/cli/handlers/project.py @@ -228,9 +228,7 @@ def _delete_project(args): # noqa: ANN001 from roboflow.config import load_roboflow_api_key try: - workspace_url, project_slug, _version = resolve_resource( - args.project_id, workspace_override=args.workspace - ) + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) except ValueError as exc: output_error(args, str(exc)) return @@ -277,9 +275,7 @@ def _restore_project(args): # noqa: ANN001 from roboflow.config import load_roboflow_api_key try: - workspace_url, project_slug, _version = resolve_resource( - args.project_id, workspace_override=args.workspace - ) + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) except ValueError as exc: output_error(args, str(exc)) return diff --git a/roboflow/cli/handlers/trash.py b/roboflow/cli/handlers/trash.py index 048abd2e..ca120428 100644 --- a/roboflow/cli/handlers/trash.py +++ b/roboflow/cli/handlers/trash.py @@ -167,9 +167,7 @@ def _delete_immediately(args): # noqa: ANN001 return try: - data = rfapi.trash_delete_immediately( - api_key, workspace_url, args.item_type, args.item_id, args.parent_id - ) + data = rfapi.trash_delete_immediately(api_key, workspace_url, args.item_type, args.item_id, args.parent_id) except rfapi.RoboflowError as exc: output_error(args, str(exc), exit_code=3) return diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index c1691c94..7b79a374 100644 --- a/roboflow/cli/handlers/version.py +++ b/roboflow/cli/handlers/version.py @@ -361,9 +361,7 @@ def _delete_version(args): # noqa: ANN001 from roboflow.config import load_roboflow_api_key try: - workspace_url, project_slug, version_num = resolve_resource( - args.version_ref, workspace_override=args.workspace - ) + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) except ValueError as exc: output_error(args, str(exc)) return @@ -414,9 +412,7 @@ def _restore_version(args): # noqa: ANN001 from roboflow.config import load_roboflow_api_key try: - workspace_url, project_slug, version_num = resolve_resource( - args.version_ref, workspace_override=args.workspace - ) + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) except ValueError as exc: output_error(args, str(exc)) return @@ -444,11 +440,7 @@ def _restore_version(args): # noqa: ANN001 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 - ), + (v for v in versions if str(v.get("id")) == target and v.get("parentUrl") == project_slug), None, ) if not match: diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 6c1117e8..841384be 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1082,9 +1082,5 @@ def restore(self): datasets = trash.get("sections", {}).get("datasets", []) match = next((d for d in datasets if d.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, "dataset", match["id"] - ) + raise RuntimeError(f"Project '{self.__project_name}' is not in Trash — nothing to restore.") + return rfapi.restore_trash_item(self.__api_key, self.__workspace, "dataset", match["id"]) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 34c7b6e9..3d4de68d 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -704,9 +704,7 @@ def restore(self): None, ) if not match: - raise RuntimeError( - f"Version '{self.project}/{self.version}' is not in Trash — nothing to restore." - ) + 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, diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index cdcd8eb7..7f27fa8f 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1372,9 +1372,7 @@ def delete_from_trash(self, item_type: str, item_id: str, parent_id: Optional[st Permanently delete a Trash item (cannot be undone). The item must already be in Trash. """ - return rfapi.trash_delete_immediately( - self.__api_key, self.url, item_type, item_id, parent_id - ) + return rfapi.trash_delete_immediately(self.__api_key, self.url, item_type, item_id, parent_id) def empty_trash(self): """ From 319c740401410d29779ac509b7e3d5749ed03634 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 22:29:43 -0300 Subject: [PATCH 03/15] Docs + CLI tests for soft-delete / Trash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI-COMMANDS.md: new "Delete and restore" section covering project, version, and trash commands; added `trash` to the command-groups table. - tests/cli/test_project_handler.py: registration + delete/restore unit tests. - tests/cli/test_version_handler.py: registration + delete/restore unit tests (including the parent-project disambiguation lookup). - tests/cli/test_trash_handler.py: full coverage for list/empty/delete commands with mocked rfapi. SDK-side (`docs/core/*.md`) is auto-generated via mkdocstrings from docstrings, so no changes needed there — the new methods are already documented via their docstrings. 479 tests pass (20 new), ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLI-COMMANDS.md | 22 ++++ tests/cli/test_project_handler.py | 92 ++++++++++++++++ tests/cli/test_trash_handler.py | 174 ++++++++++++++++++++++++++++++ tests/cli/test_version_handler.py | 111 +++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 tests/cli/test_trash_handler.py diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index fbd155ce..3e4c5571 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -101,6 +101,27 @@ 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 + +# List, empty, or permanently delete a single item in Trash. +roboflow trash list +roboflow trash empty +roboflow trash delete version --parent-id + +# Skip the confirmation prompt for scripts. +roboflow project delete my-workspace/my-project --yes +``` + ### Workspace stats and billing ```bash @@ -177,6 +198,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, empty, or permanently delete items in Trash | | `universe` | Search Roboflow Universe | | `video` | Video inference | | `batch` | Batch processing jobs *(coming soon)* | diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py index e217a608..cc40cc20 100644 --- a/tests/cli/test_project_handler.py +++ b/tests/cli/test_project_handler.py @@ -30,12 +30,104 @@ 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": { + "datasets": [{"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": "dataset", "id": "abc123"}, + ) as mock_restore, + ): + _restore_project(self._args()) + mock_restore.assert_called_once_with("fake-key", "my-ws", "dataset", "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": {"datasets": []}}, + ), + 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..595a8513 --- /dev/null +++ b/tests/cli/test_trash_handler.py @@ -0,0 +1,174 @@ +"""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_trash_empty_exists(self) -> None: + result = runner.invoke(app, ["trash", "empty", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_trash_delete_exists(self) -> None: + result = runner.invoke(app, ["trash", "delete", "--help"]) + self.assertEqual(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) + self.assertIn("empty", result.output) + self.assertIn("delete", result.output) + + +def _args(**overrides): + base = { + "json": False, + "workspace": None, + "api_key": "fake-key", + "quiet": False, + "yes": True, + } + 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": "dataset", + "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 TestTrashEmptyHandler(unittest.TestCase): + """trash empty calls rfapi.empty_trash and honors --yes.""" + + def test_empty_calls_rfapi(self) -> None: + from roboflow.cli.handlers.trash import _empty_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.empty_trash", return_value={"dispatched": 5} + ) as mock_empty, + patch("builtins.print"), + ): + _empty_trash(_args()) + mock_empty.assert_called_once_with("fake-key", "test-ws") + + +class TestTrashDeleteImmediatelyHandler(unittest.TestCase): + """trash delete calls rfapi.trash_delete_immediately.""" + + def test_delete_dataset(self) -> None: + from roboflow.cli.handlers.trash import _delete_immediately + + 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.trash_delete_immediately", + return_value={"deleted": True}, + ) as mock_del, + patch("builtins.print"), + ): + _delete_immediately( + _args(item_type="dataset", item_id="abc123", parent_id=None) + ) + mock_del.assert_called_once_with( + "fake-key", "test-ws", "dataset", "abc123", None + ) + + def test_delete_version_with_parent(self) -> None: + from roboflow.cli.handlers.trash import _delete_immediately + + 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.trash_delete_immediately", + return_value={"deleted": True}, + ) as mock_del, + patch("builtins.print"), + ): + _delete_immediately( + _args(item_type="version", item_id="3", parent_id="dataset-123") + ) + mock_del.assert_called_once_with( + "fake-key", "test-ws", "version", "3", "dataset-123" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_version_handler.py b/tests/cli/test_version_handler.py index 0bfcb697..d8b256fc 100644 --- a/tests/cli/test_version_handler.py +++ b/tests/cli/test_version_handler.py @@ -113,5 +113,116 @@ 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() From 9dc9c4266cbf7975f6e03c6daef9a38ca66789df Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:29:58 +0000 Subject: [PATCH 04/15] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cli/test_project_handler.py | 10 ++-------- tests/cli/test_trash_handler.py | 20 +++++--------------- tests/cli/test_version_handler.py | 8 ++------ 3 files changed, 9 insertions(+), 29 deletions(-) diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py index cc40cc20..f0a01bf2 100644 --- a/tests/cli/test_project_handler.py +++ b/tests/cli/test_project_handler.py @@ -70,9 +70,7 @@ def test_delete_calls_rfapi(self) -> None: from roboflow.cli.handlers.project import _delete_project - with patch( - "roboflow.adapters.rfapi.delete_project", return_value={"deleted": True} - ) as mock_del: + 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") @@ -96,11 +94,7 @@ def test_restore_found(self) -> None: from roboflow.cli.handlers.project import _restore_project - trash = { - "sections": { - "datasets": [{"id": "abc123", "url": "my-proj", "name": "My Project"}] - } - } + trash = {"sections": {"datasets": [{"id": "abc123", "url": "my-proj", "name": "My Project"}]}} with ( patch("roboflow.adapters.rfapi.list_trash", return_value=trash), patch( diff --git a/tests/cli/test_trash_handler.py b/tests/cli/test_trash_handler.py index 595a8513..f7841a50 100644 --- a/tests/cli/test_trash_handler.py +++ b/tests/cli/test_trash_handler.py @@ -119,9 +119,7 @@ def test_empty_calls_rfapi(self) -> None: 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.empty_trash", return_value={"dispatched": 5} - ) as mock_empty, + patch("roboflow.adapters.rfapi.empty_trash", return_value={"dispatched": 5}) as mock_empty, patch("builtins.print"), ): _empty_trash(_args()) @@ -143,12 +141,8 @@ def test_delete_dataset(self) -> None: ) as mock_del, patch("builtins.print"), ): - _delete_immediately( - _args(item_type="dataset", item_id="abc123", parent_id=None) - ) - mock_del.assert_called_once_with( - "fake-key", "test-ws", "dataset", "abc123", None - ) + _delete_immediately(_args(item_type="dataset", item_id="abc123", parent_id=None)) + mock_del.assert_called_once_with("fake-key", "test-ws", "dataset", "abc123", None) def test_delete_version_with_parent(self) -> None: from roboflow.cli.handlers.trash import _delete_immediately @@ -162,12 +156,8 @@ def test_delete_version_with_parent(self) -> None: ) as mock_del, patch("builtins.print"), ): - _delete_immediately( - _args(item_type="version", item_id="3", parent_id="dataset-123") - ) - mock_del.assert_called_once_with( - "fake-key", "test-ws", "version", "3", "dataset-123" - ) + _delete_immediately(_args(item_type="version", item_id="3", parent_id="dataset-123")) + mock_del.assert_called_once_with("fake-key", "test-ws", "version", "3", "dataset-123") if __name__ == "__main__": diff --git a/tests/cli/test_version_handler.py b/tests/cli/test_version_handler.py index d8b256fc..068c608e 100644 --- a/tests/cli/test_version_handler.py +++ b/tests/cli/test_version_handler.py @@ -147,9 +147,7 @@ def test_delete_calls_rfapi(self) -> None: from roboflow.cli.handlers.version import _delete_version - with patch( - "roboflow.adapters.rfapi.delete_version", return_value={"deleted": True} - ) as mock_del: + 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) @@ -193,9 +191,7 @@ def test_restore_found(self) -> None: ) as mock_restore, ): _restore_version(self._args()) - mock_restore.assert_called_once_with( - "fake-key", "my-ws", "version", "3", parent_id="proj-id-123" - ) + 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 9de0bdf03d658de7a4fa9138cb42a661781c4028 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 22:36:17 -0300 Subject: [PATCH 05/15] Drop permanent-delete / empty-trash from the SDK and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permanent deletion destroys data irrecoverably. Exposing it on the public API — and through the SDK/CLI that wraps it — makes it one stray curl or for-loop away from catastrophe, with no undo. It stays available only in the web UI's Trash view, which has an explicit confirmation dialog; items left in Trash are cleaned up automatically after 30 days either way. Removed: - rfapi.trash_delete_immediately(), rfapi.empty_trash() - Workspace.delete_from_trash(), Workspace.empty_trash() - roboflow trash empty, roboflow trash delete commands - Corresponding tests (replaced with guard tests that fail if the permanent-delete surface is ever re-added by accident) - CLI-COMMANDS.md references Kept (soft-delete + restore + list only): - Project.delete() / Project.restore() - Version.delete() / Version.restore() - Workspace.trash() / Workspace.restore_from_trash() - roboflow project delete|restore - roboflow version delete|restore - roboflow trash list Paired with the corresponding backend removal in roboflow/roboflow#11131 — the public REST API no longer exposes POST /:ws/trash/deleteImmediately or POST /:ws/trash/empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLI-COMMANDS.md | 11 ++-- roboflow/adapters/rfapi.py | 26 ++------- roboflow/cli/handlers/trash.py | 96 +++------------------------------ roboflow/core/workspace.py | 17 ++---- tests/cli/test_trash_handler.py | 77 ++++++++++---------------- 5 files changed, 48 insertions(+), 179 deletions(-) diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 3e4c5571..893b4839 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -113,15 +113,18 @@ roboflow project restore my-workspace/my-project roboflow version delete my-workspace/my-project/3 roboflow version restore my-workspace/my-project/3 -# List, empty, or permanently delete a single item in Trash. +# Inspect what's currently in Trash. roboflow trash list -roboflow trash empty -roboflow trash delete version --parent-id # 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 @@ -198,7 +201,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, empty, or permanently delete items in Trash | +| `trash` | List items in Trash | | `universe` | Search Roboflow Universe | | `video` | Video inference | | `batch` | Batch processing jobs *(coming soon)* | diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index af09735d..87a5476a 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -911,26 +911,6 @@ def restore_trash_item(api_key, workspace_url, item_type, item_id, parent_id=Non return response.json() -def trash_delete_immediately(api_key, workspace_url, item_type, item_id, parent_id=None): - """POST /{workspace}/trash/deleteImmediately — permanently delete a Trash - item (cannot be undone). The item must already be in Trash. - """ - url = f"{API_URL}/{workspace_url}/trash/deleteImmediately?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 RoboflowError(response.text) - return response.json() - - -def empty_trash(api_key, workspace_url): - """POST /{workspace}/trash/empty — dispatch async cleanup for everything - in workspace Trash. Returns immediately; cleanup happens in the background. - """ - url = f"{API_URL}/{workspace_url}/trash/empty?api_key={api_key}" - response = requests.post(url, json={}) - if response.status_code != 200: - raise RoboflowError(response.text) - 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/handlers/trash.py b/roboflow/cli/handlers/trash.py index ca120428..6f5b9da9 100644 --- a/roboflow/cli/handlers/trash.py +++ b/roboflow/cli/handlers/trash.py @@ -1,8 +1,12 @@ -"""Trash management commands: list, empty, delete-immediately.""" +"""Trash management commands. -from __future__ import annotations +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 typing import Annotated, Optional +from __future__ import annotations import typer @@ -18,32 +22,6 @@ def list_trash_cmd(ctx: typer.Context) -> None: _list_trash(args) -@trash_app.command("empty") -def empty_trash_cmd( - ctx: typer.Context, - yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, -) -> None: - """Permanently delete everything in Trash. Cannot be undone.""" - args = ctx_to_args(ctx, yes=yes) - _empty_trash(args) - - -@trash_app.command("delete") -def delete_immediately_cmd( - ctx: typer.Context, - item_type: Annotated[str, typer.Argument(help="dataset, version, or workflow")], - item_id: Annotated[str, typer.Argument(help="Firestore id of the item in Trash")], - parent_id: Annotated[ - Optional[str], - typer.Option("--parent-id", help="Parent dataset id (required for versions)."), - ] = None, - yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, -) -> None: - """Permanently delete a single Trash item. Cannot be undone.""" - args = ctx_to_args(ctx, item_type=item_type, item_id=item_id, parent_id=parent_id, yes=yes) - _delete_immediately(args) - - # --------------------------------------------------------------------------- # Business logic # --------------------------------------------------------------------------- @@ -113,63 +91,3 @@ def _list_trash(args): # noqa: ANN001 if not rows: table = "(Trash is empty)" output(args, trash, text=table) - - -def _empty_trash(args): # noqa: ANN001 - from roboflow.adapters import rfapi - from roboflow.cli._output import output, output_error - - workspace_url, api_key = _resolve_workspace(args) - if not workspace_url: - return - - if not getattr(args, "yes", False) and not getattr(args, "json", False): - import typer as _typer - - confirmed = _typer.confirm( - f"Permanently delete ALL items in '{workspace_url}' Trash? This cannot be undone.", - default=False, - ) - if not confirmed: - output(args, {"cancelled": True}, text="Cancelled.") - return - - try: - data = rfapi.empty_trash(api_key, workspace_url) - except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) - return - - output( - args, - data, - text=f"Emptying Trash — {data.get('dispatched', 0)} cleanup tasks dispatched.", - ) - - -def _delete_immediately(args): # noqa: ANN001 - from roboflow.adapters import rfapi - from roboflow.cli._output import output, output_error - - workspace_url, api_key = _resolve_workspace(args) - if not workspace_url: - return - - if not getattr(args, "yes", False) and not getattr(args, "json", False): - import typer as _typer - - confirmed = _typer.confirm( - f"Permanently delete {args.item_type} '{args.item_id}'? This cannot be undone.", - default=False, - ) - if not confirmed: - output(args, {"cancelled": True}, text="Cancelled.") - return - - try: - data = rfapi.trash_delete_immediately(api_key, workspace_url, args.item_type, args.item_id, args.parent_id) - except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) - return - - output(args, data, text=f"Permanently deleted {args.item_type} {args.item_id}.") diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index 7f27fa8f..de5cb1d8 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1367,19 +1367,10 @@ def restore_from_trash(self, item_type: str, item_id: str, parent_id: Optional[s """ return rfapi.restore_trash_item(self.__api_key, self.url, item_type, item_id, parent_id) - def delete_from_trash(self, item_type: str, item_id: str, parent_id: Optional[str] = None): - """ - Permanently delete a Trash item (cannot be undone). The item must - already be in Trash. - """ - return rfapi.trash_delete_immediately(self.__api_key, self.url, item_type, item_id, parent_id) - - def empty_trash(self): - """ - Dispatch async cleanup for everything currently in the workspace - Trash. Returns immediately; cleanup happens in the background. - """ - return rfapi.empty_trash(self.__api_key, self.url) + # 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() diff --git a/tests/cli/test_trash_handler.py b/tests/cli/test_trash_handler.py index f7841a50..e0009d99 100644 --- a/tests/cli/test_trash_handler.py +++ b/tests/cli/test_trash_handler.py @@ -23,20 +23,20 @@ def test_trash_list_exists(self) -> None: result = runner.invoke(app, ["trash", "list", "--help"]) self.assertEqual(result.exit_code, 0) - def test_trash_empty_exists(self) -> None: - result = runner.invoke(app, ["trash", "empty", "--help"]) - self.assertEqual(result.exit_code, 0) - - def test_trash_delete_exists(self) -> None: - result = runner.invoke(app, ["trash", "delete", "--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) - self.assertIn("empty", result.output) - self.assertIn("delete", result.output) + # empty / delete should NOT appear in the command group + self.assertNotIn("empty", result.output.lower()) def _args(**overrides): @@ -45,7 +45,6 @@ def _args(**overrides): "workspace": None, "api_key": "fake-key", "quiet": False, - "yes": True, } base.update(overrides) return Namespace(**base) @@ -110,54 +109,32 @@ def test_list_empty(self) -> None: self.assertIn("empty", printed.lower()) -class TestTrashEmptyHandler(unittest.TestCase): - """trash empty calls rfapi.empty_trash and honors --yes.""" +class TestRfapiSurface(unittest.TestCase): + """Guard: rfapi must not expose permanent-delete wrappers.""" - def test_empty_calls_rfapi(self) -> None: - from roboflow.cli.handlers.trash import _empty_trash + def test_no_trash_delete_immediately(self) -> None: + from roboflow.adapters import rfapi - 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.empty_trash", return_value={"dispatched": 5}) as mock_empty, - patch("builtins.print"), - ): - _empty_trash(_args()) - mock_empty.assert_called_once_with("fake-key", "test-ws") + self.assertFalse(hasattr(rfapi, "trash_delete_immediately")) + def test_no_empty_trash(self) -> None: + from roboflow.adapters import rfapi -class TestTrashDeleteImmediatelyHandler(unittest.TestCase): - """trash delete calls rfapi.trash_delete_immediately.""" + self.assertFalse(hasattr(rfapi, "empty_trash")) - def test_delete_dataset(self) -> None: - from roboflow.cli.handlers.trash import _delete_immediately - 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.trash_delete_immediately", - return_value={"deleted": True}, - ) as mock_del, - patch("builtins.print"), - ): - _delete_immediately(_args(item_type="dataset", item_id="abc123", parent_id=None)) - mock_del.assert_called_once_with("fake-key", "test-ws", "dataset", "abc123", None) +class TestWorkspaceSurface(unittest.TestCase): + """Guard: Workspace must not expose permanent-delete helpers.""" - def test_delete_version_with_parent(self) -> None: - from roboflow.cli.handlers.trash import _delete_immediately + def test_no_delete_from_trash(self) -> None: + from roboflow.core.workspace import Workspace - 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.trash_delete_immediately", - return_value={"deleted": True}, - ) as mock_del, - patch("builtins.print"), - ): - _delete_immediately(_args(item_type="version", item_id="3", parent_id="dataset-123")) - mock_del.assert_called_once_with("fake-key", "test-ws", "version", "3", "dataset-123") + 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__": From 2bbf5979c57dd54dc8b0af89ceec2d53259cf964 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 22:57:50 -0300 Subject: [PATCH 06/15] CLI: add actionable hints to every error path in the trash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md's Agent Experience checklist asks that every output_error() include an actionable hint. The delete/restore/list commands shipped with hints on some paths but not others. Fills in the gaps so agents get a consistent "what went wrong AND what to do" pair on every failure: - resolver ValueError: tell them the valid shorthand formats - API RoboflowError: point at the specific scope that's likely missing ('project:update' / 'version:update' / 'project:read') - 'not in trash' on version restore: additionally note that a parent project in trash must be restored first No behavior change — same error paths, same exit codes, same JSON schema. Just more useful text. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/cli/handlers/project.py | 35 +++++++++++++++++++---- roboflow/cli/handlers/trash.py | 7 ++++- roboflow/cli/handlers/version.py | 48 ++++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py index 36047d13..88cc6c35 100644 --- a/roboflow/cli/handlers/project.py +++ b/roboflow/cli/handlers/project.py @@ -230,7 +230,11 @@ def _delete_project(args): # noqa: ANN001 try: workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) except ValueError as exc: - output_error(args, str(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) @@ -258,7 +262,12 @@ def _delete_project(args): # noqa: ANN001 try: data = rfapi.delete_project(api_key, workspace_url, project_slug) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + output_error( + args, + str(exc), + hint="Check your API key has 'project:update' scope on this workspace.", + exit_code=3, + ) return output( @@ -277,7 +286,11 @@ def _restore_project(args): # noqa: ANN001 try: workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) except ValueError as exc: - output_error(args, str(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) @@ -293,7 +306,12 @@ def _restore_project(args): # noqa: ANN001 try: trash = rfapi.list_trash(api_key, workspace_url) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + output_error( + args, + str(exc), + hint="Check your API key has 'project:read' scope on this workspace.", + exit_code=3, + ) return datasets = trash.get("sections", {}).get("datasets", []) @@ -302,7 +320,7 @@ def _restore_project(args): # noqa: ANN001 output_error( args, f"Project '{workspace_url}/{project_slug}' is not in Trash.", - hint="Use 'roboflow trash list' to see what can be restored.", + hint="Run 'roboflow trash list' to see what can be restored.", exit_code=3, ) return @@ -310,7 +328,12 @@ def _restore_project(args): # noqa: ANN001 try: data = rfapi.restore_trash_item(api_key, workspace_url, "dataset", match["id"]) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + 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 index 6f5b9da9..7c7a3815 100644 --- a/roboflow/cli/handlers/trash.py +++ b/roboflow/cli/handlers/trash.py @@ -62,7 +62,12 @@ def _list_trash(args): # noqa: ANN001 try: trash = rfapi.list_trash(api_key, workspace_url) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + 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", []) diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index 7b79a374..13ec2cb2 100644 --- a/roboflow/cli/handlers/version.py +++ b/roboflow/cli/handlers/version.py @@ -363,11 +363,19 @@ def _delete_version(args): # noqa: ANN001 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)) + 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 (e.g. project/3 or ws/project/3).") + 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) @@ -395,7 +403,12 @@ def _delete_version(args): # noqa: ANN001 try: data = rfapi.delete_version(api_key, workspace_url, project_slug, version_num) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + output_error( + args, + str(exc), + hint="Check your API key has 'version:update' scope and the version exists.", + exit_code=3, + ) return output( @@ -414,11 +427,19 @@ def _restore_version(args): # noqa: ANN001 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)) + 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 (e.g. project/3 or ws/project/3).") + 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) @@ -434,7 +455,12 @@ def _restore_version(args): # noqa: ANN001 try: trash = rfapi.list_trash(api_key, workspace_url) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + 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", []) @@ -447,7 +473,8 @@ def _restore_version(args): # noqa: ANN001 output_error( args, f"Version '{workspace_url}/{project_slug}/{version_num}' is not in Trash.", - hint="Use 'roboflow trash list' to see what can be restored.", + 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 @@ -461,7 +488,12 @@ def _restore_version(args): # noqa: ANN001 parent_id=match.get("parentId"), ) except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=3) + output_error( + args, + str(exc), + hint="Check your API key has 'project:update' scope on this workspace.", + exit_code=3, + ) return output( From 0b8d6499358736e6baed788e538ef2adb865932a Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 23:12:49 -0300 Subject: [PATCH 07/15] rfapi: drop stale reference to removed trash_delete_immediately The delete_project() docstring still mentioned trash_delete_immediately as a follow-on action, but that helper was removed. Replace with a note about the 30-day cleanup cron so the lifecycle is documented without pointing at a symbol that doesn't exist. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/adapters/rfapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 87a5476a..80d9b4c8 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -860,7 +860,7 @@ def delete_project(api_key, workspace_url, project_url): 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, or permanently deleted via `trash_delete_immediately`. + 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) From 86c91b13a52148aaddd9830a1908c6eb90905ca0 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 23:30:39 -0300 Subject: [PATCH 08/15] rfapi: add delete_workflow helper for new public API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors delete_project() / delete_version() — wraps DELETE /:workspace/workflows/:workflowUrl on the public API (added in the paired backend PR). Workflow restore was already available via restore_trash_item(..., \"workflow\", ...) through the generic trash endpoint, so this closes the symmetry gap. Intentionally not adding Workflow.delete() / .restore() SDK methods or a `roboflow workflow delete|restore` CLI subcommand in this PR — there is no Workflow handle object in the SDK yet, and adding that is significant scope. Agents/scripts that want workflow lifecycle control can call rfapi.delete_workflow() and Workspace.restore_from_trash() directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/adapters/rfapi.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 80d9b4c8..52c0c860 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -881,6 +881,17 @@ def delete_version(api_key, workspace_url, project_url, version): 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 RoboflowError(response.text) + return response.json() + + def list_trash(api_key, workspace_url): """GET /{workspace}/trash — list items currently in Trash. From 4c032de1de445c4fa85de3f7038b78ae0d95eff8 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Thu, 23 Apr 2026 23:52:39 -0300 Subject: [PATCH 09/15] CLI: add `roboflow workflow delete` and `roboflow workflow restore` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI surface for soft-delete — workflows now have the same delete/restore subcommands as projects and versions. Both commands: - call rfapi.delete_workflow / rfapi.restore_trash_item under the hood (the public API endpoints added in the paired backend PR) - honor --json for structured output - honor --yes / -y on delete to skip the confirmation prompt for scripted use - emit output_error(..., hint=..., exit_code=N) with actionable hints per the Agent Experience checklist: which scope to check, what to run to see what's in Trash, etc. - accept either the workflow URL slug (e.g. slow-webhooks) or its Firestore id (e.g. wf_abc123) on restore Restore looks the workflow up in the workspace Trash by URL (falling back to id), just like project/version restore. CLI-COMMANDS.md gets a one-line update in the "Delete and restore" section. 485 tests pass (6 new: 2 registration + 4 handler). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLI-COMMANDS.md | 4 ++ roboflow/cli/handlers/workflow.py | 107 +++++++++++++++++++++++++++++ tests/cli/test_workflow_handler.py | 87 +++++++++++++++++++++++ 3 files changed, 198 insertions(+) diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 893b4839..a733a840 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -113,6 +113,10 @@ roboflow project restore my-workspace/my-project 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 diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py index 82085dca..0b48a4be 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,89 @@ 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 'project: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/tests/cli/test_workflow_handler.py b/tests/cli/test_workflow_handler.py index 0209cf62..8b9c2d80 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,82 @@ 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() From 2a97b6cd41d31609606e729e7bb64f093633570b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:52:57 +0000 Subject: [PATCH 10/15] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roboflow/cli/handlers/workflow.py | 3 +-- tests/cli/test_workflow_handler.py | 20 +++----------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py index 0b48a4be..b550a331 100644 --- a/roboflow/cli/handlers/workflow.py +++ b/roboflow/cli/handlers/workflow.py @@ -437,8 +437,7 @@ def _delete_workflow(args) -> None: # noqa: ANN001 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.)", + f"Move workflow '{workspace_url}/{args.workflow_url}' to Trash? (Retained for 30 days.)", default=False, ) if not confirmed: diff --git a/tests/cli/test_workflow_handler.py b/tests/cli/test_workflow_handler.py index 8b9c2d80..128fb377 100644 --- a/tests/cli/test_workflow_handler.py +++ b/tests/cli/test_workflow_handler.py @@ -403,9 +403,7 @@ 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: + 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") @@ -416,13 +414,7 @@ class TestWorkflowRestoreHandler(unittest.TestCase): 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"} - ] - } - } + 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), @@ -439,13 +431,7 @@ def test_restore_found_by_id(self) -> None: # 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"} - ] - } - } + 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), From def28d229cd82f80b786c13a7c65b44d2efc100c Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Sat, 25 Apr 2026 16:24:01 -0300 Subject: [PATCH 11/15] rfapi: extract error message from JSON body on non-2xx Backend trash endpoints (DELETE /:ws/:project, DELETE /:ws/:project/:version, DELETE /:ws/workflows/:url, GET /:ws/trash, POST /:ws/trash/restore) now return proper 4xx/5xx HTTP status codes with `{"error": "..."}` JSON bodies instead of 200 with an error tucked into the body. Update the Python SDK to pull the `error` string out before raising RoboflowError so users (and the CLI's error formatter) see a clean message instead of the raw response body. Falls back to response.text if the body isn't JSON or doesn't have an `error` field, so non-trash endpoints (which still return raw text on some failure paths) keep their existing behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/adapters/rfapi.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 52c0c860..152c8519 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -855,6 +855,23 @@ def search_universe(query, *, api_key=None, project_type=None, limit=12, page=1) # --------------------------------------------------------------------------- +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. + """ + try: + body = response.json() + if isinstance(body, dict) and body.get("error"): + raise RoboflowError(body["error"]) + except (ValueError, AttributeError): + pass + raise RoboflowError(response.text) + + def delete_project(api_key, workspace_url, project_url): """DELETE /{workspace}/{project} — move a project to Trash (30-day retention). @@ -865,7 +882,7 @@ def delete_project(api_key, workspace_url, project_url): url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}" response = requests.delete(url) if response.status_code != 200: - raise RoboflowError(response.text) + _raise_for_trash_response(response) return response.json() @@ -877,7 +894,7 @@ def delete_version(api_key, workspace_url, project_url, version): url = f"{API_URL}/{workspace_url}/{project_url}/{version}?api_key={api_key}" response = requests.delete(url) if response.status_code != 200: - raise RoboflowError(response.text) + _raise_for_trash_response(response) return response.json() @@ -888,7 +905,7 @@ def delete_workflow(api_key, workspace_url, workflow_url): url = f"{API_URL}/{workspace_url}/workflows/{workflow_url}?api_key={api_key}" response = requests.delete(url) if response.status_code != 200: - raise RoboflowError(response.text) + _raise_for_trash_response(response) return response.json() @@ -902,7 +919,7 @@ def list_trash(api_key, workspace_url): url = f"{API_URL}/{workspace_url}/trash?api_key={api_key}" response = requests.get(url) if response.status_code != 200: - raise RoboflowError(response.text) + _raise_for_trash_response(response) return response.json() @@ -918,7 +935,7 @@ def restore_trash_item(api_key, workspace_url, item_type, item_id, parent_id=Non payload["parentId"] = parent_id response = requests.post(url, json=payload) if response.status_code != 200: - raise RoboflowError(response.text) + _raise_for_trash_response(response) return response.json() From 8e1360d6d8bc7c2748240fa44abbb5b4928cd531 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Sat, 25 Apr 2026 16:48:16 -0300 Subject: [PATCH 12/15] rfapi: restructure _raise_for_trash_response so the raise is unconditional The previous version raised inside the try block if the JSON body had an `error` field, then fell through to a second raise outside the try if it didn't. Worked, but if anyone widened the except clause from `ValueError` to something broader (e.g. `Exception`) the success-path raise would silently get swallowed and the helper would return None, breaking every caller's `if status != 200: _raise_for_trash_response(response)` flow. Compute the message inside the try, then raise once unconditionally outside it. Same observable behavior, no swallow risk. --- roboflow/adapters/rfapi.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 152c8519..25669541 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -862,14 +862,19 @@ def _raise_for_trash_response(response): 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) and body.get("error"): - raise RoboflowError(body["error"]) - except (ValueError, AttributeError): + if isinstance(body, dict): + msg = body.get("error") + except ValueError: + # Body wasn't JSON — fall through to response.text. pass - raise RoboflowError(response.text) + raise RoboflowError(msg or response.text) def delete_project(api_key, workspace_url, project_url): From 3624bacbc5f76f8f4c8dbd8e2825a18111b61cad Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Sat, 25 Apr 2026 18:03:43 -0300 Subject: [PATCH 13/15] CLI: per-type scope hints on restore, drop dead matcher branch in Version.restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow restore hint was 'project:update' → should be 'workflow:update' - version restore hint was 'project:update' → should be 'version:update' (the public route picks the per-type scope, so the old hints sent users to add a scope they already had) Also dropped the parentId fallback in Version.restore's matcher. self.project is always the project URL slug — the SDK never holds the Firestore doc id that parentId would equal — so the `or v.get("parentId") == self.project` branch was dead. Comment now explicitly says we match on parentUrl because that's the only available key. --- roboflow/cli/handlers/version.py | 2 +- roboflow/cli/handlers/workflow.py | 2 +- roboflow/core/version.py | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index 13ec2cb2..275bc6ce 100644 --- a/roboflow/cli/handlers/version.py +++ b/roboflow/cli/handlers/version.py @@ -491,7 +491,7 @@ def _restore_version(args): # noqa: ANN001 output_error( args, str(exc), - hint="Check your API key has 'project:update' scope on this workspace.", + hint="Check your API key has 'version:update' scope on this workspace.", exit_code=3, ) return diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py index b550a331..cd0be353 100644 --- a/roboflow/cli/handlers/workflow.py +++ b/roboflow/cli/handlers/workflow.py @@ -503,7 +503,7 @@ def _restore_workflow(args) -> None: # noqa: ANN001 output_error( args, str(exc), - hint="Check your API key has 'project:update' scope on this workspace.", + hint="Check your API key has 'workflow:update' scope on this workspace.", exit_code=3, ) return diff --git a/roboflow/core/version.py b/roboflow/core/version.py index dc715de3..fd4a3f5f 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -706,13 +706,12 @@ def restore(self): """ 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 or v.get("parentId") == self.project) - ), + (v for v in versions if str(v.get("id")) == str(self.version) and v.get("parentUrl") == self.project), None, ) if not match: From cbd209e4519d20aae2d01e30a35489b5cd19bbe6 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Tue, 28 Apr 2026 08:35:07 -0500 Subject: [PATCH 14/15] trash: use "project" instead of "dataset" for the item-type vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the SDK with the rest of the public API. Diego flagged on the docs PR that DELETE responses use `type: "project"` but the trash list/restore payloads were using `type: "dataset"` — same item, two names. Fixing on `"project"` to match the rest of the public surface (`:project` URL params, DELETE responses, the `project:*` scope namespace). Backend ships the same change on the `soft-delete` branch; the docs PR covers the user-visible REST API and SDK pages. - `Project.restore()`: looks up `trash["sections"]["projects"]` and passes `"project"` to `rfapi.restore_trash_item`. Docstring updated to show `type: "project"` in the example response. - `Workspace.restore_from_trash()`: docstring lists the valid item types as `"project"`, `"version"`, `"workflow"` (was `"dataset"`/.../...). - `roboflow project restore` CLI: same lookup + restore call. - `rfapi.restore_trash_item` docstring: matching update. - Tests under `tests/cli/test_project_handler.py` and `tests/cli/test_trash_handler.py` now exercise the project vocabulary. Co-Authored-By: Claude Opus 4.7 (1M context) --- roboflow/adapters/rfapi.py | 4 ++-- roboflow/cli/handlers/project.py | 6 +++--- roboflow/core/project.py | 8 ++++---- roboflow/core/workspace.py | 6 +++--- tests/cli/test_project_handler.py | 8 ++++---- tests/cli/test_trash_handler.py | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 25669541..0354ddaf 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -931,8 +931,8 @@ 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 "dataset", "version", "workflow". - `parent_id` is required when restoring a version (the dataset id). + `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} diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py index 88cc6c35..0b9d6125 100644 --- a/roboflow/cli/handlers/project.py +++ b/roboflow/cli/handlers/project.py @@ -314,8 +314,8 @@ def _restore_project(args): # noqa: ANN001 ) return - datasets = trash.get("sections", {}).get("datasets", []) - match = next((d for d in datasets if d.get("url") == project_slug), None) + 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, @@ -326,7 +326,7 @@ def _restore_project(args): # noqa: ANN001 return try: - data = rfapi.restore_trash_item(api_key, workspace_url, "dataset", match["id"]) + data = rfapi.restore_trash_item(api_key, workspace_url, "project", match["id"]) except rfapi.RoboflowError as exc: output_error( args, diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 841384be..a1acd55a 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -1069,7 +1069,7 @@ def restore(self): RuntimeError if the project isn't currently in Trash. Returns: - dict: Server response with `{restored: True, type: "dataset", ...}`. + dict: Server response with `{restored: True, type: "project", ...}`. Example: >>> import roboflow @@ -1079,8 +1079,8 @@ def restore(self): >>> project.restore() """ trash = rfapi.list_trash(self.__api_key, self.__workspace) - datasets = trash.get("sections", {}).get("datasets", []) - match = next((d for d in datasets if d.get("url") == self.__project_name), None) + 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, "dataset", match["id"]) + return rfapi.restore_trash_item(self.__api_key, self.__workspace, "project", match["id"]) diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index de5cb1d8..965b51b7 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1339,7 +1339,7 @@ def trash(self) -> dict: Returns a dict with: - `items`: flat list of everything in Trash - - `sections`: grouped by `datasets`, `versions`, `workflows` + - `sections`: grouped by `projects`, `versions`, `workflows` Each item includes `id`, `type`, `name`, `deletedAt`, `scheduledCleanupAt`, and — for versions — `parentId` / `parentUrl`. @@ -1358,9 +1358,9 @@ def restore_from_trash(self, item_type: str, item_id: str, parent_id: Optional[s Restore an item from Trash. Args: - item_type: one of "dataset", "version", "workflow" + 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 dataset id + parent_id: required when restoring a version — the parent project id Returns: dict: Server response with `{restored: True, type, id}`. diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py index f0a01bf2..628ba473 100644 --- a/tests/cli/test_project_handler.py +++ b/tests/cli/test_project_handler.py @@ -94,16 +94,16 @@ def test_restore_found(self) -> None: from roboflow.cli.handlers.project import _restore_project - trash = {"sections": {"datasets": [{"id": "abc123", "url": "my-proj", "name": "My 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": "dataset", "id": "abc123"}, + 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", "dataset", "abc123") + 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 @@ -115,7 +115,7 @@ def test_restore_not_in_trash(self) -> None: with ( patch( "roboflow.adapters.rfapi.list_trash", - return_value={"sections": {"datasets": []}}, + return_value={"sections": {"projects": []}}, ), patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, patch("sys.exit"), diff --git a/tests/cli/test_trash_handler.py b/tests/cli/test_trash_handler.py index e0009d99..996508aa 100644 --- a/tests/cli/test_trash_handler.py +++ b/tests/cli/test_trash_handler.py @@ -59,7 +59,7 @@ def test_list_text_output(self) -> None: trash_response = { "items": [ { - "type": "dataset", + "type": "project", "id": "d1", "name": "My Project", "deletedAt": "2026-04-01", From 1825d587eb214bfa60d44eb4de27b11a4d363ac8 Mon Sep 17 00:00:00 2001 From: Brad Dwyer Date: Tue, 28 Apr 2026 22:34:55 -0500 Subject: [PATCH 15/15] bump version to 1.3.7 Brings in the soft-delete / Trash work from #463 plus the master merge that lands the 1.3.6 image-upload fix from #464. CHANGELOG updated with the 1.3.7 release notes (SDK + CLI + low-level rfapi additions, the upload-bytes change inherited from 1.3.6, and the intentional non-exposure of permanent-delete actions). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++ roboflow/__init__.py | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) 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/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):