Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions roboflow/cli/handlers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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:<slug>` 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
Expand Down Expand Up @@ -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:<slug>` 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,
)
Expand Down
3 changes: 2 additions & 1 deletion roboflow/cli/handlers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions tests/cli/test_image_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` 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."""
Expand Down
Loading