Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 71 additions & 1 deletion roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import mimetypes
import os
import urllib
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
from urllib.parse import quote

import requests
Expand Down Expand Up @@ -378,6 +378,76 @@ def workspace_delete_images(
return response.json()


def update_image_metadata(
api_key: str,
workspace_url: str,
image_id: str,
*,
metadata: Optional[Dict] = None,
remove_metadata: Optional[List[str]] = None,
add_tags: Optional[List[str]] = None,
remove_tags: Optional[List[str]] = None,
) -> dict:
"""Update metadata and tags on a single image (synchronous).

Args:
api_key: Roboflow API key.
workspace_url: Workspace slug/url.
image_id: Image/source ID.
metadata: Key-value pairs to set on the image.
remove_metadata: Metadata keys to delete.
add_tags: Tags to append.
remove_tags: Tags to remove.

Returns:
Parsed JSON response (``{"success": true}``).

Raises:
RoboflowError: On non-200 response.
"""
url = f"{API_URL}/{workspace_url}/images/{quote(image_id, safe='')}/metadata"
body: Dict[str, Any] = {}
if metadata is not None:
body["metadata"] = metadata
if remove_metadata is not None:
body["removeMetadata"] = remove_metadata
if add_tags is not None:
body["addTags"] = add_tags
if remove_tags is not None:
body["removeTags"] = remove_tags

response = requests.post(url, params={"api_key": api_key}, json=body)
if response.status_code != 200:
raise RoboflowError(response.text)
return response.json()


def batch_update_image_metadata(
api_key: str,
workspace_url: str,
updates: List[Dict],
) -> dict:
"""Batch-update metadata and tags on multiple images (asynchronous).

Args:
api_key: Roboflow API key.
workspace_url: Workspace slug/url.
updates: List of update dicts, each containing ``imageId`` and optionally
``metadata``, ``removeMetadata``, ``addTags``, ``removeTags``.

Returns:
Parsed JSON with ``taskId`` and ``url`` for polling.

Raises:
RoboflowError: On non-202 response.
"""
url = f"{API_URL}/{workspace_url}/images/metadata"
response = requests.post(url, params={"api_key": api_key}, json={"updates": updates})
if response.status_code != 202:
raise RoboflowError(response.text)
return response.json()


def upload_image(
api_key,
project_url,
Expand Down
231 changes: 182 additions & 49 deletions roboflow/cli/handlers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,70 @@ def search_images(
_search(args)


@image_app.command("tag")
def _metadata_command(
ctx: typer.Context,
image_ids: str,
metadata: Optional[str] = None,
remove_metadata: Optional[str] = None,
tags: Optional[str] = None,
remove_tags: Optional[str] = None,
poll: bool = False,
timeout: int = 1800,
) -> None:
"""Update metadata and/or tags on existing images.

Single image ID: updates synchronously.
Multiple comma-separated IDs: uses the batch async endpoint.
"""
args = ctx_to_args(
ctx,
image_ids=image_ids,
metadata=metadata,
remove_metadata=remove_metadata,
add_tags=tags,
remove_tags=remove_tags,
poll=poll,
timeout=timeout,
)
_handle_metadata(args)


@image_app.command("metadata")
def metadata_image(
ctx: typer.Context,
image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs (batch mode if multiple)")],
metadata: Annotated[
Optional[str], typer.Option("-m", "--metadata", help="JSON string of key-value metadata to set")
] = None,
remove_metadata: Annotated[
Optional[str], typer.Option("--remove-metadata", help="Comma-separated metadata keys to remove")
] = None,
tags: Annotated[Optional[str], typer.Option("--tags", help="Comma-separated tags to add")] = None,
remove_tags: Annotated[Optional[str], typer.Option("--remove-tags", help="Comma-separated tags to remove")] = None,
poll: Annotated[bool, typer.Option("--poll/--no-poll", help="For batch updates: poll until complete")] = False,
timeout: Annotated[int, typer.Option("--timeout", help="Polling timeout in seconds")] = 1800,
) -> None:
"""Update metadata and/or tags on existing images."""
_metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout)


@image_app.command("tag", hidden=True)
def tag_image(
ctx: typer.Context,
image_id: Annotated[str, typer.Argument(help="Image ID")],
project: Annotated[str, typer.Option("-p", "--project", help="Project ID")],
add_tags: Annotated[Optional[str], typer.Option("--add", help="Comma-separated tags to add")] = None,
remove_tags: Annotated[Optional[str], typer.Option("--remove", help="Comma-separated tags to remove")] = None,
image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs (batch mode if multiple)")],
metadata: Annotated[
Optional[str], typer.Option("-m", "--metadata", help="JSON string of key-value metadata to set")
] = None,
remove_metadata: Annotated[
Optional[str], typer.Option("--remove-metadata", help="Comma-separated metadata keys to remove")
] = None,
tags: Annotated[Optional[str], typer.Option("--tags", help="Comma-separated tags to add")] = None,
remove_tags: Annotated[Optional[str], typer.Option("--remove-tags", help="Comma-separated tags to remove")] = None,
poll: Annotated[bool, typer.Option("--poll/--no-poll", help="For batch updates: poll until complete")] = False,
timeout: Annotated[int, typer.Option("--timeout", help="Polling timeout in seconds")] = 1800,
) -> None:
"""Add or remove tags on an image."""
args = ctx_to_args(ctx, image_id=image_id, project=project, add_tags=add_tags, remove_tags=remove_tags)
_handle_tag(args)
"""Alias for 'metadata'."""
_metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout)


@image_app.command("delete")
Expand Down Expand Up @@ -379,56 +432,136 @@ def _handle_search(args): # noqa: ANN001
output(args, result, text=json.dumps(result, indent=2))


def _handle_tag(args): # noqa: ANN001
import requests
def _handle_metadata(args): # noqa: ANN001
import json as json_mod

from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.config import API_URL, load_roboflow_api_key
from roboflow.cli._resolver import resolve_ws_and_key

if not args.add_tags and not args.remove_tags:
output_error(args, "Nothing to do", hint="Specify --add and/or --remove with comma-separated tags")
ids = [i.strip() for i in args.image_ids.split(",") if i.strip()]
if not ids:
output_error(args, "No image IDs provided")
return

api_key = args.api_key or load_roboflow_api_key(args.workspace)
if not api_key:
output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2)
metadata_dict = None
if args.metadata:
try:
metadata_dict = json_mod.loads(args.metadata)
if not isinstance(metadata_dict, dict):
output_error(args, "Metadata must be a JSON object", hint='Example: \'{"key": "value"}\'')
return
except json_mod.JSONDecodeError as exc:
output_error(args, f"Invalid metadata JSON: {exc}", hint='Example: \'{"key": "value"}\'')
return

