From c00bc681ce73ac2cb2fdf0ceed633db3613c6a94 Mon Sep 17 00:00:00 2001 From: sridipbasu Date: Wed, 22 Jul 2026 19:49:39 +0530 Subject: [PATCH 1/3] feat(search): add unified \kaggle search\ command across all content types --- CHANGELOG.md | 2 + docs/index.rst | 1 + docs/search.md | 76 +++++ skills/SKILL.md | 4 +- skills/references/search.md | 65 +++++ src/kaggle/api/kaggle_api_extended.py | 264 ++++++++++++++++- src/kaggle/cli.py | 36 +++ tests/unit/conftest.py | 2 + tests/unit/test_cli_search.py | 64 ++++ tests/unit/test_dataset_metadata_update.py | 73 +++++ tests/unit/test_search.py | 325 +++++++++++++++++++++ 11 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 docs/search.md create mode 100644 skills/references/search.md create mode 100644 tests/unit/test_cli_search.py create mode 100644 tests/unit/test_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 943e91ba..4f338fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Changelog ### Next +* Add unified `kaggle search` command across competitions, datasets, notebooks, models, users, and discussions + ### 2.2.3 * Update --format help text to reference output_format.md (#1074) 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..2d737f72 --- /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) — 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`. 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`, `published`, `updated`, `comments`, `viewed`. +* `--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 updated + ``` + +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..b5ae3f13 --- /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) — 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`. 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`, `published`, `updated`, `comments`, `viewed`. +- `--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 updated +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 102155d8..441ac34d 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -191,6 +191,7 @@ DatasetCollaborator, DatasetSettingsFile, ) +from kagglesdk.users.types.users_enums import CollaboratorType from kagglesdk.kaggle_object import KaggleObject from kagglesdk.kernels.types.kernels_api_service import ( ApiListKernelsRequest, @@ -235,6 +236,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 @@ -948,6 +957,10 @@ class KaggleApi: "voteCount", ] + # Search valid types + valid_search_document_types = ["competition", "dataset", "notebook", "model", "user", "discussion"] + valid_search_sort_by = ["relevance", "hotness", "votes", "published", "updated", "comments", "viewed"] + # Competitions valid types valid_competition_groups = ["general", "entered", "community", "hosted", "unlaunched", "unlaunched_community"] valid_competition_categories = [ @@ -4675,11 +4688,10 @@ def _new_license(name): l.name = name return l - @staticmethod - def _new_collaborator(name, role): + def _new_collaborator(self, name, role): u = DatasetCollaborator() u.username = name - u.role = role + u.role = self.lookup_enum(CollaboratorType, CollaboratorType.COLLABORATOR_TYPE_UNSPECIFIED, role) return u def dataset_metadata(self, dataset, path): @@ -5891,6 +5903,252 @@ 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 [] + mapping = { + "competition": DocumentType.COMPETITION, + "dataset": DocumentType.DATASET, + "notebook": DocumentType.KERNEL, + "kernel": DocumentType.KERNEL, + "model": DocumentType.MODEL, + "user": DocumentType.USER, + "discussion": DocumentType.TOPIC, + } + resolved: list[DocumentType] = [] + for raw in document_types: + key = raw.strip().lower() + if key.endswith("s"): + key = key[:-1] + if key not in mapping: + raise ValueError( + "Invalid document type '%s' specified. Valid options are %s" + % (raw.strip(), str(self.valid_search_document_types)) + ) + value = mapping[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. + """ + mapping = { + "relevance": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED, + "hotness": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_HOTNESS, + "votes": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_VOTES, + "published": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_DATE_CREATED, + "updated": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_DATE_UPDATED, + "comments": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_TOTAL_COMMENTS, + "viewed": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_LAST_VIEWED, + } + if not sort_by: + return mapping["relevance"] + key = sort_by.strip().lower() + if key not in mapping: + raise ValueError( + "Invalid sort by '%s' specified. Valid options are %s" % (sort_by, str(self.valid_search_sort_by)) + ) + return mapping[key] + + 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 "" + if document.document_type in (DocumentType.DATASET, DocumentType.KERNEL, DocumentType.MODEL) 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". + """ + labels = { + DocumentType.COMPETITION: "competition", + DocumentType.DATASET: "dataset", + DocumentType.KERNEL: "notebook", + DocumentType.MODEL: "model", + DocumentType.USER: "user", + DocumentType.TOPIC: "discussion", + DocumentType.COMMENT: "comment", + } + if document_type in labels: + return labels[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 6daf1f7f..d5e10534 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)) @@ -2229,6 +2230,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 # ------------------------------------------------------------------ @@ -2340,6 +2361,7 @@ class Help(object): "config", "auth", "quota", + "search", ] competitions_choices = [ "list", @@ -2480,6 +2502,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" @@ -2618,6 +2641,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.\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, published, updated, comments, viewed" + ) + # 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_dataset_metadata_update.py b/tests/unit/test_dataset_metadata_update.py index 272a87c6..9a72302e 100644 --- a/tests/unit/test_dataset_metadata_update.py +++ b/tests/unit/test_dataset_metadata_update.py @@ -13,6 +13,7 @@ from kaggle.api.kaggle_api_extended import KaggleApi from kagglesdk.datasets.types.dataset_types import DatasetSettings, DatasetSettingsFile, DatasetSettingsFileColumn from kagglesdk.datasets.types.dataset_api_service import ApiUpdateDatasetMetadataRequest +from kagglesdk.users.types.users_enums import CollaboratorType def _make_api(): @@ -113,6 +114,78 @@ def test_metadata_update_with_resources_succeeds(self, mock_build): self.assertEqual(call_args.settings.data[0].columns[1].name, "col2") self.assertEqual(call_args.settings.data[0].columns[1].description, "col2 desc") # title mapped to description + @patch.object(KaggleApi, "build_kaggle_client") + def test_metadata_update_with_collaborator_converts_role_to_enum(self, mock_build): + # A single collaborator with a string role should be converted to the CollaboratorType enum. + metadata = { + "title": "New Title", + "collaborators": [{"username": "bob", "role": "writer"}], + } + meta_file = os.path.join(self.temp_dir, "dataset-metadata.json") + with open(meta_file, "w") as f: + json.dump(metadata, f) + + mock_kaggle = MagicMock() + mock_response = MagicMock() + mock_response.errors = [] + mock_kaggle.datasets.dataset_api_client.update_dataset_metadata.return_value = mock_response + mock_build.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_build.return_value.__exit__ = MagicMock(return_value=False) + + self.api.dataset_metadata_update("owner/dataset", self.temp_dir) + + call_args = mock_kaggle.datasets.dataset_api_client.update_dataset_metadata.call_args[0][0] + self.assertEqual(len(call_args.settings.collaborators), 1) + self.assertEqual(call_args.settings.collaborators[0].username, "bob") + self.assertEqual(call_args.settings.collaborators[0].role, CollaboratorType.WRITER) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_metadata_update_with_multiple_collaborators_converts_roles(self, mock_build): + # Multiple collaborators with mixed-case roles should each be converted independently. + metadata = { + "title": "New Title", + "collaborators": [ + {"username": "bob", "role": "writer"}, + {"username": "alice", "role": "reader"}, + {"username": "carol", "role": "ADMIN"}, + ], + } + meta_file = os.path.join(self.temp_dir, "dataset-metadata.json") + with open(meta_file, "w") as f: + json.dump(metadata, f) + + mock_kaggle = MagicMock() + mock_response = MagicMock() + mock_response.errors = [] + mock_kaggle.datasets.dataset_api_client.update_dataset_metadata.return_value = mock_response + mock_build.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_build.return_value.__exit__ = MagicMock(return_value=False) + + self.api.dataset_metadata_update("owner/dataset", self.temp_dir) + + call_args = mock_kaggle.datasets.dataset_api_client.update_dataset_metadata.call_args[0][0] + collaborators = call_args.settings.collaborators + self.assertEqual(len(collaborators), 3) + self.assertEqual(collaborators[0].username, "bob") + self.assertEqual(collaborators[0].role, CollaboratorType.WRITER) + self.assertEqual(collaborators[1].username, "alice") + self.assertEqual(collaborators[1].role, CollaboratorType.READER) + self.assertEqual(collaborators[2].username, "carol") + self.assertEqual(collaborators[2].role, CollaboratorType.ADMIN) + + def test_metadata_update_with_invalid_collaborator_role_fails(self): + # An unrecognized role should raise rather than silently pass a bad value to the SDK. + metadata = { + "title": "New Title", + "collaborators": [{"username": "bob", "role": "not-a-role"}], + } + meta_file = os.path.join(self.temp_dir, "dataset-metadata.json") + with open(meta_file, "w") as f: + json.dump(metadata, f) + + with self.assertRaises(KeyError): + self.api.dataset_metadata_update("owner/dataset", self.temp_dir) + def test_process_column_has_description_uses_description(self): col_dict = {"name": "col", "description": "desc", "type": "string"} processed = self.api.process_column(col_dict) diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py new file mode 100644 index 00000000..329cb67f --- /dev/null +++ b/tests/unit/test_search.py @@ -0,0 +1,325 @@ +# 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 six 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, + ], + ) + + 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_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_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") + + +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() From f664438a7715b8dee683cf7bf40a2478a08eaa97 Mon Sep 17 00:00:00 2001 From: sridipbasu Date: Thu, 23 Jul 2026 18:50:15 +0530 Subject: [PATCH 2/3] fixed as requested --- docs/search.md | 4 +- skills/references/search.md | 4 +- src/kaggle/api/kaggle_api_extended.py | 69 +++++++++++++-------------- src/kaggle/cli.py | 2 +- tests/unit/test_search.py | 22 +++++++++ 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/docs/search.md b/docs/search.md index 2d737f72..1d72d534 100644 --- a/docs/search.md +++ b/docs/search.md @@ -27,7 +27,7 @@ kaggle search "" [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`. 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`, `published`, `updated`, `comments`, `viewed`. +* `--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. @@ -60,7 +60,7 @@ notebooks, and models; the bare slug for competitions and users. 4. Search your own content, most recently updated first: ```bash - kaggle search "baseline" --mine --sort-by updated + kaggle search "baseline" --mine --sort-by dateUpdated ``` 5. Get machine-readable output: diff --git a/skills/references/search.md b/skills/references/search.md index b5ae3f13..55d3fb33 100644 --- a/skills/references/search.md +++ b/skills/references/search.md @@ -37,7 +37,7 @@ kaggle search "" [options] - `query`: Term(s) to search for (required). - `-t, --type `: Comma-separated content types to restrict to. Valid: `competition`, `dataset`, `notebook`, `model`, `user`, `discussion`. 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`, `published`, `updated`, `comments`, `viewed`. +- `--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. @@ -49,7 +49,7 @@ kaggle search "" [options] kaggle search "weather" kaggle search "diffusion" --type dataset,model kaggle search "andrew ng" --type user -kaggle search "baseline" --mine --sort-by updated +kaggle search "baseline" --mine --sort-by dateUpdated kaggle search "titanic" --format json ``` diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 441ac34d..7edd5cd3 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -959,7 +959,15 @@ class KaggleApi: # Search valid types valid_search_document_types = ["competition", "dataset", "notebook", "model", "user", "discussion"] - valid_search_sort_by = ["relevance", "hotness", "votes", "published", "updated", "comments", "viewed"] + valid_search_sort_by = [ + "relevance", + "hotness", + "votes", + "dateCreated", + "dateUpdated", + "totalComments", + "lastViewed", + ] # Competitions valid types valid_competition_groups = ["general", "entered", "community", "hosted", "unlaunched", "unlaunched_community"] @@ -6021,26 +6029,23 @@ def _resolve_search_document_types(self, document_types: Optional[list[str]]) -> """ if not document_types: return [] - mapping = { - "competition": DocumentType.COMPETITION, - "dataset": DocumentType.DATASET, - "notebook": DocumentType.KERNEL, - "kernel": DocumentType.KERNEL, - "model": DocumentType.MODEL, - "user": DocumentType.USER, - "discussion": DocumentType.TOPIC, - } + # 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 mapping: + 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 = mapping[key] + value = self.lookup_enum(DocumentType, DocumentType.DOCUMENT_TYPE_UNSPECIFIED, aliases.get(key, key)) if value not in resolved: resolved.append(value) return resolved @@ -6054,23 +6059,21 @@ def _resolve_search_sort_by(self, sort_by: Optional[str]) -> ListSearchContentOr Returns: ListSearchContentOrderBy: The resolved enum value. """ - mapping = { - "relevance": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_UNSPECIFIED, - "hotness": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_HOTNESS, - "votes": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_VOTES, - "published": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_DATE_CREATED, - "updated": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_DATE_UPDATED, - "comments": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_TOTAL_COMMENTS, - "viewed": ListSearchContentOrderBy.LIST_SEARCH_CONTENT_ORDER_BY_LAST_VIEWED, - } if not sort_by: - return mapping["relevance"] - key = sort_by.strip().lower() - if key not in mapping: + 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)) ) - return mapping[key] + 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. @@ -6136,17 +6139,11 @@ def _search_document_type_label(self, document_type: DocumentType) -> str: Returns: str: A lowercase label such as "notebook" or "discussion". """ - labels = { - DocumentType.COMPETITION: "competition", - DocumentType.DATASET: "dataset", - DocumentType.KERNEL: "notebook", - DocumentType.MODEL: "model", - DocumentType.USER: "user", - DocumentType.TOPIC: "discussion", - DocumentType.COMMENT: "comment", - } - if document_type in labels: - return labels[document_type] + # 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): diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index d5e10534..42a4d453 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -2651,7 +2651,7 @@ class Help(object): ) param_search_mine = "Restrict the search to your own content" param_search_sort_by = ( - "Sort order. One of: relevance (default), hotness, votes, published, updated, comments, viewed" + "Sort order. One of: relevance (default), hotness, votes, dateCreated, dateUpdated, totalComments, lastViewed" ) # Forums params diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py index 329cb67f..33fae66f 100644 --- a/tests/unit/test_search.py +++ b/tests/unit/test_search.py @@ -130,6 +130,12 @@ def test_search_discussion_maps_to_topic(self): self.api.search("x", document_types=["discussion"]) self.assertEqual(list(captured["request"].filters.document_types), [DocumentType.TOPIC]) + 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) @@ -143,6 +149,22 @@ def test_search_sort_by_votes(self): 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") From c978a30f71fbdbdadb61be7df88b179272301153 Mon Sep 17 00:00:00 2001 From: sridipbasu Date: Fri, 24 Jul 2026 22:52:30 +0530 Subject: [PATCH 3/3] benchmarks add --- docs/search.md | 6 +++--- skills/references/search.md | 8 ++++---- src/kaggle/api/kaggle_api_extended.py | 10 ++++++++-- src/kaggle/cli.py | 2 +- tests/unit/test_search.py | 17 ++++++++++++++++- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/search.md b/docs/search.md index 1d72d534..a6f37721 100644 --- a/docs/search.md +++ b/docs/search.md @@ -11,8 +11,8 @@ cross-content equivalent of the per-command `-s/--search` flags (such as 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) — not every backend -document type (it excludes types the CLI cannot render usefully, such as +(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. @@ -25,7 +25,7 @@ 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`. If omitted, all of these CLI-supported types are searched. +* `-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). diff --git a/skills/references/search.md b/skills/references/search.md index 55d3fb33..bdc2ded8 100644 --- a/skills/references/search.md +++ b/skills/references/search.md @@ -22,9 +22,9 @@ Unlike the per-command `-s/--search` flags (for example 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) — 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. +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:** @@ -35,7 +35,7 @@ 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`. Omit to search all of these CLI-supported types. +- `-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). diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 55cc78f9..6aa93980 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -966,7 +966,7 @@ class KaggleApi: ] # Search valid types - valid_search_document_types = ["competition", "dataset", "notebook", "model", "user", "discussion"] + valid_search_document_types = ["competition", "dataset", "notebook", "model", "user", "discussion", "benchmark"] valid_search_sort_by = [ "relevance", "hotness", @@ -6585,7 +6585,13 @@ def _search_document_ref(self, document: ListEntitiesDocument, owner: Optional[s str: The reference string. """ slug = document.slug or "" - if document.document_type in (DocumentType.DATASET, DocumentType.KERNEL, DocumentType.MODEL) and owner and slug: + 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 diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 9c2de5f4..3b83a1ea 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -2768,7 +2768,7 @@ class Help(object): 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.\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." ) diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py index 33fae66f..d3db7938 100644 --- a/tests/unit/test_search.py +++ b/tests/unit/test_search.py @@ -85,7 +85,7 @@ def test_search_builds_request(self): ) def test_search_defaults_to_cli_supported_types(self): - # When no --type is given, the CLI restricts the pool to the six supported + # 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()) @@ -99,6 +99,7 @@ def test_search_defaults_to_cli_supported_types(self): DocumentType.MODEL, DocumentType.USER, DocumentType.TOPIC, + DocumentType.BENCHMARK, ], ) @@ -130,6 +131,11 @@ def test_search_discussion_maps_to_topic(self): 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. @@ -252,6 +258,15 @@ def test_discussion_falls_back_to_id(self): 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."""