diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 5562aa22..eef2e2a7 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -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 @@ -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, diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 18c2dd72..382a3f18 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -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") @@ -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 metadata_dict is None and remove_meta_list is None and add_tags_list is None and remove_tags_list is None: + 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 # matches the workspace images/metadata endpoint limit + 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), exit_code=1) 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 diff --git a/tests/adapters/test_rfapi_phase2.py b/tests/adapters/test_rfapi_phase2.py index dee26e9f..dc03e800 100644 --- a/tests/adapters/test_rfapi_phase2.py +++ b/tests/adapters/test_rfapi_phase2.py @@ -832,5 +832,80 @@ def test_error(self, mock_get): search_universe("query") +class TestUpdateImageMetadata(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + result = update_image_metadata("key", "ws", "img-1", add_tags=["tag1"], remove_tags=["old"]) + self.assertEqual(result, {"success": True}) + mock_post.assert_called_once() + self.assertIn("/ws/images/img-1/metadata", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["addTags"], ["tag1"]) + self.assertEqual(payload["removeTags"], ["old"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_only_sends_provided_fields(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img-1", add_tags=["foo"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"addTags": ["foo"]}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_metadata_and_tags(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img-1", metadata={"cam": "1"}, add_tags=["review"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["metadata"], {"cam": "1"}) + self.assertEqual(payload["addTags"], ["review"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error_404(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, update_image_metadata + + mock_post.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + update_image_metadata("key", "ws", "img-1", add_tags=["x"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_image_id_url_encoded(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img/1", add_tags=["a"]) + called_url = mock_post.call_args[0][0] + self.assertIn("/ws/images/img%2F1/metadata", called_url) + self.assertNotIn("/ws/images/img/1/metadata", called_url) + + +class TestBatchUpdateImageMetadata(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import batch_update_image_metadata + + updates = [{"imageId": "img-1", "addTags": ["t1"]}, {"imageId": "img-2", "metadata": {"k": "v"}}] + mock_post.return_value = MagicMock(status_code=202, json=lambda: {"taskId": "t1", "url": "poll-url"}) + result = batch_update_image_metadata("key", "ws", updates) + self.assertEqual(result, {"taskId": "t1", "url": "poll-url"}) + mock_post.assert_called_once() + self.assertIn("/ws/images/metadata", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"updates": updates}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error_400(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, batch_update_image_metadata + + mock_post.return_value = MagicMock(status_code=400, text="Bad request") + with self.assertRaises(RoboflowError): + batch_update_image_metadata("key", "ws", [{"imageId": "img-1"}]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index c1fb97c7..3c386a04 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -528,29 +528,155 @@ def test_nonexistent_path(self): _handle_upload(args) -class TestImageTagValidation(unittest.TestCase): - """Test that tag command validates --add/--remove presence.""" +class TestImageMetadataRegistration(unittest.TestCase): + """Verify the metadata command and tag alias register correctly.""" - def test_tag_no_add_or_remove(self): - from roboflow.cli.handlers.image import _handle_tag + def test_image_metadata_help(self): + result = runner.invoke(app, ["image", "metadata", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("tags", result.output.lower()) + self.assertIn("metadata", result.output.lower()) + + def test_tag_is_alias(self): + result = runner.invoke(app, ["image", "tag", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("tags", result.output.lower()) + self.assertNotIn("project", result.output.lower()) + + +class TestImageMetadataSingle(unittest.TestCase): + """Test the single-image metadata path.""" + + @patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")) + @patch("roboflow.adapters.rfapi.update_image_metadata", return_value={"success": True}) + def test_metadata_single(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_metadata args = _make_args( + image_ids="img-1", + metadata='{"camera": "cam1"}', + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + ) + _handle_metadata(args) + mock_update.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", image_id="img-1", - project="proj", + metadata={"camera": "cam1"}, + remove_metadata=None, + add_tags=["review"], + remove_tags=None, + ) + + def test_metadata_invalid_json(self): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1", + metadata="not-json", + remove_metadata=None, add_tags=None, remove_tags=None, + poll=False, + timeout=1800, ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _handle_metadata(args) + finally: + sys.stderr = old + self.assertIn("Invalid metadata JSON", buf.getvalue()) + + def test_metadata_nothing_to_do(self): + from roboflow.cli.handlers.image import _handle_metadata + args = _make_args( + image_ids="img-1", + metadata=None, + remove_metadata=None, + add_tags=None, + remove_tags=None, + poll=False, + timeout=1800, + ) buf = io.StringIO() old = sys.stderr sys.stderr = buf try: with self.assertRaises(SystemExit): - _handle_tag(args) + _handle_metadata(args) finally: sys.stderr = old + self.assertIn("Nothing to update", buf.getvalue()) + + +class TestImageMetadataBatch(unittest.TestCase): + """Test the batch (multi-image) metadata path.""" - self.assertIn("Nothing to do", buf.getvalue()) + @patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")) + @patch( + "roboflow.adapters.rfapi.batch_update_image_metadata", + return_value={"taskId": "t1", "url": "https://api.roboflow.com/test-ws/asynctasks/t1"}, + ) + def test_metadata_batch_no_poll(self, mock_batch, mock_resolve): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1,img-2,img-3", + metadata=None, + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + json=True, + ) + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_metadata(args) + finally: + sys.stdout = old + data = json.loads(buf.getvalue()) + self.assertEqual(data["taskId"], "t1") + self.assertEqual(data["imageCount"], 3) + mock_batch.assert_called_once() + updates = mock_batch.call_args[1]["updates"] + self.assertEqual(len(updates), 3) + self.assertEqual(updates[0]["imageId"], "img-1") + self.assertEqual(updates[0]["addTags"], ["review"]) + + def test_metadata_batch_over_limit(self): + from roboflow.cli.handlers.image import _handle_metadata + + ids = ",".join([f"img-{i}" for i in range(1001)]) + args = _make_args( + image_ids=ids, + metadata=None, + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + ) + with patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")): + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _handle_metadata(args) + finally: + sys.stderr = old + self.assertIn("Too many images", buf.getvalue()) if __name__ == "__main__":