From c9227db8db7680233495b46daa7e106667a31300 Mon Sep 17 00:00:00 2001 From: Tony Lampada Date: Mon, 31 Aug 2026 17:55:37 -0300 Subject: [PATCH 1/3] feat: annotation_overwrite on zip dataset uploads (SDK + CLI) Co-Authored-By: Claude Fable 5 --- roboflow/adapters/rfapi.py | 6 ++- roboflow/cli/handlers/image.py | 10 +++++ roboflow/core/workspace.py | 6 +++ tests/cli/test_image_handler.py | 55 ++++++++++++++++++++++++++ tests/test_project.py | 68 +++++++++++++++++++++++++++++++++ tests/test_rfapi.py | 22 +++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 43cfeb6a..16d29a66 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -897,10 +897,12 @@ def _save_annotation_error(response): # --------------------------------------------------------------------------- -def init_zip_upload(api_key, workspace_url, project_url, split=None, tags=None, batch_name=None) -> dict: +def init_zip_upload( + api_key, workspace_url, project_url, split=None, tags=None, batch_name=None, annotation_overwrite=False +) -> dict: """POST /{ws}/{proj}/upload/zip — initialize a zip upload and get a signed URL.""" url = f"{API_URL}/{workspace_url}/{project_url}/upload/zip" - body: Dict[str, Union[str, List[str]]] = {} + body: Dict[str, Union[str, List[str], bool]] = {"annotationOverwrite": annotation_overwrite} if split is not None: body["split"] = split if tags is not None: diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 9a250f4e..a54b2927 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -40,6 +40,14 @@ def upload_image( bool, typer.Option("--zip-upload", help="Zip the directory client-side and use the async zip upload flow"), ] = False, + annotation_overwrite: Annotated[ + Optional[bool], + typer.Option( + "--annotation-overwrite/--no-annotation-overwrite", + help="Zip flow: overwrite existing annotations on duplicate images " + "(default: off, except classification projects where the API requires it on)", + ), + ] = None, no_wait: Annotated[ bool, typer.Option("--no-wait", help="Zip flow: return immediately with task_id instead of polling"), @@ -60,6 +68,7 @@ def upload_image( labelmap=labelmap, is_prediction=is_prediction, zip_upload=zip_upload, + annotation_overwrite=annotation_overwrite, no_wait=no_wait, ) _handle_upload(args) @@ -348,6 +357,7 @@ def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: AN num_retries=retries, is_prediction=getattr(args, "is_prediction", False), use_zip_upload=getattr(args, "zip_upload", False), + annotation_overwrite=getattr(args, "annotation_overwrite", None), split=getattr(args, "split", None), tags=tags, wait=wait, diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index 97f15555..7a084102 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -514,6 +514,7 @@ def upload_dataset( is_prediction=False, *, use_zip_upload: bool = False, + annotation_overwrite: Optional[bool] = None, tags: Optional[List[str]] = None, split: Optional[str] = None, wait: bool = True, @@ -538,6 +539,7 @@ def upload_dataset( num_retries (int, optional): number of times to retry uploading an image if the upload fails. Defaults to 0. is_prediction (bool, optional): whether the annotations provided in the dataset are predictions and not ground truth. Defaults to False. use_zip_upload (bool, optional): opt-in to the zip flow for a directory input (the SDK zips it client-side). Ignored when dataset_path is already a `.zip`. + annotation_overwrite (bool, optional): zip flow only — overwrite existing annotations on duplicate images. Defaults to False, except classification projects where it defaults to True (the API requires it). tags (list[str], optional): zip flow only — tags to apply to the uploaded batch. split (str, optional): dataset split for the uploaded batch. In per-image directory uploads, this overrides inferred splits for every image. @@ -576,6 +578,9 @@ def upload_dataset( zip_path = temp_zip = _zip_directory(dataset_path) print(f"Zipped {dataset_path} -> {zip_path}") + if annotation_overwrite is None: + annotation_overwrite = project.type == "classification" + init = rfapi.init_zip_upload( self.__api_key, self.url, @@ -583,6 +588,7 @@ def upload_dataset( split=split, tags=tags, batch_name=batch_name, + annotation_overwrite=annotation_overwrite, ) print(f"Uploading zip to Roboflow (task_id={init['taskId']})...") rfapi.upload_zip_to_signed_url(init["signedUrl"], zip_path) diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index c48bbe31..33769444 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -345,6 +345,61 @@ def test_zip_upload_flag_defaults_false(self, mock_rf_cls): _, kwargs = mock_ws.upload_dataset.call_args self.assertEqual(kwargs.get("use_zip_upload"), False) + @patch("roboflow.cli.handlers.image._handle_upload") + def test_annotation_overwrite_flag_three_states(self, mock_handle_upload): + with tempfile.TemporaryDirectory() as tmpdir: + for extra_argv, expected in [ + ([], None), + (["--annotation-overwrite"], True), + (["--no-annotation-overwrite"], False), + ]: + mock_handle_upload.reset_mock() + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "k", "image", "upload", tmpdir, "-p", "proj"] + extra_argv, + ) + self.assertEqual(result.exit_code, 0) + args = mock_handle_upload.call_args.args[0] + self.assertEqual(args.annotation_overwrite, expected) + + @patch("roboflow.Roboflow") + def test_annotation_overwrite_forwarded_to_upload_dataset(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + mock_ws.upload_dataset.return_value = {"status": "completed", "task_id": "t1"} + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split=None, + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + zip_upload=True, + annotation_overwrite=True, + no_wait=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertEqual(kwargs.get("annotation_overwrite"), True) + @patch("roboflow.Roboflow") def test_upload_directory_omits_default_split_when_not_explicit(self, mock_rf_cls): from roboflow.cli.handlers.image import _handle_upload diff --git a/tests/test_project.py b/tests/test_project.py index 747dd09c..c8685d40 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1021,6 +1021,74 @@ def test_directory_with_use_zip_upload_zips_and_cleans_up(self): if _os.path.isdir(src_dir): _os.rmdir(src_dir) + def test_annotation_overwrite_defaults_false(self): + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake zip") + zip_path = fh.name + + mocks = self._rfapi_mocks() + started = {name: m.start() for name, m in mocks.items()} + try: + self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME) + _, kwargs = started["init"].call_args + self.assertEqual(kwargs.get("annotation_overwrite"), False) + finally: + for m in mocks.values(): + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + + def test_annotation_overwrite_defaults_true_for_classification(self): + import tempfile + from types import SimpleNamespace + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake zip") + zip_path = fh.name + + mocks = self._rfapi_mocks() + mocks["project"] = patch( + "roboflow.core.workspace.Workspace._get_or_create_project", + return_value=(SimpleNamespace(id=f"{WORKSPACE_NAME}/{PROJECT_NAME}", type="classification"), False), + ) + started = {name: m.start() for name, m in mocks.items()} + try: + self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME) + _, kwargs = started["init"].call_args + self.assertEqual(kwargs.get("annotation_overwrite"), True) + finally: + for m in mocks.values(): + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + + def test_annotation_overwrite_explicit_passthrough(self): + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake zip") + zip_path = fh.name + + mocks = self._rfapi_mocks() + started = {name: m.start() for name, m in mocks.items()} + try: + self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME, annotation_overwrite=True) + _, kwargs = started["init"].call_args + self.assertEqual(kwargs.get("annotation_overwrite"), True) + finally: + for m in mocks.values(): + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + def test_directory_default_stays_on_per_image(self): import tempfile diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index d0106312..953941f4 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -12,6 +12,7 @@ delete_version_training, get_train_recipe, get_training, + init_zip_upload, list_trainings_for_version, resolve_version_training_id, restore_trash_item, @@ -208,6 +209,27 @@ def _reset_responses(self): responses.reset() +class TestInitZipUpload(unittest.TestCase): + API_KEY = "test_api_key" + WORKSPACE_URL = "test_workspace" + PROJECT_URL = "test_project" + + @responses.activate + def test_annotation_overwrite_in_body(self): + for annotation_overwrite, expected in [(None, False), (False, False), (True, True)]: + responses.reset() + responses.add( + responses.POST, + f"{API_URL}/{self.WORKSPACE_URL}/{self.PROJECT_URL}/upload/zip", + json={"signedUrl": "https://signed.example/upload", "taskId": "task-123"}, + status=200, + ) + kwargs = {} if annotation_overwrite is None else {"annotation_overwrite": annotation_overwrite} + init_zip_upload(self.API_KEY, self.WORKSPACE_URL, self.PROJECT_URL, **kwargs) + body = json.loads(responses.calls[0].request.body) + self.assertEqual(body["annotationOverwrite"], expected) + + class TestV2Trainings(unittest.TestCase): API_KEY = "test_api_key" WORKSPACE = "test-workspace" From 864163e8d22de5d1832304f264b3e3e6f6ca2ddd Mon Sep 17 00:00:00 2001 From: Tony Lampada Date: Mon, 31 Aug 2026 18:00:06 -0300 Subject: [PATCH 2/3] test: drop redundant init_zip_upload body test Co-Authored-By: Claude Fable 5 --- tests/test_rfapi.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index 953941f4..d0106312 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -12,7 +12,6 @@ delete_version_training, get_train_recipe, get_training, - init_zip_upload, list_trainings_for_version, resolve_version_training_id, restore_trash_item, @@ -209,27 +208,6 @@ def _reset_responses(self): responses.reset() -class TestInitZipUpload(unittest.TestCase): - API_KEY = "test_api_key" - WORKSPACE_URL = "test_workspace" - PROJECT_URL = "test_project" - - @responses.activate - def test_annotation_overwrite_in_body(self): - for annotation_overwrite, expected in [(None, False), (False, False), (True, True)]: - responses.reset() - responses.add( - responses.POST, - f"{API_URL}/{self.WORKSPACE_URL}/{self.PROJECT_URL}/upload/zip", - json={"signedUrl": "https://signed.example/upload", "taskId": "task-123"}, - status=200, - ) - kwargs = {} if annotation_overwrite is None else {"annotation_overwrite": annotation_overwrite} - init_zip_upload(self.API_KEY, self.WORKSPACE_URL, self.PROJECT_URL, **kwargs) - body = json.loads(responses.calls[0].request.body) - self.assertEqual(body["annotationOverwrite"], expected) - - class TestV2Trainings(unittest.TestCase): API_KEY = "test_api_key" WORKSPACE = "test-workspace" From 853f047c4eedfac568b59f19404c574e324576e4 Mon Sep 17 00:00:00 2001 From: Tony Lampada Date: Tue, 1 Sep 2026 10:24:33 -0300 Subject: [PATCH 3/3] release: prepare v1.4.2 Co-Authored-By: Claude Opus 5 --- roboflow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roboflow/__init__.py b/roboflow/__init__.py index 9ffd1639..9a0bb9c3 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -21,7 +21,7 @@ CLIPModel = None # type: ignore[assignment,misc] GazeModel = None # type: ignore[assignment,misc] -__version__ = "1.4.1" +__version__ = "1.4.2" def check_key(api_key, model, notebook, num_retries=0):