Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,34 @@ roboflow workflow fork other-ws/their-workflow
roboflow version create -p my-project --settings settings.json
```

### Delete and restore (soft delete / Trash)

```bash
# Move a project to Trash — any in-flight training jobs are cancelled automatically.
# Items stay in Trash for 30 days, then are permanently cleaned up.
roboflow project delete my-workspace/my-project
roboflow project restore my-workspace/my-project

# Same flow for versions (also cancels in-flight training on the version).
roboflow version delete my-workspace/my-project/3
roboflow version restore my-workspace/my-project/3

# Same flow for workflows.
roboflow workflow delete my-workflow
roboflow workflow restore my-workflow

# Inspect what's currently in Trash.
roboflow trash list

# Skip the confirmation prompt for scripts.
roboflow project delete my-workspace/my-project --yes
```

Permanent deletion (emptying Trash or skipping the retention window for a
single item) is intentionally not available from the SDK or CLI — those
actions destroy data irrecoverably and live only in the web UI's Trash
view. Items left in Trash are cleaned up automatically after 30 days.

### Workspace stats and billing

```bash
Expand Down Expand Up @@ -177,6 +205,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
| `workflow` | Manage workflows |
| `folder` | Manage workspace folders |
| `annotation` | Annotation batches and jobs |
| `trash` | List items in Trash |
| `universe` | Search Roboflow Universe |
| `video` | Video inference |
| `batch` | Batch processing jobs *(coming soon)* |
Expand Down
2 changes: 1 addition & 1 deletion roboflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
99 changes: 99 additions & 0 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,3 +849,102 @@ def search_universe(query, *, api_key=None, project_type=None, limit=12, page=1)
if response.status_code != 200:
raise RoboflowError(response.text)
return response.json()


# ---------------------------------------------------------------------------
# Soft-delete / Trash operations
# ---------------------------------------------------------------------------


def _raise_for_trash_response(response):
"""Raise RoboflowError with the cleanest message available.

Backend trash endpoints return `{"error": "..."}` JSON on non-2xx.
Surface that string to the caller instead of the raw response body so
error messages are agent-friendly. Falls back to the raw text if the
body isn't JSON or doesn't contain an `error` field.

The single `raise` at the end means we can't accidentally swallow the
intended error if a future refactor widens the except clause.
"""
msg = None
try:
body = response.json()
if isinstance(body, dict):
msg = body.get("error")
except ValueError:
# Body wasn't JSON — fall through to response.text.
pass
raise RoboflowError(msg or response.text)


def delete_project(api_key, workspace_url, project_url):
"""DELETE /{workspace}/{project} — move a project to Trash (30-day retention).

Any in-flight training jobs for the project will be cancelled automatically.
The project can be restored via `restore_trash_item` within the retention
window; after 30 days the cleanup cron permanently removes it.
"""
url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}"
response = requests.delete(url)
if response.status_code != 200:
_raise_for_trash_response(response)
return response.json()


def delete_version(api_key, workspace_url, project_url, version):
"""DELETE /{workspace}/{project}/{version} — move a version to Trash.

Any in-flight training on the version will be cancelled automatically.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}?api_key={api_key}"
response = requests.delete(url)
if response.status_code != 200:
_raise_for_trash_response(response)
return response.json()


def delete_workflow(api_key, workspace_url, workflow_url):
"""DELETE /{workspace}/workflows/{workflowUrl} — move a workflow to Trash
(30-day retention). Restore via `restore_trash_item(..., "workflow", ...)`.
"""
url = f"{API_URL}/{workspace_url}/workflows/{workflow_url}?api_key={api_key}"
response = requests.delete(url)
if response.status_code != 200:
_raise_for_trash_response(response)
return response.json()


def list_trash(api_key, workspace_url):
"""GET /{workspace}/trash — list items currently in Trash.