remove_meta_list = (
[k.strip() for k in args.remove_metadata.split(",") if k.strip()] if args.remove_metadata else None
)
add_tags_list = [t.strip() for t in args.add_tags.split(",") if t.strip()] if args.add_tags else None
remove_tags_list = [t.strip() for t in args.remove_tags.split(",") if t.strip()] if args.remove_tags else None

if not metadata_dict and not remove_meta_list and not add_tags_list and not remove_tags_list:
output_error(
args,
"Nothing to update",
hint="Specify at least one of --metadata, --remove-metadata, --tags, --remove-tags",
)
return

workspace_url = args.workspace or _default_workspace()
if not workspace_url:
output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'")
resolved = resolve_ws_and_key(args)
if not resolved:
return
workspace_url, api_key = resolved

if len(ids) == 1:
try:
rfapi.update_image_metadata(
api_key=api_key,
workspace_url=workspace_url,
image_id=ids[0],
metadata=metadata_dict,
remove_metadata=remove_meta_list,
add_tags=add_tags_list,
remove_tags=remove_tags_list,
)
except rfapi.RoboflowError as exc:
output_error(args, str(exc), exit_code=1)
return
data = {"success": True, "imageId": ids[0]}
output(args, data, text=f"Updated image {ids[0]}")
else:
_handle_metadata_batch(
args, api_key, workspace_url, ids, metadata_dict, remove_meta_list, add_tags_list, remove_tags_list
)


def _handle_metadata_batch(args, api_key, workspace_url, image_ids, metadata, remove_metadata, add_tags, remove_tags): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error

BATCH_LIMIT = 1000
if len(image_ids) > BATCH_LIMIT:
output_error(
args,
f"Too many images: {len(image_ids)} (limit: {BATCH_LIMIT})",
hint=f"Split into batches of {BATCH_LIMIT} or fewer",
)
return

updates = []
for img_id in image_ids:
entry: dict = {"imageId": img_id}
if metadata:
entry["metadata"] = metadata
if remove_metadata:
entry["removeMetadata"] = remove_metadata
if add_tags:
entry["addTags"] = add_tags
if remove_tags:
entry["removeTags"] = remove_tags
updates.append(entry)

try:
result = rfapi.batch_update_image_metadata(
api_key=api_key,
workspace_url=workspace_url,
updates=updates,
)
except rfapi.RoboflowError as exc:
output_error(args, str(exc), exit_code=1)
return

task_id = result.get("taskId")
polling_url = result.get("url")

if not args.poll:
data = {"taskId": task_id, "url": polling_url, "imageCount": len(image_ids)}
output(args, data, text=f"Batch update started: taskId={task_id} ({len(image_ids)} images)")
return

from roboflow.core.async_tasks import poll_until_terminal

try:
final = poll_until_terminal(
api_key,
workspace_url,
task_id,
timeout=args.timeout,
polling_url=polling_url,
)
except rfapi.RoboflowError as exc:
output_error(args, str(exc), exit_code=1)
return
except TimeoutError as exc:
output_error(args, str(exc))
return

base = f"{API_URL}/{workspace_url}/{args.project}/images/{args.image_id}/tags"
added = []
removed = []

if args.add_tags:
for tag in args.add_tags.split(","):
tag = tag.strip()
if not tag:
continue
resp = requests.post(base, params={"api_key": api_key}, json={"tag": tag})
if resp.status_code == 200:
added.append(tag)

if args.remove_tags:
for tag in args.remove_tags.split(","):
tag = tag.strip()
if not tag:
continue
resp = requests.delete(f"{base}/{tag}", params={"api_key": api_key})
if resp.status_code == 200:
removed.append(tag)

data = {"added": added, "removed": removed}
parts = []
if added:
parts.append(f"Added tags: {', '.join(added)}")
if removed:
parts.append(f"Removed tags: {', '.join(removed)}")
text = "; ".join(parts) if parts else "No tags modified"
output(args, data, text=text)
result_data = final.get("result", {})
data = {"taskId": task_id, "status": final.get("status"), **result_data}
succeeded = result_data.get("succeeded", 0)
failed = result_data.get("failed", 0)
output(args, data, text=f"Batch update complete: {succeeded} succeeded, {failed} failed (taskId={task_id})")


def _handle_delete(args): # noqa: ANN001
Expand Down
Loading
Loading