Skip to content

Commit 2b9ddd4

Browse files
committed
initial version
1 parent 375504e commit 2b9ddd4

File tree

4 files changed

+433
-0
lines changed

4 files changed

+433
-0
lines changed

roboflow/adapters/rfapi.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,52 @@ def get_version_export(
152152
return payload
153153

154154

155+
def start_search_export(
156+
api_key: str,
157+
workspace_url: str,
158+
query: str,
159+
format: str,
160+
dataset: Optional[str] = None,
161+
annotation_group: Optional[str] = None,
162+
name: Optional[str] = None,
163+
) -> str:
164+
"""Start a search export job.
165+
166+
Returns the export_id string used to poll for completion.
167+
168+
Raises RoboflowError on non-202 responses.
169+
"""
170+
url = f"{API_URL}/{workspace_url}/search/export?api_key={api_key}"
171+
body: Dict[str, str] = {"query": query, "format": format}
172+
if dataset is not None:
173+
body["dataset"] = dataset
174+
if annotation_group is not None:
175+
body["annotationGroup"] = annotation_group
176+
if name is not None:
177+
body["name"] = name
178+
179+
response = requests.post(url, json=body)
180+
if response.status_code != 202:
181+
raise RoboflowError(response.text)
182+
183+
payload = response.json()
184+
return payload["link"]
185+
186+
187+
def get_search_export(api_key: str, workspace_url: str, export_id: str) -> dict:
188+
"""Poll the status of a search export job.
189+
190+
Returns dict with ``ready`` (bool) and ``link`` (str, present when ready).
191+
192+
Raises RoboflowError on non-200 responses.
193+
"""
194+
url = f"{API_URL}/{workspace_url}/search/export/{export_id}?api_key={api_key}"
195+
response = requests.get(url)
196+
if response.status_code != 200:
197+
raise RoboflowError(response.text)
198+
return response.json()
199+
200+
155201
def upload_image(
156202
api_key,
157203
project_url,

roboflow/core/workspace.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
import json
66
import os
77
import sys
8+
import time
9+
import zipfile
810
from typing import Any, Dict, List, Optional
911

1012
import requests
1113
from PIL import Image
14+
from requests.exceptions import HTTPError
15+
from tqdm import tqdm
1216

1317
from roboflow.adapters import rfapi
1418
from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError, RoboflowError
@@ -662,6 +666,119 @@ def _upload_zip(
662666
except Exception as e:
663667
print(f"An error occured when uploading the model: {e}")
664668

669+
def search_export(
670+
self,
671+
query: str,
672+
format: str = "coco",
673+
location: Optional[str] = None,
674+
dataset: Optional[str] = None,
675+
annotation_group: Optional[str] = None,
676+
name: Optional[str] = None,
677+
extract_zip: bool = True,
678+
) -> str:
679+
"""Export search results as a downloaded dataset.
680+
681+
Args:
682+
query: Search query string (e.g. ``"tag:annotate"`` or ``"*"``).
683+
format: Annotation format for the export (default ``"coco"``).
684+
location: Local directory to save the exported dataset.
685+
Defaults to ``./search-export-{format}``.
686+
dataset: Limit export to a specific dataset (project) slug.
687+
annotation_group: Limit export to a specific annotation group.
688+
name: Optional name for the export.
689+
extract_zip: If True (default), extract the zip and remove it.
690+
If False, keep the zip file as-is.
691+
692+
Returns:
693+
Absolute path to the extracted directory or the zip file.
694+
695+
Raises:
696+
ValueError: If both *dataset* and *annotation_group* are provided.
697+
RoboflowError: On API errors or export timeout.
698+
"""
699+
if dataset is not None and annotation_group is not None:
700+
raise ValueError("dataset and annotation_group are mutually exclusive; provide only one")
701+
702+
if location is None:
703+
location = f"./search-export-{format}"
704+
location = os.path.abspath(location)
705+
706+
# 1. Start the export
707+
export_id = rfapi.start_search_export(
708+
api_key=self.__api_key,
709+
workspace_url=self.url,
710+
query=query,
711+
format=format,
712+
dataset=dataset,
713+
annotation_group=annotation_group,
714+
name=name,
715+
)
716+
print(f"Export started (id={export_id}). Polling for completion...")
717+
718+
# 2. Poll until ready
719+
timeout = 600
720+
poll_interval = 5
721+
elapsed = 0
722+
while elapsed < timeout:
723+
status = rfapi.get_search_export(
724+
api_key=self.__api_key,
725+
workspace_url=self.url,
726+
export_id=export_id,
727+
)
728+
if status.get("ready"):
729+
break
730+
time.sleep(poll_interval)
731+
elapsed += poll_interval
732+
else:
733+
raise RoboflowError(f"Search export timed out after {timeout}s")
734+
735+
download_url = status["link"]
736+
737+
# 3. Download zip
738+
if not os.path.exists(location):
739+
os.makedirs(location)
740+
741+
zip_path = os.path.join(location, "roboflow.zip")
742+
response = requests.get(download_url, stream=True)
743+
try:
744+
response.raise_for_status()
745+
except HTTPError as e:
746+
raise RoboflowError(f"Failed to download search export: {e}")
747+
748+
total_length = response.headers.get("content-length")
749+
try:
750+
total_kib = int(total_length) // 1024 + 1 if total_length is not None else None
751+
except (TypeError, ValueError):
752+
total_kib = None
753+
with open(zip_path, "wb") as f:
754+
for chunk in tqdm(
755+
response.iter_content(chunk_size=1024),
756+
desc=f"Downloading search export to {location}",
757+
total=total_kib,
758+
):
759+
if chunk:
760+
f.write(chunk)
761+
f.flush()
762+
763+
if extract_zip:
764+
desc = f"Extracting search export to {location}"
765+
try:
766+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
767+
for member in tqdm(zip_ref.infolist(), desc=desc):
768+
try:
769+
zip_ref.extract(member, location)
770+
except zipfile.error:
771+
raise RoboflowError("Error unzipping search export")
772+
except zipfile.BadZipFile:
773+
raise RoboflowError(f"Downloaded file is not a valid zip archive: {zip_path}")
774+
775+
os.remove(zip_path)
776+
print(f"Search export extracted to {location}")
777+
return location
778+
else:
779+
print(f"Search export saved to {zip_path}")
780+
return zip_path
781+
665782
def __str__(self):
666783
projects = self.projects()
667784
json_value = {"name": self.name, "url": self.url, "projects": projects}

roboflow/roboflowpy.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,21 @@ def infer(args):
202202
print(group)
203203

204204

205+
def search_export(args):
206+
rf = roboflow.Roboflow()
207+
workspace = rf.workspace(args.workspace)
208+
result = workspace.search_export(
209+
query=args.query,
210+
format=args.format,
211+
location=args.location,
212+
dataset=args.dataset,
213+
annotation_group=args.annotation_group,
214+
name=args.name,
215+
extract_zip=not args.no_extract,
216+
)
217+
print(result)
218+
219+
205220
def _argparser():
206221
parser = argparse.ArgumentParser(description="Welcome to the roboflow CLI: computer vision at your fingertips 🪄")
207222
subparsers = parser.add_subparsers(title="subcommands")
@@ -218,6 +233,7 @@ def _argparser():
218233
_add_run_video_inference_api_parser(subparsers)
219234
deployment.add_deployment_parser(subparsers)
220235
_add_whoami_parser(subparsers)
236+
_add_search_export_parser(subparsers)
221237

222238
parser.add_argument("-v", "--version", help="show version info", action="store_true")
223239
parser.set_defaults(func=show_version)
@@ -594,6 +610,19 @@ def _add_get_workspace_project_version_parser(subparsers):
594610
workspace_project_version_parser.set_defaults(func=get_workspace_project_version)
595611

596612

613+
def _add_search_export_parser(subparsers):
614+
p = subparsers.add_parser("search-export", help="Export search results as a dataset")
615+
p.add_argument("query", help="Search query (e.g. 'tag:annotate' or '*')")
616+
p.add_argument("-f", dest="format", default="coco", help="Annotation format (default: coco)")
617+
p.add_argument("-w", dest="workspace", help="Workspace url or id (uses default workspace if not specified)")
618+
p.add_argument("-l", dest="location", help="Local directory to save the export")
619+
p.add_argument("-d", dest="dataset", help="Limit export to a specific dataset (project slug)")
620+
p.add_argument("-g", dest="annotation_group", help="Limit export to a specific annotation group")
621+
p.add_argument("-n", dest="name", help="Optional name for the export")
622+
p.add_argument("--no-extract", dest="no_extract", action="store_true", help="Skip extraction, keep the zip file")
623+
p.set_defaults(func=search_export)
624+
625+
597626
def _add_login_parser(subparsers):
598627
login_parser = subparsers.add_parser("login", help="Log in to Roboflow")
599628
login_parser.add_argument(

0 commit comments

Comments
 (0)