From 9cd39d002ef6e7f1fbe4c9cb117ac44744cc540a Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Wed, 20 May 2026 16:10:26 -0300 Subject: [PATCH 1/4] feat(cli): add `image update` command and refactor `image tag` to use new metadata endpoint Adds support for the new workspace-level image metadata/tags API: - `roboflow image update` for updating metadata and tags on existing images (single sync + batch async) - Refactors `roboflow image tag` to use the new endpoint, removing the now-unnecessary `--project` flag Co-Authored-By: Claude Opus 4.6 --- roboflow/adapters/rfapi.py | 72 ++++++++- roboflow/cli/handlers/image.py | 227 +++++++++++++++++++++++----- tests/adapters/test_rfapi_phase2.py | 75 +++++++++ tests/cli/test_image_handler.py | 185 +++++++++++++++++++++++ 4 files changed, 520 insertions(+), 39 deletions(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 8d887abb..4456d9cd 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 @@ -369,6 +369,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 a4e60c9c..3f482ed2 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -143,15 +143,47 @@ def search_images( 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, ) -> 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) + args = ctx_to_args(ctx, image_id=image_id, add_tags=add_tags, remove_tags=remove_tags) _handle_tag(args) +@image_app.command("update") +def update_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, + add_tags: Annotated[Optional[str], typer.Option("--add-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 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=add_tags, + remove_tags=remove_tags, + poll=poll, + timeout=timeout, + ) + _handle_update(args) + + @image_app.command("delete") def delete_images( ctx: typer.Context, @@ -373,57 +405,176 @@ def _handle_search(args): # noqa: ANN001 def _handle_tag(args): # noqa: ANN001 - import requests - + 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") 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) + resolved = resolve_ws_and_key(args) + if not resolved: return + workspace_url, api_key = resolved - 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'") + add_list = [t.strip() for t in args.add_tags.split(",") if t.strip()] if args.add_tags else None + remove_list = [t.strip() for t in args.remove_tags.split(",") if t.strip()] if args.remove_tags else None + + try: + rfapi.update_image_metadata( + api_key=api_key, + workspace_url=workspace_url, + image_id=args.image_id, + add_tags=add_list, + remove_tags=remove_list, + ) + except rfapi.RoboflowError 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} + data = {"added": add_list or [], "removed": remove_list or []} parts = [] - if added: - parts.append(f"Added tags: {', '.join(added)}") - if removed: - parts.append(f"Removed tags: {', '.join(removed)}") + if add_list: + parts.append(f"Added tags: {', '.join(add_list)}") + if remove_list: + parts.append(f"Removed tags: {', '.join(remove_list)}") text = "; ".join(parts) if parts else "No tags modified" output(args, data, text=text) +def _handle_update(args): # noqa: ANN001 + import json as json_mod + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_ws_and_key + + ids = [i.strip() for i in args.image_ids.split(",") if i.strip()] + if not ids: + output_error(args, "No image IDs provided") + return + + 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, --add-tags, --remove-tags", + ) + return + + 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_update_batch( + args, api_key, workspace_url, ids, metadata_dict, remove_meta_list, add_tags_list, remove_tags_list + ) + + +def _handle_update_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 + + 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 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error 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 982c4255..7b9d0b1a 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -485,5 +485,190 @@ def test_tag_no_add_or_remove(self): self.assertIn("Nothing to do", buf.getvalue()) +class TestImageUpdateRegistration(unittest.TestCase): + """Verify the update and refactored tag commands register correctly.""" + + def test_image_update_help(self): + result = runner.invoke(app, ["image", "update", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("metadata", result.output.lower()) + + def test_tag_help_no_project(self): + result = runner.invoke(app, ["image", "tag", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertNotIn("--project", result.output) + + +class TestImageTagRefactored(unittest.TestCase): + """Test the refactored tag handler that uses rfapi directly.""" + + @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_tag_calls_rfapi(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_tag + + args = _make_args(image_id="img-1", add_tags="foo,bar", remove_tags="baz") + _handle_tag(args) + mock_update.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + image_id="img-1", + add_tags=["foo", "bar"], + remove_tags=["baz"], + ) + + @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_tag_json_output(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_tag + + args = _make_args(image_id="img-1", add_tags="foo", remove_tags=None, json=True) + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_tag(args) + finally: + sys.stdout = old + data = json.loads(buf.getvalue()) + self.assertEqual(data["added"], ["foo"]) + self.assertEqual(data["removed"], []) + + +class TestImageUpdateSingle(unittest.TestCase): + """Test the single-image update 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_update_single_metadata(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_update + + args = _make_args( + image_ids="img-1", + metadata='{"camera": "cam1"}', + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + ) + _handle_update(args) + mock_update.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + image_id="img-1", + metadata={"camera": "cam1"}, + remove_metadata=None, + add_tags=["review"], + remove_tags=None, + ) + + def test_update_invalid_metadata_json(self): + from roboflow.cli.handlers.image import _handle_update + + 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_update(args) + finally: + sys.stderr = old + self.assertIn("Invalid metadata JSON", buf.getvalue()) + + def test_update_nothing_to_do(self): + from roboflow.cli.handlers.image import _handle_update + + 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_update(args) + finally: + sys.stderr = old + self.assertIn("Nothing to update", buf.getvalue()) + + +class TestImageUpdateBatch(unittest.TestCase): + """Test the batch (multi-image) update path.""" + + @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_update_batch_no_poll(self, mock_batch, mock_resolve): + from roboflow.cli.handlers.image import _handle_update + + 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_update(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_update_batch_over_limit(self): + from roboflow.cli.handlers.image import _handle_update + + 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_update(args) + finally: + sys.stderr = old + self.assertIn("Too many images", buf.getvalue()) + + if __name__ == "__main__": unittest.main() From 26d088514a076b868e1007f7fabd3044c61eee78 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Wed, 20 May 2026 16:20:51 -0300 Subject: [PATCH 2/4] =?UTF-8?q?refactor(cli):=20rename=20`image=20update`?= =?UTF-8?q?=20=E2=86=92=20`image=20metadata`,=20add=20`tag`=20as=20hidden?= =?UTF-8?q?=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `roboflow image metadata` and `roboflow image tag` now point to the same handler with identical flags (--tags, --remove-tags, --metadata, --remove-metadata, --poll, --timeout). Zero code duplication. Co-Authored-By: Claude Opus 4.6 --- roboflow/cli/handlers/image.py | 120 ++++++++++++++------------------ tests/cli/test_image_handler.py | 113 +++++++----------------------- 2 files changed, 78 insertions(+), 155 deletions(-) diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 3f482ed2..154896fa 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -139,20 +139,36 @@ def search_images( _search(args) -@image_app.command("tag") -def tag_image( +def _metadata_command( ctx: typer.Context, - image_id: Annotated[str, typer.Argument(help="Image 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: 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: - """Add or remove tags on an image.""" - args = ctx_to_args(ctx, image_id=image_id, add_tags=add_tags, remove_tags=remove_tags) - _handle_tag(args) + """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("update") -def update_image( +@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[ @@ -161,27 +177,32 @@ def update_image( remove_metadata: Annotated[ Optional[str], typer.Option("--remove-metadata", help="Comma-separated metadata keys to remove") ] = None, - add_tags: Annotated[Optional[str], typer.Option("--add-tags", help="Comma-separated tags to add")] = 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 tags on existing images. + """Update metadata and/or tags on existing images.""" + _metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout) - 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=add_tags, - remove_tags=remove_tags, - poll=poll, - timeout=timeout, - ) - _handle_update(args) + +@image_app.command("tag", hidden=True) +def tag_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: + """Alias for 'metadata'.""" + _metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout) @image_app.command("delete") @@ -404,46 +425,7 @@ def _handle_search(args): # noqa: ANN001 output(args, result, text=json.dumps(result, indent=2)) -def _handle_tag(args): # noqa: ANN001 - from roboflow.adapters import rfapi - from roboflow.cli._output import output, output_error - 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") - return - - resolved = resolve_ws_and_key(args) - if not resolved: - return - workspace_url, api_key = resolved - - add_list = [t.strip() for t in args.add_tags.split(",") if t.strip()] if args.add_tags else None - remove_list = [t.strip() for t in args.remove_tags.split(",") if t.strip()] if args.remove_tags else None - - try: - rfapi.update_image_metadata( - api_key=api_key, - workspace_url=workspace_url, - image_id=args.image_id, - add_tags=add_list, - remove_tags=remove_list, - ) - except rfapi.RoboflowError as exc: - output_error(args, str(exc), exit_code=1) - return - - data = {"added": add_list or [], "removed": remove_list or []} - parts = [] - if add_list: - parts.append(f"Added tags: {', '.join(add_list)}") - if remove_list: - parts.append(f"Removed tags: {', '.join(remove_list)}") - text = "; ".join(parts) if parts else "No tags modified" - output(args, data, text=text) - - -def _handle_update(args): # noqa: ANN001 +def _handle_metadata(args): # noqa: ANN001 import json as json_mod from roboflow.adapters import rfapi @@ -476,7 +458,7 @@ def _handle_update(args): # noqa: ANN001 output_error( args, "Nothing to update", - hint="Specify at least one of --metadata, --remove-metadata, --add-tags, --remove-tags", + hint="Specify at least one of --metadata, --remove-metadata, --tags, --remove-tags", ) return @@ -502,12 +484,12 @@ def _handle_update(args): # noqa: ANN001 data = {"success": True, "imageId": ids[0]} output(args, data, text=f"Updated image {ids[0]}") else: - _handle_update_batch( + _handle_metadata_batch( args, api_key, workspace_url, ids, metadata_dict, remove_meta_list, add_tags_list, remove_tags_list ) -def _handle_update_batch(args, api_key, workspace_url, image_ids, metadata, remove_metadata, add_tags, remove_tags): # noqa: ANN001 +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 diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index 7b9d0b1a..f6606704 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -460,88 +460,29 @@ 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 - - args = _make_args( - image_id="img-1", - project="proj", - add_tags=None, - remove_tags=None, - ) - - buf = io.StringIO() - old = sys.stderr - sys.stderr = buf - try: - with self.assertRaises(SystemExit): - _handle_tag(args) - finally: - sys.stderr = old - - self.assertIn("Nothing to do", buf.getvalue()) - - -class TestImageUpdateRegistration(unittest.TestCase): - """Verify the update and refactored tag commands register correctly.""" - - def test_image_update_help(self): - result = runner.invoke(app, ["image", "update", "--help"]) + def test_image_metadata_help(self): + result = runner.invoke(app, ["image", "metadata", "--help"]) self.assertEqual(result.exit_code, 0) - self.assertIn("metadata", result.output.lower()) + self.assertIn("--tags", result.output) + self.assertIn("--metadata", result.output) - def test_tag_help_no_project(self): + def test_tag_is_alias(self): result = runner.invoke(app, ["image", "tag", "--help"]) self.assertEqual(result.exit_code, 0) + self.assertIn("--tags", result.output) self.assertNotIn("--project", result.output) -class TestImageTagRefactored(unittest.TestCase): - """Test the refactored tag handler that uses rfapi directly.""" - - @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_tag_calls_rfapi(self, mock_update, mock_resolve): - from roboflow.cli.handlers.image import _handle_tag - - args = _make_args(image_id="img-1", add_tags="foo,bar", remove_tags="baz") - _handle_tag(args) - mock_update.assert_called_once_with( - api_key="test-key", - workspace_url="test-ws", - image_id="img-1", - add_tags=["foo", "bar"], - remove_tags=["baz"], - ) - - @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_tag_json_output(self, mock_update, mock_resolve): - from roboflow.cli.handlers.image import _handle_tag - - args = _make_args(image_id="img-1", add_tags="foo", remove_tags=None, json=True) - buf = io.StringIO() - old = sys.stdout - sys.stdout = buf - try: - _handle_tag(args) - finally: - sys.stdout = old - data = json.loads(buf.getvalue()) - self.assertEqual(data["added"], ["foo"]) - self.assertEqual(data["removed"], []) - - -class TestImageUpdateSingle(unittest.TestCase): - """Test the single-image update path.""" +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_update_single_metadata(self, mock_update, mock_resolve): - from roboflow.cli.handlers.image import _handle_update + def test_metadata_single(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_metadata args = _make_args( image_ids="img-1", @@ -552,7 +493,7 @@ def test_update_single_metadata(self, mock_update, mock_resolve): poll=False, timeout=1800, ) - _handle_update(args) + _handle_metadata(args) mock_update.assert_called_once_with( api_key="test-key", workspace_url="test-ws", @@ -563,8 +504,8 @@ def test_update_single_metadata(self, mock_update, mock_resolve): remove_tags=None, ) - def test_update_invalid_metadata_json(self): - from roboflow.cli.handlers.image import _handle_update + def test_metadata_invalid_json(self): + from roboflow.cli.handlers.image import _handle_metadata args = _make_args( image_ids="img-1", @@ -580,13 +521,13 @@ def test_update_invalid_metadata_json(self): sys.stderr = buf try: with self.assertRaises(SystemExit): - _handle_update(args) + _handle_metadata(args) finally: sys.stderr = old self.assertIn("Invalid metadata JSON", buf.getvalue()) - def test_update_nothing_to_do(self): - from roboflow.cli.handlers.image import _handle_update + def test_metadata_nothing_to_do(self): + from roboflow.cli.handlers.image import _handle_metadata args = _make_args( image_ids="img-1", @@ -602,22 +543,22 @@ def test_update_nothing_to_do(self): sys.stderr = buf try: with self.assertRaises(SystemExit): - _handle_update(args) + _handle_metadata(args) finally: sys.stderr = old self.assertIn("Nothing to update", buf.getvalue()) -class TestImageUpdateBatch(unittest.TestCase): - """Test the batch (multi-image) update path.""" +class TestImageMetadataBatch(unittest.TestCase): + """Test the batch (multi-image) metadata path.""" @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_update_batch_no_poll(self, mock_batch, mock_resolve): - from roboflow.cli.handlers.image import _handle_update + 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", @@ -633,7 +574,7 @@ def test_update_batch_no_poll(self, mock_batch, mock_resolve): old = sys.stdout sys.stdout = buf try: - _handle_update(args) + _handle_metadata(args) finally: sys.stdout = old data = json.loads(buf.getvalue()) @@ -645,8 +586,8 @@ def test_update_batch_no_poll(self, mock_batch, mock_resolve): self.assertEqual(updates[0]["imageId"], "img-1") self.assertEqual(updates[0]["addTags"], ["review"]) - def test_update_batch_over_limit(self): - from roboflow.cli.handlers.image import _handle_update + 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( @@ -664,7 +605,7 @@ def test_update_batch_over_limit(self): sys.stderr = buf try: with self.assertRaises(SystemExit): - _handle_update(args) + _handle_metadata(args) finally: sys.stderr = old self.assertIn("Too many images", buf.getvalue()) From 30e92d048c37dc38f1df37625858f6d9e11c150f Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Thu, 21 May 2026 09:52:48 -0300 Subject: [PATCH 3/4] fix(test): use case-insensitive checks to avoid ANSI escape code mismatches in CI --- tests/cli/test_image_handler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index f6606704..d5b9c3c1 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -466,14 +466,14 @@ class TestImageMetadataRegistration(unittest.TestCase): def test_image_metadata_help(self): result = runner.invoke(app, ["image", "metadata", "--help"]) self.assertEqual(result.exit_code, 0) - self.assertIn("--tags", result.output) - self.assertIn("--metadata", result.output) + 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) - self.assertNotIn("--project", result.output) + self.assertIn("tags", result.output.lower()) + self.assertNotIn("project", result.output.lower()) class TestImageMetadataSingle(unittest.TestCase): From 6406c52b1eb7d733fd48335ec9b2e39e002c4528 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Fri, 22 May 2026 10:34:16 -0300 Subject: [PATCH 4/4] fix --- roboflow/cli/handlers/image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 2be07ccf..382a3f18 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -461,7 +461,7 @@ def _handle_metadata(args): # noqa: ANN001 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: + 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", @@ -500,7 +500,7 @@ def _handle_metadata_batch(args, api_key, workspace_url, image_ids, metadata, re from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error - BATCH_LIMIT = 1000 + BATCH_LIMIT = 1000 # matches the workspace images/metadata endpoint limit if len(image_ids) > BATCH_LIMIT: output_error( args, @@ -554,7 +554,7 @@ def _handle_metadata_batch(args, api_key, workspace_url, image_ids, metadata, re output_error(args, str(exc), exit_code=1) return except TimeoutError as exc: - output_error(args, str(exc)) + output_error(args, str(exc), exit_code=1) return result_data = final.get("result", {})