diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e15c75..6a24e53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Changelog ### Next +* Add unified `kaggle search` command across competitions, datasets, notebooks, models, users, and discussions * Add `--wait`/`--poll-interval` to `kaggle competitions submit` to wait for scoring, and add `kaggle competitions submission ` to look up a single submission's status and score ### 2.2.4 diff --git a/docs/index.rst b/docs/index.rst index bc3f136a..d5b81ad6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,4 +22,5 @@ kaggle-cli documentation model_instances model_instances_versions benchmarks + search tutorials diff --git a/docs/search.md b/docs/search.md new file mode 100644 index 00000000..a6f37721 --- /dev/null +++ b/docs/search.md @@ -0,0 +1,76 @@ +# Search Command + +Search across all Kaggle content from a single command. + +## `kaggle search` + +Runs a unified search over Kaggle competitions, datasets, notebooks, models, +users, and discussions and returns one ranked list of results. This is the +cross-content equivalent of the per-command `-s/--search` flags (such as +`kaggle datasets list --search`); use it when you don't yet know which content +type you're looking for, or when you want results from several types at once. + +By default, `kaggle search` searches all CLI-supported content types +(competition, dataset, notebook, model, user, discussion, benchmark) — not every +backend document type (it excludes types the CLI cannot render usefully, such as +comments, blogs, and courses). Result ordering uses the backend's canonical +cross-content ranking. Use `--type` to narrow to specific types. + +**Usage:** + +```bash +kaggle search "" [options] +``` + +**Options:** + +* `query`: The term(s) to search for (required). +* `-t, --type `: Restrict results to a comma-separated list of content types. Valid types: `competition`, `dataset`, `notebook`, `model`, `user`, `discussion`, `benchmark`. If omitted, all of these CLI-supported types are searched. +* `-m, --mine`: Restrict the search to your own content. +* `--sort-by `: Sort order. One of: `relevance` (default), `hotness`, `votes`, `dateCreated`, `dateUpdated`, `totalComments`, `lastViewed`. +* `--page-size `: Number of results to show on a page (default: 20, max: 100). +* `--page-token `: Page token for results paging (printed as `Next Page Token = ...` at the top of a page when more results exist). +* `-v, --csv`: Print results in CSV format instead of a table. +* `--format `: Print results in the selected format (`csv`, `table`, `json`). Supports field projection, e.g. `--format 'json(type,ref)'`. + +**Result columns:** `type`, `ref`, `title`, `owner`, `votes`. The `ref` column +is the identifier you can pass to other commands — `owner/slug` for datasets, +notebooks, and models; the bare slug for competitions and users. + +**Examples:** + +1. Search everything for a term: + + ```bash + kaggle search "protein folding" + ``` + +2. Search only datasets and models: + + ```bash + kaggle search "diffusion" --type dataset,model + ``` + +3. Find users: + + ```bash + kaggle search "andrew ng" --type user + ``` + +4. Search your own content, most recently updated first: + + ```bash + kaggle search "baseline" --mine --sort-by dateUpdated + ``` + +5. Get machine-readable output: + + ```bash + kaggle search "titanic" --format json + ``` + +6. Fetch the next page: + + ```bash + kaggle search "llm" --page-size 50 --page-token + ``` diff --git a/skills/SKILL.md b/skills/SKILL.md index 929f40a3..1ea25e96 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -59,7 +59,8 @@ kaggle │ └── topics {list, show} ├── config {view, set, unset} ├── auth {login, print-access-token, revoke} -└── quota +├── quota +└── search ``` Note: the CLI accepts aliases such as `kernels get` for `kernels pull` and @@ -83,6 +84,7 @@ Read only the reference needed for the user's task: - [Configuration](references/configuration.md) - config file, default path, proxy, default competition. - [Authentication](references/auth.md) - OAuth login, access token printing, revocation, token/key sources. - [Quota](references/quota.md) - weekly GPU/TPU accelerator quota. +- [Search](references/search.md) - unified cross-content search over competitions, datasets, notebooks, models, users, and discussions. ## Operating Guidance diff --git a/skills/references/search.md b/skills/references/search.md new file mode 100644 index 00000000..bdc2ded8 --- /dev/null +++ b/skills/references/search.md @@ -0,0 +1,65 @@ +# Search CLI Reference + +Use `kaggle search` to run a unified search across Kaggle competitions, +datasets, notebooks, models, users, and discussions from a single command. + +## Prerequisites + +- Python 3.11+ with the `kaggle` package installed. +- Kaggle credentials. + +## Command Hierarchy + +```text +kaggle search "" +``` + +## `kaggle search` + +Searches across Kaggle content types and returns one ranked result list. +Unlike the per-command `-s/--search` flags (for example +`kaggle datasets list --search`), this command searches multiple content types +in a single request and can also find users and discussions. + +By default it searches all CLI-supported content types (competition, dataset, +notebook, model, user, discussion, benchmark) — not every backend document type; +types the CLI cannot render usefully (comments, blogs, courses, etc.) are +excluded. Ordering uses the backend's canonical cross-content ranking. + +**Usage:** + +```bash +kaggle search "" [options] +``` + +**Options:** + +- `query`: Term(s) to search for (required). +- `-t, --type `: Comma-separated content types to restrict to. Valid: `competition`, `dataset`, `notebook`, `model`, `user`, `discussion`, `benchmark`. Omit to search all of these CLI-supported types. +- `-m, --mine`: Restrict the search to your own content. +- `--sort-by `: One of `relevance` (default), `hotness`, `votes`, `dateCreated`, `dateUpdated`, `totalComments`, `lastViewed`. +- `--page-size `: Results per page (default 20, max 100). +- `--page-token `: Page token for paging. +- `-v, --csv`: Print CSV instead of a table. +- `--format `: `csv`, `table`, or `json`; supports projection, e.g. `--format 'json(type,ref)'`. + +**Examples:** + +```bash +kaggle search "weather" +kaggle search "diffusion" --type dataset,model +kaggle search "andrew ng" --type user +kaggle search "baseline" --mine --sort-by dateUpdated +kaggle search "titanic" --format json +``` + +**Purpose:** Discover content across the whole platform when you don't know the +content type in advance, or when you want results from several types at once. + +## Output + +Results are a table with columns `type`, `ref`, `title`, `owner`, and `votes`. +The `ref` value is reusable with other commands: `owner/slug` for datasets, +notebooks, and models; the bare slug for competitions and users. For example, +a `dataset` result's `ref` can be passed to `kaggle datasets download`, and a +`competition` result's `ref` to `kaggle competitions files`. diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index beb1f0e6..6aa93980 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -243,6 +243,14 @@ from kagglesdk.models.types.model_enums import ListModelsOrderBy, ModelInstanceType, ModelFramework from kagglesdk.models.types.model_proxy_api_service import ApiCreateDefaultModelProxyTokenRequest from kagglesdk.models.types.model_types import Owner +from kagglesdk.search.types.search_api_service import ( + ApiListType, + ListEntitiesDocument, + ListEntitiesFilters, + ListEntitiesRequest, + ListEntitiesResponse, +) +from kagglesdk.search.types.search_enums import DocumentType, ListSearchContentOrderBy from kagglesdk.security.types.oauth_service import IntrospectTokenRequest from ..models.upload_file import UploadFile import kagglesdk.kaggle_client @@ -957,6 +965,18 @@ class KaggleApi: "voteCount", ] + # Search valid types + valid_search_document_types = ["competition", "dataset", "notebook", "model", "user", "discussion", "benchmark"] + valid_search_sort_by = [ + "relevance", + "hotness", + "votes", + "dateCreated", + "dateUpdated", + "totalComments", + "lastViewed", + ] + # Competitions valid types valid_competition_groups = ["general", "entered", "community", "hosted", "unlaunched", "unlaunched_community"] valid_competition_categories = [ @@ -6352,6 +6372,247 @@ def quota_view_cli(self, csv_display=False, output_format=None): output_format=output_format, ) + def search( + self, + query: str, + document_types: Optional[list[str]] = None, + sort_by: Optional[str] = None, + mine: bool = False, + page_size: int = 20, + page_token: Optional[str] = None, + ) -> ListEntitiesResponse: + """Searches across Kaggle content using a single unified query. + + Searches competitions, datasets, notebooks, models, users, and + discussions in one request and returns a page of ranked results. + + Args: + query (str): The free-text search query (required). + document_types (Optional[list[str]]): The content types to restrict results + to. Valid values are in valid_search_document_types. If omitted, all + CLI-supported content types are searched (rather than every backend + document type such as comments, blogs, or courses). + sort_by (Optional[str]): How to sort results, one of valid_search_sort_by + (default is "relevance"). + mine (bool): If True, restrict the search to the current user's own content. + page_size (int): The number of results per page (default is 20, max is 100). + page_token (Optional[str]): The page token for pagination. + + Returns: + ListEntitiesResponse: The response with documents and next_page_token. + """ + query = query.strip() if query else "" + if not query: + raise ValueError("A search query must be specified") + + page_size = int(page_size) + if page_size <= 0: + raise ValueError("Page size must be >= 1") + if page_size > 100: + page_size = 100 + + # Default to the CLI-supported content types (rather than sending an empty + # filter, which would let the backend include unrenderable types such as + # comments, blogs, and courses). The backend's canonical ranking still + # orders the mixed results; we only constrain which types are in the pool. + if not document_types: + document_types = self.valid_search_document_types + resolved_types = self._resolve_search_document_types(document_types) + order_by = self._resolve_search_sort_by(sort_by) + + with self.build_kaggle_client() as kaggle: + request = ListEntitiesRequest() + self._set_paging(request, page_size, page_token) + request.canonical_order_by = order_by + filters = ListEntitiesFilters() + filters.query = query + filters.document_types = resolved_types + if mine: + filters.list_type = ApiListType.API_LIST_TYPE_YOUR_WORK + request.filters = filters + return kaggle.search.search_api_client.list_entities(request) + + def search_cli( + self, + query, + document_type=None, + sort_by=None, + mine=False, + page_size=20, + page_token=None, + csv_display=False, + output_format=None, + ): + """A client wrapper for search. + + Args: + query: The free-text search query (required). + document_type: A comma-separated list of content types to restrict to. + sort_by: How to sort results, one of valid_search_sort_by. + mine: If True, restrict the search to the current user's own content. + page_size: The number of results per page (default is 20). + page_token: The page token for pagination. + csv_display: If True, print comma-separated values instead of a table. + output_format: The output format to use. + """ + document_types = document_type.split(",") if document_type else None + response = self.search( + query=query, + document_types=document_types, + sort_by=sort_by, + mine=mine, + page_size=page_size, + page_token=page_token, + ) + documents = response.documents if response else None + if response and response.next_page_token: + print("Next Page Token = {}".format(response.next_page_token)) + if documents: + rows = [self._search_document_to_row(document) for document in documents] + fields = ["type", "ref", "title", "owner", "votes"] + self.print_results( + rows, + fields, + csv_display=csv_display, + output_format=output_format, + ) + else: + print("No matches found") + + def _resolve_search_document_types(self, document_types: Optional[list[str]]) -> list[DocumentType]: + """Resolves user-supplied content-type names to DocumentType enum values. + + Args: + document_types (Optional[list[str]]): The list of type names to resolve. + + Returns: + list[DocumentType]: The resolved, de-duplicated enum values (empty for all types). + """ + if not document_types: + return [] + # notebook/kernel and discussion are CLI names that don't match their + # DocumentType members (KERNEL, TOPIC); everything else is resolved by + # lookup_enum. The supported set gates out backend types the CLI cannot + # render (comment, blog, course, ...). + aliases = {"notebook": "kernel", "kernel": "kernel", "discussion": "topic"} + supported = set(self.valid_search_document_types) | set(aliases) + resolved: list[DocumentType] = [] + for raw in document_types: + key = raw.strip().lower() + if key.endswith("s"): + key = key[:-1] + if key not in supported: + raise ValueError( + "Invalid document type '%s' specified. Valid options are %s" + % (raw.strip(), str(self.valid_search_document_types)) + ) + value = self.lookup_enum(DocumentType, DocumentType.DOCUMENT_TYPE_UNSPECIFIED, aliases.get(key, key)) + if value not in resolved: + resolved.append(value) + return resolved + + def _resolve_search_sort_by(self, sort_by: Optional[str]) -> ListSearchContentOrderBy: + """Resolves a user-supplied sort name to a ListSearchContentOrderBy enum value. + + Args: + sort_by (Optional[str]): The sort name to resolve (default is "relevance"). + + Returns: + ListSearchContentOrderBy: The resolved enum value. + """ + if not sort_by: + sort_by = "relevance" + if sort_by not in self.valid_search_sort_by: + raise ValueError( + "Invalid sort by '%s' specified. Valid options are %s" % (sort_by, str(self.valid_search_sort_by)) + ) + if sort_by == "relevance": + # ListSearchContentOrderBy has no RELEVANCE member; UNSPECIFIED is the + # backend's default relevance ranking. + return ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED + return self.lookup_enum( + ListSearchContentOrderBy, + ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED, + sort_by, + ) + + def _search_document_to_row(self, document: ListEntitiesDocument) -> "SimpleNamespace": + """Normalizes a mixed-type search document into a flat row for display. + + Args: + document (ListEntitiesDocument): A single search result document. + + Returns: + SimpleNamespace: A row with type, ref, title, owner, and votes attributes. + """ + owner = self._search_document_owner(document) + return SimpleNamespace( + type=self._search_document_type_label(document.document_type), + ref=self._search_document_ref(document, owner), + title=document.title or "", + owner=owner or "-", + votes=document.votes if document.votes is not None else 0, + ) + + def _search_document_owner(self, document: ListEntitiesDocument) -> Optional[str]: + """Extracts the owner handle for a search document, if any. + + Args: + document (ListEntitiesDocument): A single search result document. + + Returns: + Optional[str]: The owner user name or organization slug, or None. + """ + if document.document_type == DocumentType.USER: + return None + if document.owner_user is not None and document.owner_user.user_name: + return document.owner_user.user_name + if document.owner_organization is not None and document.owner_organization.slug: + return document.owner_organization.slug + return None + + def _search_document_ref(self, document: ListEntitiesDocument, owner: Optional[str]) -> str: + """Builds a CLI-usable reference for a search document. + + Datasets, notebooks, and models are referenced as ``owner/slug``; competitions + and users are referenced by their slug alone. + + Args: + document (ListEntitiesDocument): A single search result document. + owner (Optional[str]): The pre-computed owner handle for the document. + + Returns: + str: The reference string. + """ + slug = document.slug or "" + owner_slug_types = ( + DocumentType.DATASET, + DocumentType.KERNEL, + DocumentType.MODEL, + DocumentType.BENCHMARK, + ) + if document.document_type in owner_slug_types and owner and slug: + return "%s/%s" % (owner, slug) + if slug: + return slug + return str(document.id) if document.id else "" + + def _search_document_type_label(self, document_type: DocumentType) -> str: + """Returns a friendly, user-facing label for a document type. + + Args: + document_type (DocumentType): The document type enum value. + + Returns: + str: A lowercase label such as "notebook" or "discussion". + """ + # Only KERNEL and TOPIC need a user-facing label that differs from their + # enum name; everything else uses the built-in enum name. + overrides = {DocumentType.KERNEL: "notebook", DocumentType.TOPIC: "discussion"} + if document_type in overrides: + return overrides[document_type] + return document_type.name.replace("DOCUMENT_TYPE_", "").lower() + def kernels_list_files(self, kernel, page_token=None, page_size=20): """Lists files for a kernel. diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 25a2d74b..3b83a1ea 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -63,6 +63,7 @@ def main() -> None: parse_config(subparsers) parse_auth(subparsers) parse_quota(subparsers) + parse_search(subparsers) args = parser.parse_args() command_args = {} command_args.update(vars(args)) @@ -2333,6 +2334,26 @@ def parse_quota(subparsers) -> None: parser_quota.set_defaults(func=api.quota_view_cli) +def parse_search(subparsers) -> None: + parser_search = subparsers.add_parser( + "search", formatter_class=argparse.RawTextHelpFormatter, help=Help.group_search + ) + parser_search_optional = parser_search._action_groups.pop() + parser_search_optional.add_argument("query", help=Help.param_search_query) + parser_search_optional.add_argument( + "-t", "--type", dest="document_type", required=False, help=Help.param_search_document_type + ) + parser_search_optional.add_argument("-m", "--mine", dest="mine", action="store_true", help=Help.param_search_mine) + parser_search_optional.add_argument("--sort-by", dest="sort_by", required=False, help=Help.param_search_sort_by) + parser_search_optional.add_argument( + "--page-size", dest="page_size", default=20, type=int, required=False, help=Help.param_page_size + ) + parser_search_optional.add_argument("--page-token", dest="page_token", required=False, help=Help.param_page_token) + _add_output_format_args(parser_search_optional) + parser_search._action_groups.append(parser_search_optional) + parser_search.set_defaults(func=api.search_cli) + + # ------------------------------------------------------------------ # Shared helpers for discussion topics across entity types # ------------------------------------------------------------------ @@ -2444,6 +2465,7 @@ class Help(object): "config", "auth", "quota", + "search", ] competitions_choices = [ "list", @@ -2587,6 +2609,7 @@ class Help(object): group_config = "Configuration settings" group_auth = "Commands related to authentication" group_quota = "Show the current user's weekly GPU and TPU accelerator quota" + group_search = "Search across Kaggle competitions, datasets, notebooks, models, users, and discussions" # Entity topics commands (shared across entity types) command_entity_topics_show = "Display a topic with all its comments in tree form" @@ -2741,6 +2764,19 @@ class Help(object): param_search = "Term(s) to search for" param_mine = "Display only my items" + # Search params + param_search_query = "Term(s) to search for across Kaggle content" + param_search_document_type = ( + "Restrict results to a comma-separated list of content types.\n" + "Valid types: competition, dataset, notebook, model, user, discussion, benchmark.\n" + "Example: --type dataset,model.\n" + "If omitted, all CLI-supported content types (the types listed above) are searched." + ) + param_search_mine = "Restrict the search to your own content" + param_search_sort_by = ( + "Sort order. One of: relevance (default), hotness, votes, dateCreated, dateUpdated, totalComments, lastViewed" + ) + # Forums params param_forum = ( "Forum slug (e.g. 'getting-started', 'product-feedback').\n" 'Use "kaggle forums" to list available forums.' diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2aaa6724..b642a308 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -61,6 +61,7 @@ def parser(monkeypatch, api): parse_models, parse_forums, parse_benchmarks, + parse_search, Help, ) import kaggle.cli @@ -82,4 +83,5 @@ def parser(monkeypatch, api): parse_models(subparsers) parse_forums(subparsers) parse_benchmarks(subparsers) + parse_search(subparsers) return KaggleParser(root) diff --git a/tests/unit/test_cli_search.py b/tests/unit/test_cli_search.py new file mode 100644 index 00000000..c19416e0 --- /dev/null +++ b/tests/unit/test_cli_search.py @@ -0,0 +1,64 @@ +# coding=utf-8 +import pytest + + +def test_search_parser_default_succeeds(parser): + func, kwargs = parser.dispatch(["search", "weather"]) + assert func.__name__ == "search_cli" + assert kwargs["query"] == "weather" + assert kwargs.get("document_type") is None + assert kwargs.get("mine") is False + assert kwargs.get("sort_by") is None + assert kwargs.get("page_size") == 20 + assert kwargs.get("page_token") is None + assert kwargs.get("csv_display") is False + assert kwargs.get("output_format") is None + + +def test_search_parser_with_flags_succeeds(parser): + func, kwargs = parser.dispatch( + [ + "search", + "llm", + "--type", + "dataset,model", + "--mine", + "--sort-by", + "votes", + "--page-size", + "50", + "--page-token", + "tok123", + "--csv", + ] + ) + assert func.__name__ == "search_cli" + assert kwargs["query"] == "llm" + assert kwargs["document_type"] == "dataset,model" + assert kwargs["mine"] is True + assert kwargs["sort_by"] == "votes" + assert kwargs["page_size"] == 50 + assert kwargs["page_token"] == "tok123" + assert kwargs["csv_display"] is True + + +def test_search_parser_short_flags_succeeds(parser): + func, kwargs = parser.dispatch(["search", "gemma", "-t", "model", "-m"]) + assert func.__name__ == "search_cli" + assert kwargs["document_type"] == "model" + assert kwargs["mine"] is True + + +def test_search_parser_format_json_succeeds(parser): + func, kwargs = parser.dispatch(["search", "x", "--format", "json"]) + assert kwargs["output_format"] == "json" + + +def test_search_parser_requires_query(parser): + with pytest.raises(SystemExit): + parser.dispatch(["search"]) + + +def test_search_parser_csv_and_format_mutually_exclusive(parser): + with pytest.raises(SystemExit): + parser.dispatch(["search", "x", "--csv", "--format", "json"]) diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py new file mode 100644 index 00000000..d3db7938 --- /dev/null +++ b/tests/unit/test_search.py @@ -0,0 +1,362 @@ +# coding=utf-8 +import io +import json +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, "../..") + +from kaggle.api.kaggle_api_extended import KaggleApi +from kagglesdk.search.types.search_api_service import ( + ApiListType, + ApiOrganizationCard, + ApiUserAvatar, + ListEntitiesDocument, +) +from kagglesdk.search.types.search_enums import DocumentType, ListSearchContentOrderBy + + +def _avatar(user_name): + avatar = ApiUserAvatar() + avatar.user_name = user_name + return avatar + + +def _org(slug): + org = ApiOrganizationCard() + org.slug = slug + return org + + +def _doc(document_type, title="", slug="", user=None, org=None, votes=0, doc_id=0): + document = ListEntitiesDocument() + document.document_type = document_type + document.title = title + document.slug = slug + document.votes = votes + document.id = doc_id + if user is not None: + document.owner_user = _avatar(user) + if org is not None: + document.owner_organization = _org(org) + return document + + +def _response(documents=None, next_page_token=""): + response = MagicMock() + response.documents = documents if documents is not None else [] + response.next_page_token = next_page_token + return response + + +class TestSearchApi(unittest.TestCase): + """Tests for the request-building behavior of KaggleApi.search.""" + + def setUp(self): + self.api = KaggleApi.__new__(KaggleApi) + + def _patch_client(self, response): + captured = {} + mock_kaggle = MagicMock() + + def _list_entities(request): + captured["request"] = request + return response + + mock_kaggle.search.search_api_client.list_entities.side_effect = _list_entities + patcher = patch.object(KaggleApi, "build_kaggle_client") + mock_client = patcher.start() + mock_client.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + self.addCleanup(patcher.stop) + captured["client"] = mock_client + return captured + + def test_search_builds_request(self): + captured = self._patch_client(_response()) + self.api.search("weather") + request = captured["request"] + self.assertEqual(request.filters.query, "weather") + self.assertEqual(request.page_size, 20) + self.assertEqual( + request.canonical_order_by, + ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED, + ) + + def test_search_defaults_to_cli_supported_types(self): + # When no --type is given, the CLI restricts the pool to the supported + # content types rather than sending an empty filter (which would let the + # backend include unrenderable types like comments and blogs). + captured = self._patch_client(_response()) + self.api.search("weather") + self.assertEqual( + list(captured["request"].filters.document_types), + [ + DocumentType.COMPETITION, + DocumentType.DATASET, + DocumentType.KERNEL, + DocumentType.MODEL, + DocumentType.USER, + DocumentType.TOPIC, + DocumentType.BENCHMARK, + ], + ) + + def test_search_uses_authenticated_client(self): + captured = self._patch_client(_response()) + self.api.search("weather") + captured["client"].assert_called_once() + + def test_search_strips_query(self): + captured = self._patch_client(_response()) + self.api.search(" weather ") + self.assertEqual(captured["request"].filters.query, "weather") + + def test_search_multiple_document_types(self): + captured = self._patch_client(_response()) + self.api.search("llm", document_types=["dataset", "model"]) + self.assertEqual( + list(captured["request"].filters.document_types), + [DocumentType.DATASET, DocumentType.MODEL], + ) + + def test_search_notebook_alias_maps_to_kernel_and_dedupes(self): + captured = self._patch_client(_response()) + self.api.search("x", document_types=["notebook", "kernel", "notebooks"]) + self.assertEqual(list(captured["request"].filters.document_types), [DocumentType.KERNEL]) + + def test_search_discussion_maps_to_topic(self): + captured = self._patch_client(_response()) + self.api.search("x", document_types=["discussion"]) + self.assertEqual(list(captured["request"].filters.document_types), [DocumentType.TOPIC]) + + def test_search_benchmark_type(self): + captured = self._patch_client(_response()) + self.api.search("llm", document_types=["benchmark", "benchmarks"]) + self.assertEqual(list(captured["request"].filters.document_types), [DocumentType.BENCHMARK]) + + def test_search_rejects_unsupported_backend_type(self): + # "comment" is a real DocumentType member but is not CLI-supported; it + # must be rejected even though lookup_enum could resolve it. + with self.assertRaises(ValueError): + self.api.search("x", document_types=["comment"]) + + def test_search_mine_sets_list_type(self): + captured = self._patch_client(_response()) + self.api.search("x", mine=True) + self.assertEqual(captured["request"].filters.list_type, ApiListType.API_LIST_TYPE_YOUR_WORK) + + def test_search_sort_by_votes(self): + captured = self._patch_client(_response()) + self.api.search("x", sort_by="votes") + self.assertEqual( + captured["request"].canonical_order_by, + ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_VOTES, + ) + + def test_search_sort_by_camelcase_resolves_via_lookup_enum(self): + captured = self._patch_client(_response()) + self.api.search("x", sort_by="dateCreated") + self.assertEqual( + captured["request"].canonical_order_by, + ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_DATE_CREATED, + ) + + def test_search_sort_by_relevance_maps_to_unspecified(self): + captured = self._patch_client(_response()) + self.api.search("x", sort_by="relevance") + self.assertEqual( + captured["request"].canonical_order_by, + ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED, + ) + + def test_search_pagination(self): + captured = self._patch_client(_response()) + self.api.search("x", page_size=50, page_token="tok") + self.assertEqual(captured["request"].page_size, 50) + self.assertEqual(captured["request"].page_token, "tok") + + def test_search_page_size_capped_at_100(self): + captured = self._patch_client(_response()) + self.api.search("x", page_size=500) + self.assertEqual(captured["request"].page_size, 100) + + def test_search_empty_query_raises(self): + with self.assertRaises(ValueError): + self.api.search(" ") + + def test_search_none_query_raises(self): + with self.assertRaises(ValueError): + self.api.search(None) + + def test_search_invalid_document_type_raises(self): + with self.assertRaises(ValueError): + self.api.search("x", document_types=["frobnicate"]) + + def test_search_invalid_sort_by_raises(self): + with self.assertRaises(ValueError): + self.api.search("x", sort_by="nonsense") + + def test_search_invalid_page_size_raises(self): + with self.assertRaises(ValueError): + self.api.search("x", page_size=0) + + def test_search_backend_failure_propagates(self): + patcher = patch.object(KaggleApi, "build_kaggle_client") + mock_client = patcher.start() + self.addCleanup(patcher.stop) + mock_kaggle = MagicMock() + mock_kaggle.search.search_api_client.list_entities.side_effect = RuntimeError("boom") + mock_client.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + with self.assertRaises(RuntimeError): + self.api.search("x") + + +class TestSearchRenderer(unittest.TestCase): + """Tests for the mixed-entity row normalization.""" + + def setUp(self): + self.api = KaggleApi.__new__(KaggleApi) + + def test_dataset_ref_is_owner_slash_slug(self): + row = self.api._search_document_to_row( + _doc(DocumentType.DATASET, "Wine Reviews", "wine-reviews", user="zynicide", votes=10) + ) + self.assertEqual(row.type, "dataset") + self.assertEqual(row.ref, "zynicide/wine-reviews") + self.assertEqual(row.owner, "zynicide") + self.assertEqual(row.title, "Wine Reviews") + self.assertEqual(row.votes, 10) + + def test_competition_ref_is_slug_only(self): + row = self.api._search_document_to_row(_doc(DocumentType.COMPETITION, "Titanic", "titanic", org="kaggle")) + self.assertEqual(row.type, "competition") + self.assertEqual(row.ref, "titanic") + self.assertEqual(row.owner, "kaggle") + + def test_kernel_labelled_notebook(self): + row = self.api._search_document_to_row(_doc(DocumentType.KERNEL, "EDA", "titanic-eda", user="startupsci")) + self.assertEqual(row.type, "notebook") + self.assertEqual(row.ref, "startupsci/titanic-eda") + + def test_model_ref_uses_organization_owner(self): + row = self.api._search_document_to_row(_doc(DocumentType.MODEL, "Gemma", "gemma", org="google")) + self.assertEqual(row.type, "model") + self.assertEqual(row.ref, "google/gemma") + self.assertEqual(row.owner, "google") + + def test_user_owner_is_dash(self): + row = self.api._search_document_to_row(_doc(DocumentType.USER, "Dan Becker", "dansbecker")) + self.assertEqual(row.type, "user") + self.assertEqual(row.ref, "dansbecker") + self.assertEqual(row.owner, "-") + + def test_discussion_falls_back_to_id(self): + row = self.api._search_document_to_row(_doc(DocumentType.TOPIC, "Welcome", "", doc_id=42)) + self.assertEqual(row.type, "discussion") + self.assertEqual(row.ref, "42") + + def test_benchmark_ref_is_owner_slash_slug(self): + row = self.api._search_document_to_row( + _doc(DocumentType.BENCHMARK, "LLMs understand images", "llms-understand-images", user="gpreda", votes=7) + ) + self.assertEqual(row.type, "benchmark") + self.assertEqual(row.ref, "gpreda/llms-understand-images") + self.assertEqual(row.owner, "gpreda") + self.assertEqual(row.votes, 7) + + +class TestSearchCli(unittest.TestCase): + """Tests for the output formatting of KaggleApi.search_cli.""" + + def setUp(self): + self.api = KaggleApi.__new__(KaggleApi) + + def _capture(self, **kwargs): + captured = io.StringIO() + sys.stdout = captured + try: + self.api.search_cli(**kwargs) + finally: + sys.stdout = sys.__stdout__ + return captured.getvalue() + + @patch.object(KaggleApi, "search") + def test_search_cli_table(self, mock_search): + mock_search.return_value = _response( + documents=[ + _doc(DocumentType.DATASET, "Wine Reviews", "wine-reviews", user="zynicide", votes=10), + _doc(DocumentType.COMPETITION, "Titanic", "titanic", org="kaggle"), + ] + ) + out = self._capture(query="x") + self.assertIn("dataset", out) + self.assertIn("zynicide/wine-reviews", out) + self.assertIn("competition", out) + self.assertIn("titanic", out) + + @patch.object(KaggleApi, "search") + def test_search_cli_no_matches(self, mock_search): + mock_search.return_value = _response(documents=[]) + self.assertIn("No matches found", self._capture(query="x")) + + @patch.object(KaggleApi, "search") + def test_search_cli_prints_next_page_token(self, mock_search): + mock_search.return_value = _response( + documents=[_doc(DocumentType.DATASET, "D", "d", user="u")], + next_page_token="NEXT", + ) + self.assertIn("Next Page Token = NEXT", self._capture(query="x")) + + @patch.object(KaggleApi, "search") + def test_search_cli_csv(self, mock_search): + mock_search.return_value = _response( + documents=[_doc(DocumentType.DATASET, "Wine", "wine-reviews", user="zynicide", votes=10)] + ) + lines = [line for line in self._capture(query="x", csv_display=True).splitlines() if line] + self.assertEqual(lines[0], "type,ref,title,owner,votes") + self.assertTrue(any("zynicide/wine-reviews" in line for line in lines)) + + @patch.object(KaggleApi, "search") + def test_search_cli_json(self, mock_search): + mock_search.return_value = _response( + documents=[_doc(DocumentType.MODEL, "Gemma", "gemma", org="google", votes=5)] + ) + data = json.loads(self._capture(query="x", output_format="json")) + self.assertEqual(data[0]["type"], "model") + self.assertEqual(data[0]["ref"], "google/gemma") + self.assertEqual(data[0]["owner"], "google") + self.assertEqual(data[0]["votes"], 5) + + @patch.object(KaggleApi, "search") + def test_search_cli_json_projection(self, mock_search): + mock_search.return_value = _response( + documents=[_doc(DocumentType.MODEL, "Gemma", "gemma", org="google", votes=5)] + ) + data = json.loads(self._capture(query="x", output_format="json(type,ref)")) + self.assertEqual(list(data[0].keys()), ["type", "ref"]) + self.assertEqual(data[0]["ref"], "google/gemma") + + @patch.object(KaggleApi, "search") + def test_search_cli_splits_document_type(self, mock_search): + mock_search.return_value = _response(documents=[]) + self._capture(query="x", document_type="dataset,model") + _, kwargs = mock_search.call_args + self.assertEqual(kwargs["document_types"], ["dataset", "model"]) + + @patch.object(KaggleApi, "search") + def test_search_cli_passes_arguments(self, mock_search): + mock_search.return_value = _response(documents=[]) + self._capture(query="llm", sort_by="votes", mine=True, page_size=50, page_token="tok") + _, kwargs = mock_search.call_args + self.assertEqual(kwargs["query"], "llm") + self.assertEqual(kwargs["sort_by"], "votes") + self.assertEqual(kwargs["mine"], True) + self.assertEqual(kwargs["page_size"], 50) + self.assertEqual(kwargs["page_token"], "tok") + + +if __name__ == "__main__": + unittest.main()