diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 382a3f18..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: - # Project-scoped search (legacy behavior) - 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 @@ -422,10 +425,17 @@ def _handle_search(args): # noqa: ANN001 output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") return + # 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: + 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/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 3c386a04..c48bbe31 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -462,9 +462,73 @@ def test_search(self, mock_workspace_search): sys.stdout = old mock_workspace_search.assert_called_once() + # -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()) 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") + + @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.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): + 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."""