From 900117df205a6aaa919beaa6cfa36376eab02167 Mon Sep 17 00:00:00 2001 From: sankalp nagaonkar Date: Wed, 22 Jul 2026 19:17:06 +0530 Subject: [PATCH] Add Search V2 migration compatibility --- tests/test_search_v2_migration_warnings.py | 358 +++++++++++++++++++++ videodb/collection.py | 36 ++- videodb/search.py | 124 ++++++- videodb/video.py | 33 +- 4 files changed, 523 insertions(+), 28 deletions(-) create mode 100644 tests/test_search_v2_migration_warnings.py diff --git a/tests/test_search_v2_migration_warnings.py b/tests/test_search_v2_migration_warnings.py new file mode 100644 index 0000000..2af8290 --- /dev/null +++ b/tests/test_search_v2_migration_warnings.py @@ -0,0 +1,358 @@ +import pytest + +import videodb.search as search_module +from videodb.collection import Collection +from videodb.exceptions import SearchError +from videodb.video import Video + + +WARNING = { + "code": "search_v2_migration_dual_read", + "message": "search() now uses Search V2 and may also check older indexes during migration.", + "docs": [{"label": "Search V2 search", "url": "https://example.test/search"}], +} +ASK_WARNING = { + "code": "ask_requires_search_v2_index", + "message": "ask() uses Search V2 indexes only.", + "docs": [{"label": "Ask", "url": "https://example.test/ask"}], +} +SEMANTIC_WARNING = { + "code": "semantic_search_v2_migration_dual_read", + "message": "semantic_search() may also check older indexes during migration.", + "docs": [{"label": "Semantic search", "url": "https://example.test/semantic"}], +} + + +class FakeConnection: + def __init__(self): + self.calls = [] + + def post(self, path, data=None, **kwargs): + self.calls.append((path, data or {})) + if path == "compile": + return { + "stream_url": "https://stream.example.test/compiled.m3u8", + "player_url": "https://player.example.test/watch?v=compiled", + } + if path.endswith("/ask"): + return {"answer": "I do not have enough information to answer.", "sources": [], "warnings": [ASK_WARNING]} + if path.endswith("/semantic-search"): + return {"results": _results("semantic"), "warnings": [SEMANTIC_WARNING]} + if path.endswith("/query"): + return {"results": _results("query"), "warnings": [WARNING]} + if path.endswith("/aggregate"): + return {"results": [{"label": "object", "count": 2}], "warnings": [WARNING]} + if path.endswith("/search/v2"): + if (data or {}).get("mode") == "deepsearch": + return {"response_type": "deepsearch", "results": _results("deepsearch"), "warnings": [WARNING]} + return {"response_type": "shots", "results": _results("v2"), "warnings": [WARNING]} + if path.endswith("/search"): + return {"results": _results("legacy")} + raise AssertionError(f"unexpected path: {path}") + + +class FakeCollectionConnection(FakeConnection): + pass + + +def _results(text): + return [ + { + "collection_id": "c1", + "video_id": "v1", + "length": 30, + "title": "video", + "docs": [{"start": 1, "end": 3, "text": text, "score": 0.8}], + } + ] + + +@pytest.fixture(autouse=True) +def reset_warning_state(): + search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() + yield + search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() + + +def test_search_v2_server_warnings_are_emitted_and_exposed(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2") as emitted: + response = video.search("find cars", filter={"legacy_key": "legacy_value"}) + + assert len(emitted) == 1 + assert conn.calls[-1] == ( + "video/v1/search/v2", + {"query": "find cars", "filter": {"legacy_key": "legacy_value"}}, + ) + assert response.warnings == [WARNING] + assert response.results.warnings == [WARNING] + assert response.shots[0].text == "v2" + + +def test_deepsearch_server_warnings_are_emitted_and_exposed_on_search_response(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars", mode="deepsearch") + + assert conn.calls[-1] == ("video/v1/search/v2", {"query": "find cars", "mode": "deepsearch"}) + assert response.response_type == "deepsearch" + assert response.warnings == [WARNING] + assert response.results.warnings == [WARNING] + + +def test_ask_server_warnings_are_emitted_and_exposed_on_ask_response(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"ask\(\) uses Search V2 indexes only"): + response = video.ask("what happened?") + + assert conn.calls[-1] == ( + "video/v1/ask", + {"question": "what happened?", "top_k": 15, "mode": "default", "include_sources": False}, + ) + assert response.answer == "I do not have enough information to answer." + assert response.warnings == [ASK_WARNING] + + +def test_search_response_compile_delegates_for_shots(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars") + + assert response.compile() == "https://stream.example.test/compiled.m3u8" + assert conn.calls[-1] == ( + "compile", + [{"video_id": "v1", "collection_id": "c1", "shots": [(1, 3)]}], + ) + assert response.results.stream_url == "https://stream.example.test/compiled.m3u8" + + +def test_search_response_play_delegates_for_shots(monkeypatch): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + played = [] + + monkeypatch.setattr(search_module, "play_stream", lambda url: played.append(url) or f"played:{url}") + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars") + + assert response.play() == "played:https://stream.example.test/compiled.m3u8" + assert played == ["https://stream.example.test/compiled.m3u8"] + + +def test_search_response_embed_code_delegates_for_deepsearch(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.search("find cars", mode="deepsearch") + + html = response.get_embed_code(width="640", height=360, title="Result", allow_fullscreen=False) + assert response.response_type == "deepsearch" + assert 'src="https://player.example.test/embed?v=compiled"' in html + assert 'width="640"' in html + assert 'height="360"' in html + assert 'title="Result"' in html + assert "allowfullscreen" not in html + + +def test_search_response_media_helpers_raise_for_non_shot_response(): + response = search_module.SearchResponse( + FakeConnection(), + response_type="aggregate", + results=[{"label": "car", "count": 2}], + ) + + with pytest.raises(SearchError, match="does not contain shot results"): + response.compile() + with pytest.raises(SearchError, match="does not contain shot results"): + response.play() + with pytest.raises(SearchError, match="does not contain shot results"): + response.get_embed_code() + + +def test_semantic_search_accepts_plural_selectors_emits_and_exposes_warnings(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"semantic_search\(\) may also check older indexes"): + response = video.semantic_search("find cars", index_names=["scene"], index_ids=["idx-v2"]) + + assert conn.calls[-1] == ( + "video/v1/semantic-search", + { + "query": "find cars", + "index_names": ["scene"], + "index_ids": ["idx-v2"], + "top_k": 10, + "score_threshold": None, + "filter": None, + "return_fields": None, + }, + ) + assert response.warnings == [SEMANTIC_WARNING] + assert response.shots[0].text == "semantic" + + +def test_semantic_search_rejects_singular_selectors_in_sdk_signature(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(TypeError, match="unexpected keyword argument 'index_name'"): + video.semantic_search("find cars", index_name="scene") + with pytest.raises(TypeError, match="unexpected keyword argument 'index_id'"): + video.semantic_search("find cars", index_id="idx-old") + assert conn.calls == [] + + +def test_query_supports_singular_index_name_emits_and_exposes_warnings(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.query(index_name="scene") + + assert conn.calls[-1] == ( + "video/v1/query", + { + "index_name": "scene", + "index_id": None, + "filter": None, + "limit": 100, + "return_fields": None, + "sort": None, + }, + ) + assert response.warnings == [WARNING] + assert response.shots[0].text == "query" + + +def test_query_supports_singular_index_id(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.query(index_id="idx-v2") + + assert conn.calls[-1][0] == "video/v1/query" + assert conn.calls[-1][1]["index_name"] is None + assert conn.calls[-1][1]["index_id"] == "idx-v2" + assert response.warnings == [WARNING] + + +def test_query_rejects_plural_selectors_in_sdk_signature(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(TypeError, match="unexpected keyword argument 'index_ids'"): + video.query(index_ids=["idx-v2"]) + assert conn.calls == [] + + +def test_aggregate_keeps_singular_index_name_emits_and_returns_raw_warning_payload(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.aggregate(index_name="scene") + + assert conn.calls[-1] == ( + "video/v1/aggregate", + { + "index_name": "scene", + "index_id": None, + "filter": None, + "group_by": None, + "metric": "count", + "limit": 100, + "sort": None, + }, + ) + assert response["warnings"] == [WARNING] + + +def test_aggregate_keeps_singular_index_id(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): + response = video.aggregate(index_id="idx-v2") + + assert conn.calls[-1][0] == "video/v1/aggregate" + assert conn.calls[-1][1]["index_name"] is None + assert conn.calls[-1][1]["index_id"] == "idx-v2" + assert response["warnings"] == [WARNING] + + +def test_search_index_id_routes_video_to_legacy_with_python_warning_and_scene_index_id_mapping(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided") as emitted: + response = video.search("find cars", index_id="old-scene-index") + + assert len(emitted) == 1 + assert conn.calls[-1][0] == "video/v1/search" + assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" + assert response.warnings == [] + assert response.shots[0].text == "legacy" + + +def test_search_index_id_routes_collection_to_legacy_with_python_warning_and_scene_index_id_mapping(): + conn = FakeCollectionConnection() + collection = Collection(conn, id="c1") + + with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided"): + response = collection.search("find cars", index_id="old-scene-index") + + assert conn.calls[-1][0] == "collection/c1/search" + assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" + assert response.warnings == [] + + +def test_explicit_legacy_search_warns_once_and_uses_old_endpoint(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.warns(UserWarning, match=r"legacy_search\(\) searches older spoken-word and scene indexes only") as emitted: + video.legacy_search("find cars") + video.legacy_search("find people") + + assert len(emitted) == 1 + assert [call[0] for call in conn.calls] == ["video/v1/search", "video/v1/search"] + + +def test_search_rejects_mixed_legacy_and_v2_params_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match="Cannot mix legacy search parameters with Search V2 parameters"): + video.search("find cars", index_id="old-scene-index", top_k=5) + assert conn.calls == [] + + +def test_search_rejects_index_selectors_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): + video.search("find cars", index_names=["scene"]) + with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): + video.search("find cars", index_ids=["idx-v2"]) + assert conn.calls == [] + + +def test_search_rejects_internal_deepsearch_config_before_http(): + conn = FakeConnection() + video = Video(conn, id="v1", collection_id="c1") + + with pytest.raises(ValueError, match=r"deepsearch_config is not a public search\(\) option"): + video.search("find cars", deepsearch_config={"internal": True}) + assert conn.calls == [] diff --git a/videodb/collection.py b/videodb/collection.py index 9059ab0..c8e1ea7 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -22,7 +22,15 @@ from videodb.meeting import Meeting from videodb.capture_session import CaptureSession from videodb.rtstream import RTStream, RTStreamSearchResult, RTStreamShot -from videodb.search import AskResponse, SearchFactory, SearchResponse, SearchResult, warn_legacy_search_once +from videodb.search import ( + AskResponse, + SearchFactory, + SearchResponse, + SearchResult, + warn_explicit_legacy_search_once, + warn_legacy_search_once, + warn_response_warnings_once, +) logger = logging.getLogger(__name__) @@ -644,7 +652,7 @@ def search( "session_id", "config", } - unsupported_params = {"index_name", "index_names", "index_id", "index_ids"} + unsupported_params = {"index_name", "index_names", "index_ids"} if config is not None: kwargs["config"] = config @@ -669,21 +677,23 @@ def search( has_unsupported = any(k in kwargs and kwargs[k] is not None for k in unsupported_params) if kwargs.get("deepsearch_config") is not None: - raise ValueError("deepsearch_config is internal and cannot be passed to search().") + raise ValueError( + "deepsearch_config is not a public search() option. Use mode='deepsearch', top_k, session_id, and return_fields for DeepSearch requests." + ) if has_old and (has_new or has_unsupported): raise ValueError( - "Cannot mix legacy search params with new search params. " - "Use search(...) for new search or legacy_search(...) for legacy search." + "Cannot mix legacy search parameters with Search V2 parameters. " + "Use legacy_search(...) for older indexes, or remove legacy parameters and use Search V2 search(...)." ) if has_unsupported: raise ValueError( - "index_name/index_names/index_id/index_ids are not supported in search(). " - "Use semantic_search(), query(), or aggregate() for index-specific calls." + "search() chooses indexes automatically and does not accept index selectors. " + "Use semantic_search() for semantic index selection, query() for structured filtering, or aggregate() for counts and facets." ) if has_old: warn_legacy_search_once() - return self.legacy_search(query=query, **kwargs) + return self.legacy_search(query=query, _skip_warning=True, **kwargs) return self._new_search(query=query, **kwargs) @@ -769,7 +779,7 @@ def aggregate( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> Union[Dict, List[Dict]]: - return self._connection.post( + aggregate_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.aggregate}", data={ "index_name": index_name, @@ -781,6 +791,9 @@ def aggregate( "sort": sort, }, ) + if isinstance(aggregate_data, dict): + warn_response_warnings_once(aggregate_data.get("warnings") or []) + return aggregate_data def legacy_search( self, @@ -796,6 +809,7 @@ def legacy_search( scene_index_id: Optional[str] = None, index_id: Optional[str] = None, algorithm: Optional[str] = None, + _skip_warning: bool = False, ) -> Union[SearchResult, RTStreamSearchResult]: """Search for a query in the collection. @@ -815,6 +829,8 @@ def legacy_search( :rtype: Union[:class:`videodb.search.SearchResult`, :class:`videodb.rtstream.RTStreamSearchResult`] """ + if not _skip_warning: + warn_explicit_legacy_search_once() if scene_index_id is None and index_id is not None: scene_index_id = index_id @@ -864,6 +880,8 @@ def legacy_search( dynamic_score_percentage=dynamic_score_percentage, sort_docs_on=sort_docs_on, filter=filter, + scene_index_id=scene_index_id, + algorithm=algorithm, ) def search_title(self, query) -> List[Video]: diff --git a/videodb/search.py b/videodb/search.py index b11eec2..32cb102 100644 --- a/videodb/search.py +++ b/videodb/search.py @@ -17,18 +17,57 @@ _LEGACY_SEARCH_WARNING = ( - "Legacy search parameters detected. This call is routed to legacy search. " - "Use legacy_search(...) to keep legacy behavior, or update to the new search interface." + "This search used legacy search because legacy parameters were provided. " + "Use legacy_search(...) to keep searching older indexes, or remove legacy parameters and create Search V2 indexes. " + "Docs: https://videodb-docs-indexing-search-v2.mintlify.app/api-reference/search-v2/legacy-search" ) -_LEGACY_SEARCH_WARNING_EMITTED = False +_EXPLICIT_LEGACY_SEARCH_WARNING = ( + "legacy_search() searches older spoken-word and scene indexes only. " + "Create Search V2 indexes to use search(), semantic_search(), query(), aggregate(), and ask(). " + "Docs: https://videodb-docs-indexing-search-v2.mintlify.app/pages/understand/indexing-pipelines/create-an-index" +) +_LEGACY_SEARCH_WARNING_EMITTED = set() -def warn_legacy_search_once(): - global _LEGACY_SEARCH_WARNING_EMITTED - if _LEGACY_SEARCH_WARNING_EMITTED: +def _warn_once(key: str, message: str, stacklevel: int = 4): + if key in _LEGACY_SEARCH_WARNING_EMITTED: return - _LEGACY_SEARCH_WARNING_EMITTED = True - warnings.warn(_LEGACY_SEARCH_WARNING, UserWarning, stacklevel=3) + _LEGACY_SEARCH_WARNING_EMITTED.add(key) + warnings.warn(message, UserWarning, stacklevel=stacklevel) + + +def warn_legacy_search_once(): + _warn_once("legacy_params", _LEGACY_SEARCH_WARNING) + + +def warn_explicit_legacy_search_once(): + _warn_once("legacy_search", _EXPLICIT_LEGACY_SEARCH_WARNING) + + +def _warning_docs_text(warning: dict) -> str: + docs = warning.get("docs") or [] + parts = [] + for item in docs: + if not isinstance(item, dict): + continue + label = item.get("label") + url = item.get("url") + if label and url: + parts.append(f"{label}: {url}") + elif url: + parts.append(str(url)) + return f" Docs: {'; '.join(parts)}" if parts else "" + + +def warn_response_warnings_once(response_warnings, stacklevel: int = 5): + if isinstance(response_warnings, dict): + response_warnings = [response_warnings] + for warning in response_warnings or []: + if not isinstance(warning, dict): + continue + code = str(warning.get("code") or warning.get("message") or "response_warning") + message = str(warning.get("message") or f"VideoDB warning: {code}").strip() + _warn_once(f"response:{code}", f"{message}{_warning_docs_text(warning)}", stacklevel=stacklevel) class SearchResult: @@ -46,6 +85,9 @@ def __init__(self, _connection, **kwargs): self.stream_url = None self.player_url = None self.collection_id = "default" + self.warnings = kwargs.get("warnings") or [] + if kwargs.get("_emit_warnings", True): + warn_response_warnings_once(self.warnings, stacklevel=5) self._results = kwargs.get("results", []) self._format_results() @@ -175,7 +217,14 @@ class AskResponse: def __init__(self, _connection, **kwargs): self._connection = _connection self.answer = kwargs.get("answer") or "" - self.sources = SearchResult(_connection, results=kwargs.get("sources") or []).shots + self.warnings = kwargs.get("warnings") or [] + warn_response_warnings_once(self.warnings, stacklevel=5) + self.sources = SearchResult( + _connection, + results=kwargs.get("sources") or [], + warnings=self.warnings, + _emit_warnings=False, + ).shots def __repr__(self) -> str: return f"AskResponse(answer={self.answer!r}, sources={self.sources})" @@ -195,9 +244,16 @@ def __init__(self, _connection, **kwargs): self.waiting_for = kwargs.get("waiting_for") or "none" self.clarification = kwargs.get("clarification") self.trace = kwargs.get("trace") + self.warnings = kwargs.get("warnings") or [] + warn_response_warnings_once(self.warnings, stacklevel=6) raw_results = kwargs.get("results", []) if self.response_type in {"shots", "deepsearch"}: - self.results = SearchResult(_connection, results=raw_results) + self.results = SearchResult( + _connection, + results=raw_results, + warnings=self.warnings, + _emit_warnings=False, + ) self.shots = self.results.shots else: self.results = raw_results @@ -234,6 +290,54 @@ def __getitem__(self, index): return self.results[index] return self.results[index] + def get_shot_results(self) -> SearchResult: + """Return the underlying shot results for media helper methods. + + ``search()`` now returns a SearchResponse envelope, but older SDK code often + chained ``.compile()``, ``.play()``, or ``.get_embed_code()`` directly from + ``search()``. Keep that flow working for response types backed by shots. + """ + if self.response_type in {"shots", "deepsearch"} and isinstance(self.results, SearchResult): + return self.results + raise SearchError( + "This SearchResponse does not contain shot results. " + "compile(), play(), and get_embed_code() are only available when response_type is 'shots' or 'deepsearch'." + ) + + def compile(self) -> str: + """Compile shot results into a stream URL. + + Available for ``response_type='shots'`` and ``response_type='deepsearch'``. + """ + return self.get_shot_results().compile() + + def play(self) -> str: + """Compile if needed, then play shot results. + + Available for ``response_type='shots'`` and ``response_type='deepsearch'``. + """ + return self.get_shot_results().play() + + def get_embed_code( + self, + width: str = "100%", + height: int = 405, + title: str = "VideoDB Player", + allow_fullscreen: bool = True, + auto_generate: bool = True, + ) -> str: + """Generate an iframe embed code for shot results. + + Available for ``response_type='shots'`` and ``response_type='deepsearch'``. + """ + return self.get_shot_results().get_embed_code( + width=width, + height=height, + title=title, + allow_fullscreen=allow_fullscreen, + auto_generate=auto_generate, + ) + def get_shots(self) -> List[Shot]: return self.shots diff --git a/videodb/video.py b/videodb/video.py index 298c7f1..8786db6 100644 --- a/videodb/video.py +++ b/videodb/video.py @@ -15,7 +15,15 @@ from videodb.index import Index from videodb.understanding import Understanding, normalize_understanding_analyzers from videodb.scene import Scene, SceneCollection -from videodb.search import AskResponse, SearchFactory, SearchResponse, SearchResult, warn_legacy_search_once +from videodb.search import ( + AskResponse, + SearchFactory, + SearchResponse, + SearchResult, + warn_explicit_legacy_search_once, + warn_legacy_search_once, + warn_response_warnings_once, +) from videodb.shot import Shot _VALID_SEGMENTERS = {Segmenter.word, Segmenter.sentence, Segmenter.time} @@ -115,7 +123,7 @@ def search( "session_id", "config", } - unsupported_params = {"index_name", "index_names", "index_id", "index_ids"} + unsupported_params = {"index_name", "index_names", "index_ids"} if config is not None: kwargs["config"] = config @@ -137,21 +145,23 @@ def search( has_unsupported = any(k in kwargs and kwargs[k] is not None for k in unsupported_params) if kwargs.get("deepsearch_config") is not None: - raise ValueError("deepsearch_config is internal and cannot be passed to search().") + raise ValueError( + "deepsearch_config is not a public search() option. Use mode='deepsearch', top_k, session_id, and return_fields for DeepSearch requests." + ) if has_old and (has_new or has_unsupported): raise ValueError( - "Cannot mix legacy search params with new search params. " - "Use search(...) for new search or legacy_search(...) for legacy search." + "Cannot mix legacy search parameters with Search V2 parameters. " + "Use legacy_search(...) for older indexes, or remove legacy parameters and use Search V2 search(...)." ) if has_unsupported: raise ValueError( - "index_name/index_names/index_id/index_ids are not supported in search(). " - "Use semantic_search(), query(), or aggregate() for index-specific calls." + "search() chooses indexes automatically and does not accept index selectors. " + "Use semantic_search() for semantic index selection, query() for structured filtering, or aggregate() for counts and facets." ) if has_old: warn_legacy_search_once() - return self.legacy_search(query=query, **kwargs) + return self.legacy_search(query=query, _skip_warning=True, **kwargs) return self._new_search(query=query, **kwargs) @@ -237,7 +247,7 @@ def aggregate( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> Union[Dict, List[Dict]]: - return self._connection.post( + aggregate_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.aggregate}", data={ "index_name": index_name, @@ -249,6 +259,9 @@ def aggregate( "sort": sort, }, ) + if isinstance(aggregate_data, dict): + warn_response_warnings_once(aggregate_data.get("warnings") or []) + return aggregate_data def legacy_search( self, @@ -273,6 +286,8 @@ def legacy_search( :return: :class:`SearchResult ` object :rtype: :class:`videodb.search.SearchResult` """ + if not kwargs.pop("_skip_warning", False): + warn_explicit_legacy_search_once() if kwargs.get("scene_index_id") is None and kwargs.get("index_id") is not None: kwargs["scene_index_id"] = kwargs.get("index_id") kwargs.pop("index_id", None)