diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 2a7667af..b7311c14 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -219,6 +219,24 @@ roboflow search "tag:reviewed" --limit 100 roboflow search "class:person" --export -f coco -l ./export/ ``` +Search returns images only unless `--media-types` asks otherwise. Valid values are +`image`, `video`, or both: + +```bash +# Native videos only, with a signed video URL on each hit +roboflow search "*" --media-types video --fields id,filename,url + +# Images and videos together +roboflow search "tag:reviewed" --media-types image,video + +# Scope to one project +roboflow image search "*" -p my-project --media-types video --fields id,url +``` + +Every hit carries `mediaType`. Video hits add a signed `videoUrl` when you request the +`url` field; `url` itself stays the poster frame, so image-only consumers keep a +thumbnail for every hit. `--media-types` is not accepted with `--export`. + ### Browse resources ```bash @@ -551,11 +569,11 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between | `workspace` | List and inspect workspaces | | `project` | List, get, create projects | | `version` | List, get, download, export dataset versions | -| `image` | Upload, get, search, metadata, tag, delete, annotate images | +| `image` | Upload, get, search, metadata, tag, delete, annotate images and videos | | `model` | List, get, upload trained models | | `train` | Start model training | | `infer` | Run inference on images | -| `search` | Search workspace images (RoboQL), export results | +| `search` | Search workspace images and videos (RoboQL), export results | | `deployment` | Manage dedicated deployments | | `device` | List, get, create, and observe RFDM devices (v2 deployment API) | | `eval` | Inspect model evaluation runs (mAP, confusion matrix, recommendations, ...) | diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 95793070..6127d392 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -10,6 +10,7 @@ from requests_toolbelt.multipart.encoder import MultipartEncoder from roboflow.config import API_URL, DEFAULT_BATCH_NAME, DEFAULT_JOB_NAME +from roboflow.util.search_utils import normalize_media_types class RoboflowError(Exception): @@ -545,8 +546,10 @@ def workspace_search( page_size: int = 50, fields: Optional[List[str]] = None, continuation_token: Optional[str] = None, + *, + media_types: Optional[List[str]] = None, ) -> dict: - """Search across all images in a workspace using RoboQL syntax. + """Search across all media in a workspace using RoboQL syntax. Args: api_key: Roboflow API key. @@ -555,11 +558,14 @@ def workspace_search( page_size: Number of results per page (default 50). fields: Fields to include in each result. continuation_token: Token for fetching the next page. + media_types: Media types to search: ``["image"]``, ``["video"]`` or + ``["image", "video"]``. Omit to search images only (the API default). Returns: Parsed JSON response with ``results``, ``total``, and ``continuationToken``. Raises: + ValueError: If ``media_types`` is not a non-empty list of ``"image"``/``"video"``. RoboflowError: On non-200 response status codes. """ url = f"{API_URL}/{workspace_url}/search/v1?api_key={api_key}" @@ -571,6 +577,10 @@ def workspace_search( payload["fields"] = fields if continuation_token is not None: payload["continuationToken"] = continuation_token + # Omitted media_types leaves `mediaTypes` off the body so the API default (images) applies. + normalized_media_types = normalize_media_types(media_types) + if normalized_media_types is not None: + payload["mediaTypes"] = normalized_media_types response = requests.post(url, json=payload) if response.status_code != 200: diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index a54b2927..8d3a2786 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -95,6 +95,14 @@ def search_images( ] = None, limit: Annotated[int, typer.Option(help="Number of results")] = 50, cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, + fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None, + media_types: Annotated[ + Optional[str], + typer.Option( + "--media-types", + help="Comma-separated media types to search: image, video, or image,video (default: image)", + ), + ] = None, export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, @@ -107,11 +115,19 @@ def search_images( name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, ) -> None: - """Search images in workspace or project. + """Search media in workspace or project. Without -p/--project, searches across the entire workspace using RoboQL. With -p/--project, searches within a specific project. Use --export to download matching results as a dataset. + + Searches images only unless --media-types asks otherwise. Request the 'url' field + to get a signed 'videoUrl' on each native video hit. + + Examples: + roboflow image search "tag:review" + roboflow image search "*" --media-types video --fields id,filename,url + roboflow image search "*" -p my-project --media-types image,video """ if export: # Export scopes to a project via the `dataset` (project slug) body param, @@ -131,11 +147,21 @@ def search_images( annotation_group=annotation_group, name=name, no_extract=no_extract, + fields=fields, + media_types=media_types, ) _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) + args = ctx_to_args( + ctx, + query=query, + project=project, + limit=limit, + cursor=cursor, + fields=fields, + media_types=media_types, + ) _handle_search(args) else: # Workspace-level search @@ -153,7 +179,8 @@ def search_images( annotation_group=annotation_group, name=name, no_extract=no_extract, - fields=None, + fields=fields, + media_types=media_types, ) _search(args) @@ -424,6 +451,7 @@ def _handle_search(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.config import load_roboflow_api_key + from roboflow.util.search_utils import parse_media_types_option api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: @@ -442,13 +470,29 @@ def _handle_search(args): # noqa: ANN001 if project: query = f"project:{project} {args.query}" - result = rfapi.workspace_search( - api_key=api_key, - workspace_url=workspace_url, - query=query, - page_size=args.limit, - continuation_token=args.cursor, - ) + try: + media_types = parse_media_types_option(getattr(args, "media_types", None)) + except ValueError as exc: + output_error(args, str(exc), hint="Valid media types: image, video") + return + + fields_raw = getattr(args, "fields", None) + fields = [f.strip() for f in fields_raw.split(",") if f.strip()] if fields_raw else None + + try: + result = rfapi.workspace_search( + api_key=api_key, + workspace_url=workspace_url, + query=query, + page_size=args.limit, + fields=fields, + continuation_token=args.cursor, + media_types=media_types, + ) + except Exception as exc: + output_error(args, str(exc)) + return + output(args, result, text=json.dumps(result, indent=2)) diff --git a/roboflow/cli/handlers/search.py b/roboflow/cli/handlers/search.py index fb2e3d48..3aec5eaf 100644 --- a/roboflow/cli/handlers/search.py +++ b/roboflow/cli/handlers/search.py @@ -1,4 +1,4 @@ -"""Search commands: query workspace images and export search results.""" +"""Search commands: query workspace media and export search results.""" from __future__ import annotations @@ -19,6 +19,13 @@ def search( limit: Annotated[int, typer.Option(help="Max results to return")] = 50, cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None, + media_types: Annotated[ + Optional[str], + typer.Option( + "--media-types", + help="Comma-separated media types to search: image, video, or image,video (default: image)", + ), + ] = None, export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, @@ -32,13 +39,22 @@ def search( name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, ) -> None: - """Search workspace images or export results as a dataset.""" + """Search workspace media or export results as a dataset. + + Searches images only unless --media-types asks otherwise. + + Examples: + roboflow search "tag:review" + roboflow search "*" --media-types video --fields id,filename,url + roboflow search "tag:review" --media-types image,video + """ args = ctx_to_args( ctx, query=query, limit=limit, cursor=cursor, fields=fields, + media_types=media_types, export=export, format=format, location=location, @@ -64,6 +80,15 @@ def _search(args): # noqa: ANN001 return if args.export: + # `is not None` on purpose: an explicit but invalid value (e.g. --media-types "") + # must not silently fall through to the export route. + if getattr(args, "media_types", None) is not None: + output_error( + args, + "--media-types is not supported with --export", + hint="Drop --media-types to export, or omit --export to search with media type selection", + ) + return _do_export(args, workspace) else: _do_search(args, workspace) @@ -71,14 +96,22 @@ def _search(args): # noqa: ANN001 def _do_search(args: Any, workspace: Any) -> None: from roboflow.cli._output import output, output_error + from roboflow.util.search_utils import parse_media_types_option + + fields = [field.strip() for field in args.fields.split(",") if field.strip()] if args.fields else None + try: + media_types = parse_media_types_option(getattr(args, "media_types", None)) + except ValueError as exc: + output_error(args, str(exc), hint="Valid media types: image, video") + return - fields = args.fields.split(",") if args.fields else None try: result = workspace.search( query=args.query, page_size=args.limit, fields=fields, continuation_token=args.cursor, + media_types=media_types, ) except Exception as exc: output_error(args, str(exc)) @@ -89,18 +122,32 @@ def _do_search(args: Any, workspace: Any) -> None: token = result.get("continuationToken") data = {"results": results, "total": total} + if media_types: + data["mediaTypes"] = media_types if token: data["cursor"] = token text_lines = [f"Found {total} result(s)."] for r in results: - text_lines.append(f" {r.get('filename', r.get('id', ''))}") + text_lines.append(f" {_describe_hit(r)}") if token: text_lines.append(f"\nNext page: --cursor {token}") output(args, data, text="\n".join(text_lines)) +def _describe_hit(hit: dict) -> str: + """One text line per hit: label, media type, and the signed video URL when present.""" + label = hit.get("filename") or hit.get("name") or hit.get("id", "") + parts = [str(label)] + media_type = hit.get("mediaType") + if media_type: + parts.append(f"[{media_type}]") + if hit.get("videoUrl"): + parts.append(str(hit["videoUrl"])) + return " ".join(parts) + + def _do_export(args: Any, workspace: Any) -> None: from roboflow.cli._output import output, output_error diff --git a/roboflow/core/project.py b/roboflow/core/project.py index 8cfeb017..f647cd24 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -20,6 +20,7 @@ from roboflow.util.autolabel_utils import resolve_model as _resolve_autolabel_model from roboflow.util.general import Retry from roboflow.util.image_utils import load_labelmap +from roboflow.util.search_utils import normalize_media_types ACCEPTED_IMAGE_FORMATS = { "image/bmp", @@ -697,6 +698,7 @@ def search( *, annotation_job: Optional[bool] = None, annotation_job_id: Optional[str] = None, + media_types: Optional[List[str]] = None, ): """ Search for images in a project. @@ -713,12 +715,16 @@ def search( batch_id (str): batch id that an image must be in annotation_job (bool): whether the image must be in an annotation job annotation_job_id (str): annotation job id that an image must be in + media_types (list): media types to search: ``["image"]``, ``["video"]`` or + ``["image", "video"]``. Omit to search images only (the API default). fields (list): fields to return in results (default: ["id", "created", "name", "labels"]). Available fields: id, name, created, annotations, labels, split, tags, owner, embedding, user_metadata. Returns: - A list of images that match the search criteria. + A list of media that match the search criteria. Every hit carries ``mediaType`` + (``"image"`` or ``"video"``); video hits add a signed ``videoUrl`` when ``url`` + is requested in ``fields``, while ``url`` itself stays the poster frame. Example: >>> import roboflow @@ -735,6 +741,16 @@ def search( ... limit=10, ... fields=["id", "name", "tags", "user_metadata"] ... ) + + >>> # Native videos only, with a signed video URL on each hit + >>> videos = project.search( + ... media_types=["video"], + ... fields=["id", "name", "url"] + ... ) + >>> videos[0]["mediaType"], videos[0]["videoUrl"] + + >>> # Images and videos together + >>> mixed = project.search(media_types=["image", "video"], limit=10) """ # noqa: E501 // docs if fields is None: fields = ["id", "created", "name", "labels"] @@ -774,6 +790,11 @@ def search( if annotation_job_id is not None: payload["annotation_job_id"] = annotation_job_id + # Omitted media_types leaves `mediaTypes` off the body so the API default (images) applies. + normalized_media_types = normalize_media_types(media_types) + if normalized_media_types is not None: + payload["mediaTypes"] = normalized_media_types + payload["fields"] = fields data = requests.post( @@ -798,6 +819,7 @@ def search_all( *, annotation_job: Optional[bool] = None, annotation_job_id: Optional[str] = None, + media_types: Optional[List[str]] = None, ): """ Create a paginated list of search results for use in searching the images in a project. @@ -814,12 +836,16 @@ def search_all( batch_id (str): batch id that an image must be in annotation_job (bool): whether the image must be in an annotation job annotation_job_id (str): annotation job id that an image must be in + media_types (list): media types to search: ``["image"]``, ``["video"]`` or + ``["image", "video"]``. Omit to search images only (the API default). fields (list): fields to return in results (default: ["id", "created"]). Available fields: id, name, created, annotations, labels, split, tags, owner, embedding, user_metadata. Returns: - A generator yielding images that match the search criteria. + A generator yielding pages of media that match the search criteria. Every hit + carries ``mediaType``; video hits add a signed ``videoUrl`` when ``url`` is + requested in ``fields``. Example: >>> import roboflow @@ -832,6 +858,11 @@ def search_all( >>> for result in results: >>> print(result) + + >>> # Page through every native video in the project + >>> for page in project.search_all(media_types=["video"], limit=50): + >>> for video in page: + >>> print(video["id"]) """ # noqa: E501 // docs if fields is None: fields = ["id", "created"] @@ -850,6 +881,7 @@ def search_all( fields=fields, annotation_job=annotation_job, annotation_job_id=annotation_job_id, + media_types=media_types, ) yield data diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index d4e3a634..fca33356 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -977,21 +977,31 @@ def search( page_size: int = 50, fields: Optional[List[str]] = None, continuation_token: Optional[str] = None, + *, + media_types: Optional[List[str]] = None, ) -> dict: - """Search across all images in the workspace using RoboQL syntax. + """Search across all media in the workspace using RoboQL syntax. Args: query: RoboQL search query (e.g. ``"tag:review"``, ``"project:false"`` - for orphan images, or free-text for semantic CLIP search). + for orphan media, or free-text for semantic CLIP search). page_size: Number of results per page (default 50). fields: Fields to include in each result. Defaults to ``["tags", "projects", "filename"]``. continuation_token: Token returned by a previous call for fetching the next page. + media_types: Media types to search: ``["image"]``, ``["video"]`` or + ``["image", "video"]``. Omit to search images only (the API default). Returns: Dict with ``results`` (list), ``total`` (int), and - ``continuationToken`` (str or None). + ``continuationToken`` (str or None). Every result carries ``mediaType``; + video results add a signed ``videoUrl`` when ``url`` is requested in + ``fields``, while ``url`` itself stays the poster frame. + + Raises: + ValueError: If ``media_types`` is not a non-empty list of + ``"image"``/``"video"``. Example: >>> ws = rf.workspace() @@ -999,6 +1009,17 @@ def search( >>> print(page["total"]) >>> for img in page["results"]: ... print(img["filename"]) + + >>> # Native videos only, with a signed video URL on each hit + >>> page = ws.search( + ... "*", + ... media_types=["video"], + ... fields=["id", "filename", "url"] + ... ) + >>> page["results"][0]["mediaType"], page["results"][0]["videoUrl"] + + >>> # Images and videos together + >>> page = ws.search("tag:review", media_types=["image", "video"]) """ if fields is None: fields = ["tags", "projects", "filename"] @@ -1010,6 +1031,7 @@ def search( page_size=page_size, fields=fields, continuation_token=continuation_token, + media_types=media_types, ) def delete_images(self, image_ids: List[str]) -> dict: @@ -1040,8 +1062,10 @@ def search_all( query: str, page_size: int = 50, fields: Optional[List[str]] = None, + *, + media_types: Optional[List[str]] = None, ) -> Generator[List[dict], None, None]: - """Paginated search across all images in the workspace. + """Paginated search across all media in the workspace. Yields one page of results at a time, automatically following ``continuationToken`` until all results have been returned. @@ -1051,15 +1075,28 @@ def search_all( page_size: Number of results per page (default 50). fields: Fields to include in each result. Defaults to ``["tags", "projects", "filename"]``. + media_types: Media types to search: ``["image"]``, ``["video"]`` or + ``["image", "video"]``. Omit to search images only (the API default). Yields: - A list of result dicts for each page. + A list of result dicts for each page. Every result carries ``mediaType``; + video results add a signed ``videoUrl`` when ``url`` is requested in + ``fields``. + + Raises: + ValueError: If ``media_types`` is not a non-empty list of + ``"image"``/``"video"``. Example: >>> ws = rf.workspace() >>> for page in ws.search_all("tag:review"): ... for img in page: ... print(img["filename"]) + + >>> # Page through every native video in the workspace + >>> for page in ws.search_all("*", media_types=["video"]): + ... for video in page: + ... print(video["id"], video["mediaType"]) """ token = None while True: @@ -1068,6 +1105,7 @@ def search_all( page_size=page_size, fields=fields, continuation_token=token, + media_types=media_types, ) results = response.get("results", []) if not results: diff --git a/roboflow/util/search_utils.py b/roboflow/util/search_utils.py new file mode 100644 index 00000000..22db37d6 --- /dev/null +++ b/roboflow/util/search_utils.py @@ -0,0 +1,70 @@ +"""Shared helpers for the project and workspace search surfaces.""" + +from typing import List, Optional + +# The search API accepts exactly these media types. Mixed selection is `["image", "video"]`. +VALID_MEDIA_TYPES = ("image", "video") + +_VALID_LIST = ", ".join(repr(t) for t in VALID_MEDIA_TYPES) + + +def normalize_media_types(media_types: Optional[List[str]]) -> Optional[List[str]]: + """Validate and normalize a ``media_types`` search selection. + + Returns ``None`` for an omitted selection so callers leave ``mediaTypes`` off the + request body entirely and inherit the API default of images only. + + Args: + media_types: List of media types to search, e.g. ``["video"]`` or + ``["image", "video"]``. ``None`` means "use the API default". + + Returns: + A lowercased, de-duplicated list preserving the caller's order, or ``None``. + + Raises: + ValueError: If the selection is not a non-empty list of valid media types. + """ + if media_types is None: + return None + + # A bare string is the most common mistake, so name the fix instead of iterating characters. + if isinstance(media_types, str): + raise ValueError(f"media_types must be a list, not a string - use media_types=[{media_types!r}]") + + if not isinstance(media_types, (list, tuple)) or len(media_types) == 0: + raise ValueError(f"media_types must be a non-empty list containing any of: {_VALID_LIST}") + + normalized: List[str] = [] + for media_type in media_types: + if not isinstance(media_type, str): + raise ValueError(f"media_types entries must be strings, got {type(media_type).__name__!r}") + lowered = media_type.lower() + if lowered not in VALID_MEDIA_TYPES: + raise ValueError(f"invalid media type {media_type!r} - media_types must only contain: {_VALID_LIST}") + if lowered not in normalized: + normalized.append(lowered) + + return normalized + + +def parse_media_types_option(raw: Optional[str]) -> Optional[List[str]]: + """Parse a comma-separated CLI ``--media-types`` value into a normalized list. + + Args: + raw: Raw option value, e.g. ``"video"`` or ``"image,video"``. Only ``None`` + (the option was not given) means "use the API default". + + Returns: + A normalized media type list, or ``None`` when the option was not given. + + Raises: + ValueError: If the value is given but blank, or names anything other than a + valid media type. A blank value is an explicit empty selection, not a + request for the default. + """ + if raw is None: + return None + entries = [entry.strip() for entry in raw.split(",") if entry.strip()] + if not entries: + raise ValueError(f"--media-types must name at least one of: {_VALID_LIST}") + return normalize_media_types(entries) diff --git a/tests/cli/test_search_media_types.py b/tests/cli/test_search_media_types.py new file mode 100644 index 00000000..e9130fe5 --- /dev/null +++ b/tests/cli/test_search_media_types.py @@ -0,0 +1,351 @@ +"""CLI `--media-types` selection on `roboflow search` and `roboflow image search`.""" + +import json +import re +import unittest +from unittest.mock import patch + +import responses +from typer.testing import CliRunner + +from roboflow.cli import app +from roboflow.config import API_URL + +runner = CliRunner() + +API_KEY = "cli_test_key" +WORKSPACE = "cli-test-ws" +PROJECT = "cli-test-project" +SEARCH_URL = f"{API_URL}/{WORKSPACE}/search/v1?api_key={API_KEY}" +WORKSPACE_URL = f"{API_URL}/{WORKSPACE}?api_key={API_KEY}" + + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def _plain(text: str) -> str: + """Rich styles each `-` of an option name separately, so strip ANSI before matching.""" + return _ANSI.sub("", text) + + +def _search_bodies(): + return [json.loads(c.request.body) for c in responses.calls if c.request.url == SEARCH_URL and c.request.body] + + +def _stub_workspace_init(): + """`roboflow search` authenticates and builds a Workspace first; `image search -p` does not.""" + responses.add( + responses.POST, + f"{API_URL}/?api_key={API_KEY}", + json={"welcome": "Welcome to the Roboflow API.", "workspace": WORKSPACE}, + status=200, + ) + responses.add( + responses.GET, + WORKSPACE_URL, + json={"workspace": {"name": "CLI Test", "url": WORKSPACE, "projects": [], "members": []}}, + status=200, + ) + + +def _stub_search(results, total=None, token=None): + body = {"results": results, "total": total if total is not None else len(results), "continuationToken": token} + responses.add(responses.POST, SEARCH_URL, json=body, status=200) + + +class TestSearchHelpAdvertisesMediaTypes(unittest.TestCase): + def test_top_level_search_help(self): + result = runner.invoke(app, ["search", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--media-types", _plain(result.output)) + + def test_image_search_help(self): + result = runner.invoke(app, ["image", "search", "--help"]) + self.assertEqual(result.exit_code, 0) + plain = _plain(result.output) + self.assertIn("--media-types", plain) + self.assertIn("--fields", plain) + + +class TestTopLevelSearchMediaTypes(unittest.TestCase): + """`roboflow search` routes the selection through Workspace.search.""" + + @responses.activate + def test_video_selection_sends_media_types(self): + _stub_workspace_init() + _stub_search([{"id": "vid1", "filename": "clip.mp4", "mediaType": "video", "videoUrl": "https://s/clip.mp4"}]) + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "search", + "*", + "--media-types", + "video", + "--fields", + "id, filename, url", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + body = _search_bodies()[0] + self.assertEqual(body["mediaTypes"], ["video"]) + self.assertEqual(body["fields"], ["id", "filename", "url"]) + + payload = json.loads(result.output) + self.assertEqual(payload["mediaTypes"], ["video"]) + self.assertEqual(payload["results"][0]["videoUrl"], "https://s/clip.mp4") + + @responses.activate + def test_mixed_selection_sends_both(self): + _stub_workspace_init() + _stub_search([]) + + result = runner.invoke( + app, + ["--json", "--api-key", API_KEY, "--workspace", WORKSPACE, "search", "*", "--media-types", "image,video"], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(_search_bodies()[0]["mediaTypes"], ["image", "video"]) + + @responses.activate + def test_omitted_selection_keeps_image_default(self): + _stub_workspace_init() + _stub_search([{"id": "img1", "filename": "a.jpg"}]) + + result = runner.invoke(app, ["--json", "--api-key", API_KEY, "--workspace", WORKSPACE, "search", "tag:review"]) + + self.assertEqual(result.exit_code, 0, result.output) + body = _search_bodies()[0] + self.assertNotIn("mediaTypes", body) + self.assertNotIn("mediaTypes", json.loads(result.output)) + + @responses.activate + def test_text_output_shows_media_type_and_video_url(self): + _stub_workspace_init() + _stub_search([{"id": "vid1", "filename": "clip.mp4", "mediaType": "video", "videoUrl": "https://s/clip.mp4"}]) + + result = runner.invoke( + app, ["--api-key", API_KEY, "--workspace", WORKSPACE, "search", "*", "--media-types", "video"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + plain = _plain(result.output) + self.assertIn("clip.mp4", plain) + self.assertIn("[video]", plain) + self.assertIn("https://s/clip.mp4", plain) + + @responses.activate + def test_invalid_selection_is_a_structured_error_with_no_request(self): + _stub_workspace_init() + + result = runner.invoke( + app, ["--json", "--api-key", API_KEY, "--workspace", WORKSPACE, "search", "*", "--media-types", "audio"] + ) + + self.assertEqual(result.exit_code, 1) + self.assertEqual(_search_bodies(), []) + error = json.loads(result.output)["error"] + self.assertIn("audio", error["message"]) + self.assertIn("image, video", error["hint"]) + + @responses.activate + def test_export_rejects_media_types(self): + _stub_workspace_init() + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "search", + "*", + "--export", + "--media-types", + "video", + ], + ) + + self.assertEqual(result.exit_code, 1) + error = json.loads(result.output)["error"] + self.assertIn("--media-types is not supported with --export", error["message"]) + + @responses.activate + def test_export_rejects_an_explicitly_blank_media_types(self): + """A blank value is an explicit selection; it must not fall through to the export route.""" + _stub_workspace_init() + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "search", + "*", + "--export", + "--media-types", + "", + ], + ) + + self.assertEqual(result.exit_code, 1) + error = json.loads(result.output)["error"] + self.assertIn("--media-types is not supported with --export", error["message"]) + + @responses.activate + def test_blank_media_types_without_export_is_rejected(self): + _stub_workspace_init() + + result = runner.invoke( + app, + ["--json", "--api-key", API_KEY, "--workspace", WORKSPACE, "search", "*", "--media-types", ""], + ) + + self.assertEqual(result.exit_code, 1) + self.assertEqual(_search_bodies(), []) + self.assertIn("at least one of", json.loads(result.output)["error"]["message"]) + + +class TestImageSearchMediaTypes(unittest.TestCase): + """`roboflow image search` covers both the workspace and project-scoped paths.""" + + @responses.activate + def test_workspace_path_sends_media_types(self): + _stub_workspace_init() + _stub_search([{"id": "vid1", "filename": "clip.mp4", "mediaType": "video"}]) + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "image", + "search", + "*", + "--media-types", + "video", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(_search_bodies()[0]["mediaTypes"], ["video"]) + + @responses.activate + @patch("roboflow.cli.handlers.image._default_workspace", return_value=WORKSPACE) + def test_project_path_sends_media_types_with_project_filter(self, _mock_ws): + _stub_search([{"id": "vid1", "filename": "clip.mp4", "mediaType": "video"}]) + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "image", + "search", + "*", + "-p", + PROJECT, + "--media-types", + "image,video", + "--fields", + "id,url", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + body = _search_bodies()[0] + self.assertEqual(body["mediaTypes"], ["image", "video"]) + self.assertEqual(body["fields"], ["id", "url"]) + self.assertEqual(body["query"], f"project:{PROJECT} *") + + @responses.activate + @patch("roboflow.cli.handlers.image._default_workspace", return_value=WORKSPACE) + def test_project_path_omitted_selection_keeps_image_default(self, _mock_ws): + _stub_search([{"id": "img1"}]) + + result = runner.invoke( + app, + ["--json", "--api-key", API_KEY, "--workspace", WORKSPACE, "image", "search", "*", "-p", PROJECT], + ) + + self.assertEqual(result.exit_code, 0, result.output) + body = _search_bodies()[0] + self.assertNotIn("mediaTypes", body) + self.assertNotIn("fields", body) + + @responses.activate + @patch("roboflow.cli.handlers.image._default_workspace", return_value=WORKSPACE) + def test_project_path_invalid_selection_is_a_structured_error(self, _mock_ws): + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "image", + "search", + "*", + "-p", + PROJECT, + "--media-types", + "clip", + ], + ) + + self.assertEqual(result.exit_code, 1) + self.assertEqual(_search_bodies(), []) + self.assertIn("clip", json.loads(result.output)["error"]["message"]) + + @responses.activate + @patch("roboflow.cli.handlers.image._default_workspace", return_value=WORKSPACE) + def test_project_path_forwards_cursor(self, _mock_ws): + _stub_search([{"id": "vid1"}], total=2, token="tok2") + + result = runner.invoke( + app, + [ + "--json", + "--api-key", + API_KEY, + "--workspace", + WORKSPACE, + "image", + "search", + "*", + "-p", + PROJECT, + "--media-types", + "video", + "--cursor", + "tok1", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + body = _search_bodies()[0] + self.assertEqual(body["continuationToken"], "tok1") + self.assertEqual(body["mediaTypes"], ["video"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_search_media_types.py b/tests/test_search_media_types.py new file mode 100644 index 00000000..fc7e456b --- /dev/null +++ b/tests/test_search_media_types.py @@ -0,0 +1,278 @@ +"""Explicit media type selection on the project and workspace search surfaces.""" + +import json +import unittest + +import responses + +from roboflow.adapters import rfapi +from roboflow.config import API_URL +from roboflow.util.search_utils import normalize_media_types, parse_media_types_option +from tests import PROJECT_NAME, ROBOFLOW_API_KEY, WORKSPACE_NAME, RoboflowTest + + +class TestNormalizeMediaTypes(unittest.TestCase): + """The shared validator mirrors the API contract: non-empty list of image/video.""" + + def test_none_passes_through(self): + self.assertIsNone(normalize_media_types(None)) + + def test_lowercases_and_dedupes_preserving_order(self): + self.assertEqual(normalize_media_types(["VIDEO", "Image", "video"]), ["video", "image"]) + + def test_accepts_tuple(self): + self.assertEqual(normalize_media_types(("video",)), ["video"]) + + def test_bare_string_names_the_fix(self): + with self.assertRaises(ValueError) as ctx: + normalize_media_types("video") + self.assertIn("must be a list", str(ctx.exception)) + self.assertIn("media_types=['video']", str(ctx.exception)) + + def test_empty_list_rejected(self): + with self.assertRaises(ValueError) as ctx: + normalize_media_types([]) + self.assertIn("non-empty list", str(ctx.exception)) + + def test_unknown_media_type_rejected_with_valid_set(self): + with self.assertRaises(ValueError) as ctx: + normalize_media_types(["audio"]) + self.assertIn("'audio'", str(ctx.exception)) + self.assertIn("'image', 'video'", str(ctx.exception)) + + def test_non_string_entry_rejected(self): + with self.assertRaises(ValueError) as ctx: + normalize_media_types([1]) + self.assertIn("must be strings", str(ctx.exception)) + + +class TestParseMediaTypesOption(unittest.TestCase): + """The CLI option parser turns a comma-separated value into a normalized list.""" + + def test_none_passes_through(self): + self.assertIsNone(parse_media_types_option(None)) + + def test_parses_and_strips(self): + self.assertEqual(parse_media_types_option("image, video"), ["image", "video"]) + + def test_single_value(self): + self.assertEqual(parse_media_types_option("video"), ["video"]) + + def test_blank_value_rejected(self): + with self.assertRaises(ValueError) as ctx: + parse_media_types_option(" , ") + self.assertIn("at least one of", str(ctx.exception)) + + def test_unknown_value_rejected(self): + with self.assertRaises(ValueError): + parse_media_types_option("image,audio") + + +class TestProjectSearchMediaTypes(RoboflowTest): + """Project.search/search_all send `mediaTypes` only when explicitly selected.""" + + SEARCH_URL = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/search?api_key={ROBOFLOW_API_KEY}" + + def _stub(self, results): + responses.add(responses.POST, self.SEARCH_URL, json={"results": results}, status=200) + + def _search_bodies(self): + """Only the /search POSTs — RoboflowTest.setUp mocks workspace/project init calls too.""" + return [ + json.loads(call.request.body) + for call in responses.calls + if call.request.url == self.SEARCH_URL and call.request.body + ] + + def _sent(self, index=0): + return self._search_bodies()[index] + + @responses.activate + def test_omitted_media_types_keeps_image_default_off_the_wire(self): + self._stub([{"id": "img1"}]) + + self.project.search(prompt="cat") + + self.assertNotIn("mediaTypes", self._sent()) + + @responses.activate + def test_video_selection_sends_media_types(self): + self._stub([{"id": "vid1", "mediaType": "video", "videoUrl": "https://signed/vid1.mp4"}]) + + results = self.project.search(media_types=["video"], fields=["id", "url"]) + + self.assertEqual(self._sent()["mediaTypes"], ["video"]) + self.assertEqual(results[0]["mediaType"], "video") + + @responses.activate + def test_mixed_selection_sends_both(self): + self._stub([{"id": "img1"}, {"id": "vid1"}]) + + self.project.search(media_types=["image", "video"]) + + self.assertEqual(self._sent()["mediaTypes"], ["image", "video"]) + + @responses.activate + def test_selection_is_normalized_before_sending(self): + self._stub([]) + + self.project.search(media_types=["VIDEO", "video"]) + + self.assertEqual(self._sent()["mediaTypes"], ["video"]) + + @responses.activate + def test_invalid_selection_raises_before_any_request(self): + with self.assertRaises(ValueError): + self.project.search(media_types=["audio"]) + + self.assertEqual(self._search_bodies(), []) + + @responses.activate + def test_search_all_forwards_media_types_on_every_page(self): + page_one = [{"id": f"vid{i}"} for i in range(2)] + responses.add(responses.POST, self.SEARCH_URL, json={"results": page_one}, status=200) + responses.add(responses.POST, self.SEARCH_URL, json={"results": [{"id": "vid2"}]}, status=200) + + pages = list(self.project.search_all(limit=2, media_types=["video"])) + + self.assertEqual(len(pages), 2) + self.assertEqual(len(self._search_bodies()), 2) + for index in range(2): + self.assertEqual(self._sent(index)["mediaTypes"], ["video"]) + # Offset still advances by `limit` while the selection is carried along. + self.assertEqual(self._sent(0)["offset"], 0) + self.assertEqual(self._sent(1)["offset"], 2) + + @responses.activate + def test_search_all_without_selection_stays_image_default(self): + responses.add(responses.POST, self.SEARCH_URL, json={"results": [{"id": "img0"}]}, status=200) + + list(self.project.search_all(limit=2)) + + self.assertNotIn("mediaTypes", self._sent()) + + +class TestWorkspaceSearchMediaTypes(unittest.TestCase): + """Workspace.search/search_all and the adapter forward `mediaTypes` to search/v1.""" + + API_KEY = "test_key" + WORKSPACE = "test-ws" + SEARCH_URL = f"{API_URL}/{WORKSPACE}/search/v1?api_key={API_KEY}" + + def _make_workspace(self): + from roboflow.core.workspace import Workspace + + info = {"workspace": {"name": "Test", "url": self.WORKSPACE, "projects": [], "members": []}} + return Workspace(info, api_key=self.API_KEY, default_workspace=self.WORKSPACE, model_format="yolov8") + + @staticmethod + def _sent(index=0): + return json.loads(responses.calls[index].request.body) + + @responses.activate + def test_omitted_media_types_keeps_image_default_off_the_wire(self): + responses.add(responses.POST, self.SEARCH_URL, json={"results": [], "total": 0}, status=200) + + self._make_workspace().search("tag:review") + + self.assertNotIn("mediaTypes", self._sent()) + + @responses.activate + def test_video_selection_sends_media_types(self): + body = { + "results": [{"id": "vid1", "mediaType": "video", "videoUrl": "https://signed/vid1.mp4"}], + "total": 1, + "continuationToken": None, + } + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + page = self._make_workspace().search("*", media_types=["video"], fields=["id", "url"]) + + self.assertEqual(self._sent()["mediaTypes"], ["video"]) + self.assertEqual(page["results"][0]["videoUrl"], "https://signed/vid1.mp4") + + @responses.activate + def test_mixed_selection_sends_both(self): + responses.add(responses.POST, self.SEARCH_URL, json={"results": [], "total": 0}, status=200) + + self._make_workspace().search("*", media_types=["image", "video"]) + + self.assertEqual(self._sent()["mediaTypes"], ["image", "video"]) + + @responses.activate + def test_invalid_selection_raises_before_any_request(self): + with self.assertRaises(ValueError): + self._make_workspace().search("*", media_types=[]) + + self.assertEqual(len(responses.calls), 0) + + @responses.activate + def test_search_all_forwards_media_types_across_continuation_pages(self): + responses.add( + responses.POST, + self.SEARCH_URL, + json={"results": [{"id": "vid0"}], "total": 2, "continuationToken": "tok1"}, + status=200, + ) + responses.add( + responses.POST, + self.SEARCH_URL, + json={"results": [{"id": "vid1"}], "total": 2, "continuationToken": None}, + status=200, + ) + + pages = list(self._make_workspace().search_all("*", media_types=["video"])) + + self.assertEqual(len(pages), 2) + self.assertEqual(len(responses.calls), 2) + self.assertEqual(self._sent(0)["mediaTypes"], ["video"]) + self.assertEqual(self._sent(1)["mediaTypes"], ["video"]) + self.assertEqual(self._sent(1)["continuationToken"], "tok1") + + @responses.activate + def test_adapter_sends_media_types(self): + responses.add(responses.POST, self.SEARCH_URL, json={"results": [], "total": 0}, status=200) + + rfapi.workspace_search( + api_key=self.API_KEY, + workspace_url=self.WORKSPACE, + query="*", + media_types=["Video"], + ) + + self.assertEqual(self._sent()["mediaTypes"], ["video"]) + + def test_adapter_media_types_is_keyword_only(self): + """Positional callers must keep working, so media_types sits behind a bare `*`.""" + import inspect + + sig = inspect.signature(rfapi.workspace_search) + self.assertEqual(sig.parameters["media_types"].kind, inspect.Parameter.KEYWORD_ONLY) + # The pre-existing parameters stay positional-or-keyword, in their original order. + positional = [n for n, p in sig.parameters.items() if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD] + self.assertEqual( + positional, + ["api_key", "workspace_url", "query", "page_size", "fields", "continuation_token"], + ) + + def test_sdk_search_media_types_is_keyword_only(self): + import inspect + + from roboflow.core.project import Project + from roboflow.core.workspace import Workspace + + for func in (Project.search, Project.search_all, Workspace.search, Workspace.search_all): + kind = inspect.signature(func).parameters["media_types"].kind + self.assertEqual(kind, inspect.Parameter.KEYWORD_ONLY, func.__qualname__) + + @responses.activate + def test_adapter_without_selection_omits_media_types(self): + responses.add(responses.POST, self.SEARCH_URL, json={"results": [], "total": 0}, status=200) + + rfapi.workspace_search(api_key=self.API_KEY, workspace_url=self.WORKSPACE, query="*") + + self.assertNotIn("mediaTypes", self._sent()) + + +if __name__ == "__main__": + unittest.main()