Skip to content

Commit dfa6d6e

Browse files
Add workspace image deletion (DATAMAN-162) (#442)
* adding search/search all functionality to workspace * test * config * Delete images from workspace * fix(pre_commit): 🎨 auto format pre-commit hooks --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1eb8238 commit dfa6d6e

File tree

6 files changed

+367
-1
lines changed

6 files changed

+367
-1
lines changed

docs/index.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ Or from the CLI:
126126
roboflow search-export "class:person" -f coco -d my-project -l ./my-export
127127
```
128128

129+
### Delete Workspace Images
130+
131+
Delete orphan images (not in any project) from your workspace:
132+
133+
```python
134+
workspace = rf.workspace()
135+
136+
# Delete orphan images by ID
137+
result = workspace.delete_images(["image_id_1", "image_id_2"])
138+
print(f"Deleted: {result['deletedSources']}, Skipped: {result['skippedSources']}")
139+
```
140+
129141
### Upload with Metadata
130142

131143
Attach custom key-value metadata to images during upload:

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ convention = "google"
8888
"E402", # Module level import not at top of file
8989
"F401", # Imported but unused
9090
]
91+
"tests/manual/*.py" = [
92+
"INP001", # Manual scripts don't need __init__.py
93+
]
9194

9295
[tool.ruff.lint.pyupgrade]
9396
# Preserve types, even if a file imports `from __future__ import annotations`.

roboflow/adapters/rfapi.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,71 @@ def get_search_export(api_key: str, workspace_url: str, export_id: str, session:
199199
return response.json()
200200

201201

202+
def workspace_search(
203+
api_key: str,
204+
workspace_url: str,
205+
query: str,
206+
page_size: int = 50,
207+
fields: Optional[List[str]] = None,
208+
continuation_token: Optional[str] = None,
209+
) -> dict:
210+
"""Search across all images in a workspace using RoboQL syntax.
211+
212+
Args:
213+
api_key: Roboflow API key.
214+
workspace_url: Workspace slug/url.
215+
query: RoboQL search query (e.g. ``"tag:review"``, ``"project:false"``).
216+
page_size: Number of results per page (default 50).
217+
fields: Fields to include in each result.
218+
continuation_token: Token for fetching the next page.
219+
220+
Returns:
221+
Parsed JSON response with ``results``, ``total``, and ``continuationToken``.
222+
223+
Raises:
224+
RoboflowError: On non-200 response status codes.
225+
"""
226+
url = f"{API_URL}/{workspace_url}/search/v1?api_key={api_key}"
227+
payload: Dict[str, Union[str, int, List[str]]] = {
228+
"query": query,
229+
"pageSize": page_size,
230+
}
231+
if fields is not None:
232+
payload["fields"] = fields
233+
if continuation_token is not None:
234+
payload["continuationToken"] = continuation_token
235+
236+
response = requests.post(url, json=payload)
237+
if response.status_code != 200:
238+
raise RoboflowError(response.text)
239+
return response.json()
240+
241+
242+
def workspace_delete_images(
243+
api_key: str,
244+
workspace_url: str,
245+
image_ids: List[str],
246+
) -> dict:
247+
"""Delete orphan images from a workspace.
248+
249+
Args:
250+
api_key: Roboflow API key.
251+
workspace_url: Workspace slug/url.
252+
image_ids: List of image IDs to delete.
253+
254+
Returns:
255+
Parsed JSON response with deletion counts.
256+
257+
Raises:
258+
RoboflowError: On non-200 response status codes.
259+
"""
260+
url = f"{API_URL}/{workspace_url}/images?api_key={api_key}"
261+
response = requests.delete(url, json={"images": image_ids})
262+
if response.status_code != 200:
263+
raise RoboflowError(response.text)
264+
return response.json()
265+
266+
202267
def upload_image(
203268
api_key,
204269
project_url,

roboflow/core/workspace.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
import sys
88
import time
9-
from typing import Any, Dict, List, Optional
9+
from typing import Any, Dict, Generator, List, Optional
1010

1111
import requests
1212
from PIL import Image
@@ -666,6 +666,112 @@ def _upload_zip(
666666
except Exception as e:
667667
print(f"An error occured when uploading the model: {e}")
668668

669+
def search(
670+
self,
671+
query: str,
672+
page_size: int = 50,
673+
fields: Optional[List[str]] = None,
674+
continuation_token: Optional[str] = None,
675+
) -> dict:
676+
"""Search across all images in the workspace using RoboQL syntax.
677+
678+
Args:
679+
query: RoboQL search query (e.g. ``"tag:review"``, ``"project:false"``
680+
for orphan images, or free-text for semantic CLIP search).
681+
page_size: Number of results per page (default 50).
682+
fields: Fields to include in each result.
683+
Defaults to ``["tags", "projects", "filename"]``.
684+
continuation_token: Token returned by a previous call for fetching
685+
the next page.
686+
687+
Returns:
688+
Dict with ``results`` (list), ``total`` (int), and
689+
``continuationToken`` (str or None).
690+
691+
Example:
692+
>>> ws = rf.workspace()
693+
>>> page = ws.search("tag:review", page_size=10)
694+
>>> print(page["total"])
695+
>>> for img in page["results"]:
696+
... print(img["filename"])
697+
"""
698+
if fields is None:
699+
fields = ["tags", "projects", "filename"]
700+
701+
return rfapi.workspace_search(
702+
api_key=self.__api_key,
703+
workspace_url=self.url,
704+
query=query,
705+
page_size=page_size,
706+
fields=fields,
707+
continuation_token=continuation_token,
708+
)
709+
710+
def delete_images(self, image_ids: List[str]) -> dict:
711+
"""Delete orphan images from the workspace.
712+
713+
Only deletes images not associated with any project.
714+
Images still in projects are skipped.
715+
716+
Args:
717+
image_ids: List of image IDs to delete.
718+
719+
Returns:
720+
Dict with ``deletedSources`` and ``skippedSources`` counts.
721+
722+
Example:
723+
>>> ws = rf.workspace()
724+
>>> result = ws.delete_images(["img_id_1", "img_id_2"])
725+
>>> print(result["deletedSources"])
726+
"""
727+
return rfapi.workspace_delete_images(
728+
api_key=self.__api_key,
729+
workspace_url=self.url,
730+
image_ids=image_ids,
731+
)
732+
733+
def search_all(
734+
self,
735+
query: str,
736+
page_size: int = 50,
737+
fields: Optional[List[str]] = None,
738+
) -> Generator[List[dict], None, None]:
739+
"""Paginated search across all images in the workspace.
740+
741+
Yields one page of results at a time, automatically following
742+
``continuationToken`` until all results have been returned.
743+
744+
Args:
745+
query: RoboQL search query.
746+
page_size: Number of results per page (default 50).
747+
fields: Fields to include in each result.
748+
Defaults to ``["tags", "projects", "filename"]``.
749+
750+
Yields:
751+
A list of result dicts for each page.
752+
753+
Example:
754+
>>> ws = rf.workspace()
755+
>>> for page in ws.search_all("tag:review"):
756+
... for img in page:
757+
... print(img["filename"])
758+
"""
759+
token = None
760+
while True:
761+
response = self.search(
762+
query=query,
763+
page_size=page_size,
764+
fields=fields,
765+
continuation_token=token,
766+
)
767+
results = response.get("results", [])
768+
if not results:
769+
break
770+
yield results
771+
token = response.get("continuationToken")
772+
if not token:
773+
break
774+
669775
def search_export(
670776
self,
671777
query: str,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Manual demo for workspace-level search (DATAMAN-163).
2+
3+
Usage:
4+
python tests/manual/demo_workspace_search.py
5+
6+
Uses staging credentials from CLAUDE.md.
7+
"""
8+
9+
import os
10+
11+
import roboflow
12+
13+
thisdir = os.path.dirname(os.path.abspath(__file__))
14+
os.environ["ROBOFLOW_CONFIG_DIR"] = f"{thisdir}/data/.config"
15+
16+
WORKSPACE = "model-evaluation-workspace"
17+
18+
rf = roboflow.Roboflow()
19+
ws = rf.workspace(WORKSPACE)
20+
21+
# --- Single page search ---
22+
print("=== Single page search ===")
23+
page = ws.search("project:false", page_size=5)
24+
print(f"Total results: {page['total']}")
25+
print(f"Results in this page: {len(page['results'])}")
26+
print(f"Continuation token: {page.get('continuationToken')}")
27+
for img in page["results"]:
28+
print(f" - {img.get('filename', 'N/A')}")
29+
30+
# --- Paginated search_all ---
31+
print("\n=== Paginated search_all (page_size=3, max 2 pages) ===")
32+
count = 0
33+
for page_results in ws.search_all("*", page_size=3):
34+
count += 1
35+
print(f"Page {count}: {len(page_results)} results")
36+
for img in page_results:
37+
print(f" - {img.get('filename', 'N/A')}")
38+
if count >= 2:
39+
print("(stopping after 2 pages for demo)")
40+
break
41+
42+
print("\nDone.")

0 commit comments

Comments
 (0)