Returns a dict with `items` (flat list) and `sections` (grouped by type:
`datasets`, `versions`, `workflows`). Each item includes `id`, `type`,
`name`, `deletedAt`, `scheduledCleanupAt`, and (for versions) `parentId`.
"""
url = f"{API_URL}/{workspace_url}/trash?api_key={api_key}"
response = requests.get(url)
if response.status_code != 200:
_raise_for_trash_response(response)
return response.json()


def restore_trash_item(api_key, workspace_url, item_type, item_id, parent_id=None):
"""POST /{workspace}/trash/restore — restore an item from Trash.

`item_type` must be one of "project", "version", "workflow".
`parent_id` is required when restoring a version (the parent project id).
"""
url = f"{API_URL}/{workspace_url}/trash/restore?api_key={api_key}"
payload = {"type": item_type, "id": item_id}
if parent_id is not None:
payload["parentId"] = parent_id
response = requests.post(url, json=payload)
if response.status_code != 200:
_raise_for_trash_response(response)
return response.json()


# Note: permanent-delete from Trash (deleteImmediately / empty) is
# intentionally not exposed on the public API — those actions destroy data
# irrecoverably and are only available through the web UI's Trash view.
2 changes: 2 additions & 0 deletions roboflow/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
139 changes: 139 additions & 0 deletions roboflow/cli/handlers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -198,3 +219,121 @@ def _create_project(args): # noqa: ANN001
"type": project.type,
}
output(args, data, text=f"Created project: {project.name} ({project.id})")


def _delete_project(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.cli._resolver import resolve_resource
from roboflow.config import load_roboflow_api_key

try:
workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace)
except ValueError as exc:
output_error(
args,
str(exc),
hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.",
)
return

api_key = args.api_key or load_roboflow_api_key(workspace_url)
if not api_key:
output_error(
args,
"No API key found.",
hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.",
exit_code=2,
)
return

if not getattr(args, "yes", False) and not getattr(args, "json", False):
import typer

confirmed = typer.confirm(
f"Move '{workspace_url}/{project_slug}' to Trash? "
"(Retained for 30 days. Any in-flight trainings will be cancelled.)",
default=False,
)
if not confirmed:
output(args, {"cancelled": True}, text="Cancelled.")
return

try:
data = rfapi.delete_project(api_key, workspace_url, project_slug)
except rfapi.RoboflowError as exc:
output_error(
args,
str(exc),
hint="Check your API key has 'project:update' scope on this workspace.",
exit_code=3,
)
return

output(
args,
data,
text=f"Moved {workspace_url}/{project_slug} to Trash (30-day retention).",
)


def _restore_project(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.cli._resolver import resolve_resource
from roboflow.config import load_roboflow_api_key

try:
workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace)
except ValueError as exc:
output_error(
args,
str(exc),
hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.",
)
return

api_key = args.api_key or load_roboflow_api_key(workspace_url)
if not api_key:
output_error(
args,
"No API key found.",
hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.",
exit_code=2,
)
return

try:
trash = rfapi.list_trash(api_key, workspace_url)
except rfapi.RoboflowError as exc:
output_error(
args,
str(exc),
hint="Check your API key has 'project:read' scope on this workspace.",
exit_code=3,
)
return

projects = trash.get("sections", {}).get("projects", [])
match = next((p for p in projects if p.get("url") == project_slug), None)
if not match:
output_error(
args,
f"Project '{workspace_url}/{project_slug}' is not in Trash.",
hint="Run 'roboflow trash list' to see what can be restored.",
exit_code=3,
)
return

try:
data = rfapi.restore_trash_item(api_key, workspace_url, "project", match["id"])
except rfapi.RoboflowError as exc:
output_error(
args,
str(exc),
hint="Check your API key has 'project:update' scope on this workspace.",
exit_code=3,
)
return

output(args, data, text=f"Restored {workspace_url}/{project_slug} from Trash.")
Loading
Loading