From 6aaf9fa53a05d5c2f868d4afe4854a0dfc41c6be Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 25 May 2026 14:12:30 -0300 Subject: [PATCH 1/4] fix(cli): scope 'image search -p' to the project via RoboQL filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -p/--project flag was silently ignored: _handle_search passed the project nowhere, so every search returned workspace-wide results. The search/v1 endpoint only honors a project filter expressed inside the RoboQL query (project:, which the API resolves to a project id) — body-level project/dataset params are ignored. Prepend project: to the user's query with a leading space (implicit AND) so it stays compatible with free-text/semantic queries; an explicit 'AND (...)' wrapper 500s on free text. --- roboflow/cli/handlers/image.py | 15 +++++++++++++-- tests/cli/test_image_handler.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 382a3f18..ed7bad71 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -104,7 +104,7 @@ def search_images( Use --export to download matching results as a dataset. """ if project: - # Project-scoped search (legacy behavior) + # Project-scoped search: _handle_search injects a `project:` RoboQL filter. args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) _handle_search(args) elif export: @@ -422,10 +422,21 @@ def _handle_search(args): # noqa: ANN001 output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") return + # Scope the search to a single project when -p/--project is given. The search/v1 + # endpoint only honors a project filter expressed inside the RoboQL query + # (`project:`, which the API resolves to a project id) — body params like + # `project`/`dataset` are ignored. We prepend it with a leading space so it ANDs + # with the user's query while staying compatible with free-text/semantic queries + # (an explicit `AND (...)` wrapper 500s on free text). + query = args.query + project = getattr(args, "project", None) + if project: + query = f"project:{project} {args.query}" + result = rfapi.workspace_search( api_key=api_key, workspace_url=workspace_url, - query=args.query, + query=query, page_size=args.limit, continuation_token=args.cursor, ) diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index 3c386a04..650a3a61 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -462,9 +462,31 @@ def test_search(self, mock_workspace_search): sys.stdout = old mock_workspace_search.assert_called_once() + # -p/--project must scope the search via a `project:` RoboQL filter, + # combined with the user's query (the API ignores body-level project params). + called_query = mock_workspace_search.call_args.kwargs["query"] + self.assertEqual(called_query, "project:proj tag:test") result = json.loads(buf.getvalue()) self.assertEqual(result["total"], 0) + @patch("roboflow.adapters.rfapi.workspace_search") + def test_search_without_project_is_unscoped(self, mock_workspace_search): + from roboflow.cli.handlers.image import _handle_search + + mock_workspace_search.return_value = {"results": [], "total": 0} + args = _make_args(json=True, query="tag:test", project=None, limit=10, cursor=None) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_search(args) + finally: + sys.stdout = old + + called_query = mock_workspace_search.call_args.kwargs["query"] + self.assertEqual(called_query, "tag:test") + class TestImageAnnotate(unittest.TestCase): """Test the annotate handler.""" From 77dd48e62ea941d70d401f1fe34dc3a47e2780a9 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 25 May 2026 14:16:27 -0300 Subject: [PATCH 2/4] docs(cli): shorten inline comments on search-scoping change --- roboflow/cli/handlers/image.py | 10 +++------- tests/cli/test_image_handler.py | 3 +-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index ed7bad71..ae421860 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -104,7 +104,7 @@ def search_images( Use --export to download matching results as a dataset. """ if project: - # Project-scoped search: _handle_search injects a `project:` RoboQL filter. + # _handle_search scopes by injecting a `project:` RoboQL filter. args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) _handle_search(args) elif export: @@ -422,12 +422,8 @@ def _handle_search(args): # noqa: ANN001 output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") return - # Scope the search to a single project when -p/--project is given. The search/v1 - # endpoint only honors a project filter expressed inside the RoboQL query - # (`project:`, which the API resolves to a project id) — body params like - # `project`/`dataset` are ignored. We prepend it with a leading space so it ANDs - # with the user's query while staying compatible with free-text/semantic queries - # (an explicit `AND (...)` wrapper 500s on free text). + # search/v1 only scopes via a `project:` RoboQL filter (body params are + # ignored). Leading space = implicit AND; `AND (...)` 500s on free-text queries. query = args.query project = getattr(args, "project", None) if project: diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index 650a3a61..9497630a 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -462,8 +462,7 @@ def test_search(self, mock_workspace_search): sys.stdout = old mock_workspace_search.assert_called_once() - # -p/--project must scope the search via a `project:` RoboQL filter, - # combined with the user's query (the API ignores body-level project params). + # -p must scope via a `project:` filter prepended to the query. called_query = mock_workspace_search.call_args.kwargs["query"] self.assertEqual(called_query, "project:proj tag:test") result = json.loads(buf.getvalue()) From d2a62dcdbd33b63c84d21e207074c2f1cd34f54d Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 1 Jun 2026 10:31:08 -0300 Subject: [PATCH 3/4] fix(cli): scope 'image search -p --export' to the project `image search -p --export` silently dropped the export: the `if project` branch ran before `elif export`, so it routed to a plain project-scoped search and ignored --export and all export flags. Check export first and pass -p through as the export `dataset` (project slug) body param, which is how search/export natively scopes. Plain `-p` search is unchanged (still injects a `project:` RoboQL filter). Also clarify the -p help text ("Project slug to scope results") since the search/v1 endpoint scopes by slug, not by an ignored body param. Add regression tests for both routes. --- roboflow/cli/handlers/image.py | 19 +++++++++++-------- tests/cli/test_image_handler.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index ae421860..9a250f4e 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -81,7 +81,8 @@ def search_images( ctx: typer.Context, query: Annotated[str, typer.Argument(help="RoboQL search query (e.g. 'tag:review' or '*')")], project: Annotated[ - Optional[str], typer.Option("-p", "--project", help="Project ID (omit to search entire workspace)") + Optional[str], + typer.Option("-p", "--project", help="Project slug to scope results (omit to search entire workspace)"), ] = None, limit: Annotated[int, typer.Option(help="Number of results")] = 50, cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, @@ -103,12 +104,10 @@ def search_images( With -p/--project, searches within a specific project. Use --export to download matching results as a dataset. """ - if project: - # _handle_search scopes by injecting a `project:` RoboQL filter. - args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) - _handle_search(args) - elif export: - # Workspace-level export + if export: + # Export scopes to a project via the `dataset` (project slug) body param, + # so route -p through as the dataset. Check export before project so + # `-p ... --export` exports the project instead of silently ignoring --export. from roboflow.cli.handlers.search import _search args = ctx_to_args( @@ -119,12 +118,16 @@ def search_images( export=True, format=format, location=location, - dataset=dataset, + dataset=dataset or project, annotation_group=annotation_group, name=name, no_extract=no_extract, ) _search(args) + elif project: + # _handle_search scopes by injecting a `project:` RoboQL filter. + args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) + _handle_search(args) else: # Workspace-level search from roboflow.cli.handlers.search import _search diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index 9497630a..ad9f4950 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -486,6 +486,35 @@ def test_search_without_project_is_unscoped(self, mock_workspace_search): called_query = mock_workspace_search.call_args.kwargs["query"] self.assertEqual(called_query, "tag:test") + @patch("roboflow.cli.handlers.search._search") + @patch("roboflow.cli.handlers.image._handle_search") + def test_search_with_project_and_export_scopes_the_export(self, mock_handle_search, mock_search): + # `-p ... --export` must export the project, not silently drop --export. + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "k", "image", "search", "tag:test", "-p", "proj", "--export"], + ) + + self.assertEqual(result.exit_code, 0) + mock_handle_search.assert_not_called() + mock_search.assert_called_once() + export_args = mock_search.call_args.args[0] + self.assertTrue(export_args.export) + # Export scopes by the `dataset` (project slug) body param. + self.assertEqual(export_args.dataset, "proj") + + @patch("roboflow.cli.handlers.search._search") + @patch("roboflow.cli.handlers.image._handle_search") + def test_search_with_project_no_export_uses_roboql_filter(self, mock_handle_search, mock_search): + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "k", "image", "search", "tag:test", "-p", "proj"], + ) + + self.assertEqual(result.exit_code, 0) + mock_search.assert_not_called() + mock_handle_search.assert_called_once() + class TestImageAnnotate(unittest.TestCase): """Test the annotate handler.""" From a11332bc62f6a820bfb75e1c3ba937a615ec83b9 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbosa Date: Mon, 1 Jun 2026 11:38:02 -0300 Subject: [PATCH 4/4] fix(cli): forward --api-key to the SDK on the search/export path _search built the SDK client with roboflow.Roboflow() and never passed the CLI's --api-key, so the export path (and the hidden top-level search command) ignored an explicitly supplied key and required saved/env creds. This broke the now-functional `image search -p ... --export` in CI and agent workflows that pass the key directly. Pass api_key=args.api_key; Roboflow() still falls back to saved/env creds when it is None, so the default-login flow is unchanged. Add a regression test asserting Roboflow receives the root --api-key. --- roboflow/cli/handlers/search.py | 3 ++- tests/cli/test_image_handler.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/roboflow/cli/handlers/search.py b/roboflow/cli/handlers/search.py index 7e0aee85..fb2e3d48 100644 --- a/roboflow/cli/handlers/search.py +++ b/roboflow/cli/handlers/search.py @@ -56,7 +56,8 @@ def _search(args): # noqa: ANN001 try: with suppress_sdk_output(): - rf = roboflow.Roboflow() + # Forward the CLI --api-key; Roboflow() falls back to saved/env creds when None. + rf = roboflow.Roboflow(api_key=args.api_key) workspace = rf.workspace(args.workspace) except Exception as exc: output_error(args, str(exc), exit_code=2) diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index ad9f4950..c48bbe31 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -503,6 +503,20 @@ def test_search_with_project_and_export_scopes_the_export(self, mock_handle_sear # Export scopes by the `dataset` (project slug) body param. self.assertEqual(export_args.dataset, "proj") + @patch("roboflow.Roboflow") + def test_search_export_forwards_cli_api_key_to_sdk(self, mock_roboflow): + # The export path must honor an explicitly supplied --api-key, not only + # saved/env credentials (CI/agent workflows pass the key directly). + mock_roboflow.return_value = MagicMock() + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "MY_KEY", "image", "search", "tag:test", "-p", "proj", "--export"], + ) + + self.assertEqual(result.exit_code, 0) + mock_roboflow.assert_called_once() + self.assertEqual(mock_roboflow.call_args.kwargs.get("api_key"), "MY_KEY") + @patch("roboflow.cli.handlers.search._search") @patch("roboflow.cli.handlers.image._handle_search") def test_search_with_project_no_export_uses_roboql_filter(self, mock_handle_search, mock_search):