From 7bfb5a13d7263995a44b45adeae88e4664f2941a Mon Sep 17 00:00:00 2001 From: mooneyp Date: Tue, 5 May 2026 14:47:14 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Add=20hackathons=20CLI=20and=20MCP=E2=86=94?= =?UTF-8?q?CLI=20parity=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the new hackathon MCP tools (overview, write-ups list/download, resolve-links) with `kaggle hackathons` (alias `h`) so CLI users have parity with the MCP surface. Adds a parity check script + CI workflow so future MCP tools can't ship without either a matching CLI command or an explicit, reasoned skip — closing the drift gap that motivated this work. Co-authored-by: kaggle-agent --- .github/workflows/mcp-cli-parity.yaml | 44 ++++ CHANGELOG.md | 2 + skills/SKILL.md | 3 + skills/references/hackathons.md | 87 +++++++ src/kaggle/api/kaggle_api_extended.py | 279 ++++++++++++++++++++++ src/kaggle/cli.py | 120 ++++++++++ src/kaggle/test/test_hackathons_cli.py | 308 ++++++++++++++++++++++++ tools/README.md | 74 ++++++ tools/check_mcp_cli_parity.py | 309 +++++++++++++++++++++++++ tools/mcp_cli_mapping.yaml | 95 ++++++++ 10 files changed, 1321 insertions(+) create mode 100644 .github/workflows/mcp-cli-parity.yaml create mode 100644 skills/references/hackathons.md create mode 100644 src/kaggle/test/test_hackathons_cli.py create mode 100644 tools/README.md create mode 100644 tools/check_mcp_cli_parity.py create mode 100644 tools/mcp_cli_mapping.yaml diff --git a/.github/workflows/mcp-cli-parity.yaml b/.github/workflows/mcp-cli-parity.yaml new file mode 100644 index 00000000..45d33e89 --- /dev/null +++ b/.github/workflows/mcp-cli-parity.yaml @@ -0,0 +1,44 @@ +name: MCP ↔ CLI parity + +on: + pull_request: + paths: + - "src/kaggle/cli.py" + - "tools/check_mcp_cli_parity.py" + - "tools/mcp_cli_mapping.yaml" + - ".github/workflows/mcp-cli-parity.yaml" + push: + branches: [main] + +jobs: + parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install kaggle CLI (editable) + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run MCP ↔ CLI parity check + env: + # Read-only token for the private Kaggle/kaggleazure repo. + # Configure under: Settings → Secrets and variables → Actions. + GITHUB_TOKEN: ${{ secrets.KAGGLEAZURE_READ_TOKEN }} + run: | + python tools/check_mcp_cli_parity.py \ + --mcp-client-url https://raw.githubusercontent.com/Kaggle/kaggleazure/ci/Kaggle.Sdk/mcp/McpClient.cs \ + | tee parity-report.md + + - name: Upload parity report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mcp-cli-parity-report + path: parity-report.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3c5d84..77b6f307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Changelog ### Next +* Add `kaggle hackathons` (alias `h`) command group with `get`, `writeups list`, `writeups download`, and `writeups resolve-links` subcommands — CLI parity for the new hackathon MCP tools (`get_hackathon_overview`, `list_hackathon_write_ups`, `download_hackathon_write_ups`, `get_resolved_writeup_links`) +* Add `tools/check_mcp_cli_parity.py` and a CI gate that fails when a new MCP tool ships in `Kaggle.Sdk/mcp/McpClient.cs` without a corresponding CLI command (or an explicit `skip:` entry in `tools/mcp_cli_mapping.yaml`) * feat: add forums commands for browsing Kaggle discussions (#993) * Add competitions topics CLI command (#982) diff --git a/skills/SKILL.md b/skills/SKILL.md index 42f0ea15..6d9759b8 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -34,6 +34,8 @@ kaggle ├── datasets (alias: d) — Create, download, and manage datasets ├── kernels (alias: k) — Run and manage notebooks/scripts ├── models (alias: m) — Upload and version models +├── forums (alias: fo) — Browse Kaggle discussion forums +├── hackathons (alias: h) — Browse hackathon overviews and write-ups └── benchmarks (alias: b) — Benchmark LLM models on tasks ``` @@ -42,3 +44,4 @@ kaggle For detailed reference on specific command groups, see: - [Benchmarks](references/benchmarks.md) — Push tasks, run against LLM models, check status, download results +- [Hackathons](references/hackathons.md) — Get hackathon overviews, list write-ups, download CSV exports, resolve write-up links diff --git a/skills/references/hackathons.md b/skills/references/hackathons.md new file mode 100644 index 00000000..bacd2d50 --- /dev/null +++ b/skills/references/hackathons.md @@ -0,0 +1,87 @@ +# Kaggle Hackathons CLI Reference + +This reference covers `kaggle hackathons …` (alias `kaggle h …`), which +provides CLI access to the hackathon-specific endpoints exposed by Kaggle's +MCP server. + +## Prerequisites + +- Python 3.11+ +- `kaggle` CLI installed (`pip install kaggle` or `pip install -e .` from source) +- Valid Kaggle credentials: `KAGGLE_API_TOKEN` env var, `~/.kaggle/access_token`, or OAuth via `kaggle auth login` + +## Command Hierarchy + +``` +kaggle hackathons (alias: kaggle h) +├── get — Show overview pages for a hackathon +└── writeups + ├── list — List submitted write-ups + ├── download [-p ] — Download all write-ups as CSV + └── resolve-links — Resolve datasets/notebooks/links inside a write-up +``` + +Each command maps 1:1 to an MCP tool exposed by Kaggle's `McpClient`. + +| CLI | MCP tool | +| ------------------------------------------------------ | ------------------------------ | +| `kaggle h get ` | `get_hackathon_overview` | +| `kaggle h writeups list ` | `list_hackathon_write_ups` | +| `kaggle h writeups download [-p ]` | `download_hackathon_write_ups` | +| `kaggle h writeups resolve-links ` | `get_resolved_writeup_links` | + +## Examples + +### Show the overview of a hackathon + +```bash +kaggle hackathons get my-hackathon +``` + +By default the overview pages are printed as plain text (HTML stripped). +Use `-v / --csv` to emit a CSV table of `name`-keyed pages instead. + +### List all write-up submissions + +```bash +kaggle h writeups list my-hackathon +``` + +Lists each submitted write-up with its `id`, owning team, title, URL, +competition id, and `template` flag. Add `-v` for CSV output, `-q` to +suppress the trailing total/next-page-token lines. + +### Download a CSV of all write-ups + +```bash +# Default destination: ./-writeups.csv +kaggle h writeups download my-hackathon + +# Custom destination file: +kaggle h writeups download my-hackathon -p out/writeups.csv + +# Custom destination directory (filename appended automatically): +kaggle h writeups download my-hackathon -p exports/ +``` + +This command calls the host-only `ExportHackathonWriteUpsCsv` RPC and +writes the CSV body to disk. It only works after the hackathon has +closed, and only for the competition's host(s) and judges. + +### Resolve the links inside a write-up + +```bash +kaggle h writeups resolve-links 12345 +``` + +Returns metadata (download URLs, file summaries, thumbnails) for every +link embedded inside the write-up — datasets, notebooks, YouTube videos, +external pages, etc. Use this to follow a write-up's references without +manually scraping the rendered page. + +## Output formats + +All `hackathons` commands default to a human-friendly table for list output +and plain text for the overview. Pass `-v / --csv` to switch list outputs +to CSV. The `download` subcommand always writes CSV (that's the whole +point) and prints a one-line confirmation unless `-q / --quiet` is set. diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 51e2f9c7..3b161e87 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -2639,6 +2639,285 @@ def entity_topic_show_cli( quiet=quiet, ) + # ── Hackathons ─────────────────────────────────────────────────────── + + hackathon_writeup_fields = [ + "id", + "team", + "title", + "url", + "competitionId", + "template", + ] + hackathon_overview_page_fields = ["name"] + hackathon_writeup_link_fields = ["url", "type", "title"] + + def _require_sdk_method(self, client, method_name: str, hint: str): + """Return ``client.method_name`` or raise a clear error. + + The hackathon endpoints landed in kagglesdk after this CLI release + was cut, so older SDK installs may not have them yet. + """ + method = getattr(client, method_name, None) + if method is None: + raise ValueError( + f"This command requires a newer kagglesdk that exposes " + f"`{hint}`. Please upgrade kagglesdk." + ) + return method + + def _build_hackathon_overview_request(self, competition: str): + try: + from kagglesdk.competitions.types.competition_api_service import ( # type: ignore + ApiGetHackathonOverviewRequest, + ) + except ImportError as exc: + raise ValueError( + "kagglesdk is missing ApiGetHackathonOverviewRequest; please upgrade kagglesdk." + ) from exc + request = ApiGetHackathonOverviewRequest() + request.competition_name = competition + return request + + def _build_list_hackathon_writeups_request(self, competition: str): + try: + from kagglesdk.competitions.types.competition_api_service import ( # type: ignore + ApiListHackathonWriteUpsRequest, + ) + except ImportError as exc: + raise ValueError( + "kagglesdk is missing ApiListHackathonWriteUpsRequest; please upgrade kagglesdk." + ) from exc + request = ApiListHackathonWriteUpsRequest() + request.competition_name = competition + return request + + def _build_export_hackathon_writeups_csv_request(self, competition: str): + try: + from kagglesdk.competitions.types.hackathon_service import ( # type: ignore + ExportHackathonWriteUpsCsvRequest, + ) + except ImportError as exc: + raise ValueError( + "kagglesdk is missing ExportHackathonWriteUpsCsvRequest; please upgrade kagglesdk." + ) from exc + request = ExportHackathonWriteUpsCsvRequest() + request.competition_name = competition + return request + + def _build_get_resolved_writeup_links_request(self, write_up_id: int): + try: + from kagglesdk.discussions.types.writeups_service import ( # type: ignore + GetResolvedWriteUpLinksRequest, + ) + except ImportError as exc: + raise ValueError( + "kagglesdk is missing GetResolvedWriteUpLinksRequest; please upgrade kagglesdk." + ) from exc + request = GetResolvedWriteUpLinksRequest() + request.write_up_id = write_up_id + return request + + def hackathon_get_overview(self, competition: str): + """Get the overview page content for a hackathon competition. + + Mirrors the ``get_hackathon_overview`` MCP tool. + """ + if not competition: + raise ValueError("No competition specified") + with self.build_kaggle_client() as kaggle: + request = self._build_hackathon_overview_request(competition) + client = kaggle.competitions.competition_api_client + method = self._require_sdk_method( + client, + "get_hackathon_overview", + "CompetitionApiClient.get_hackathon_overview", + ) + return method(request) + + def hackathon_get_overview_cli(self, competition=None, csv_display=False, quiet=False): + """CLI wrapper for ``kaggle hackathons get ``.""" + if competition is None: + raise ValueError("No competition specified") + response = self.hackathon_get_overview(competition) + pages = getattr(response, "pages", None) or [] + if not pages: + print("No hackathon overview pages found") + return + if csv_display: + self.print_csv(pages, self.hackathon_overview_page_fields) + else: + for page in pages: + name = getattr(page, "name", "") + content = getattr(page, "content", "") + if name: + print(f"## {name}") + if content: + cleaned = bleach.clean(content, tags=[], strip=True).strip() + print(cleaned) + print() + + def hackathon_list_writeups(self, competition: str): + """List hackathon write-up submissions for a competition. + + Mirrors the ``list_hackathon_write_ups`` MCP tool. + """ + if not competition: + raise ValueError("No competition specified") + with self.build_kaggle_client() as kaggle: + request = self._build_list_hackathon_writeups_request(competition) + client = kaggle.competitions.competition_api_client + method = self._require_sdk_method( + client, + "list_hackathon_write_ups", + "CompetitionApiClient.list_hackathon_write_ups", + ) + return method(request) + + def hackathon_list_writeups_cli(self, competition=None, csv_display=False, quiet=False): + """CLI wrapper for ``kaggle hackathons writeups list ``.""" + if competition is None: + raise ValueError("No competition specified") + response = self.hackathon_list_writeups(competition) + writeups = getattr(response, "hackathon_write_ups", None) or [] + flat = [self._flatten_hackathon_writeup(w) for w in writeups] + if not flat: + print("No hackathon write-ups found") + return + if csv_display: + self.print_csv(flat, self.hackathon_writeup_fields) + else: + self.print_table(flat, self.hackathon_writeup_fields) + if not quiet: + total = getattr(response, "total_count", None) + if total: + print(f"Total: {total}") + next_page = getattr(response, "next_page_token", "") or "" + if next_page: + print(f"Next page token: {next_page}") + + def _flatten_hackathon_writeup(self, w): + """Convert a HackathonWriteUp proto into a flat object for printing.""" + + class _Flat: + pass + + flat = _Flat() + flat.id = getattr(w, "id", None) + team = getattr(w, "team", None) + flat.team = getattr(team, "name", None) or getattr(team, "team_name", None) if team else None + write_up = getattr(w, "write_up", None) + flat.title = getattr(write_up, "title", None) if write_up else None + flat.url = getattr(write_up, "url", None) if write_up else None + flat.competition_id = getattr(w, "competition_id", None) + flat.template = getattr(w, "template", False) + return flat + + def hackathon_download_writeups( + self, competition: str, path: Optional[str] = None, quiet: bool = False + ) -> str: + """Download the CSV of hackathon write-ups for a competition. + + Mirrors the ``download_hackathon_write_ups`` MCP tool. Writes the CSV + body to ```` (or ``./-writeups.csv`` by default) + and returns the absolute path to the saved file. + """ + if not competition: + raise ValueError("No competition specified") + with self.build_kaggle_client() as kaggle: + request = self._build_export_hackathon_writeups_csv_request(competition) + client = kaggle.competitions.hackathon_client + method = self._require_sdk_method( + client, + "export_hackathon_write_ups_csv", + "HackathonClient.export_hackathon_write_ups_csv", + ) + response = method(request) + + csv_body = ( + getattr(response, "csv", None) + or getattr(response, "csv_content", None) + or getattr(response, "content", None) + or "" + ) + if not csv_body: + raise ValueError("Empty CSV response from server") + + if path is None: + outfile = os.path.join(os.getcwd(), f"{competition}-writeups.csv") + elif os.path.isdir(path): + outfile = os.path.join(path, f"{competition}-writeups.csv") + else: + outfile = path + + outdir = os.path.dirname(outfile) + if outdir and not os.path.exists(outdir): + os.makedirs(outdir, exist_ok=True) + + if isinstance(csv_body, bytes): + with open(outfile, "wb") as f: + f.write(csv_body) + else: + with open(outfile, "w", newline="", encoding="utf-8") as f: + f.write(csv_body) + + if not quiet: + print(f"Downloaded hackathon write-ups CSV to {outfile}") + return outfile + + def hackathon_download_writeups_cli( + self, competition=None, path=None, quiet=False + ): + """CLI wrapper for ``kaggle hackathons writeups download ``.""" + if competition is None: + raise ValueError("No competition specified") + self.hackathon_download_writeups(competition, path=path, quiet=quiet) + + def hackathon_resolve_writeup_links(self, write_up_id: int): + """Resolve all links inside a write-up. + + Mirrors the ``get_resolved_writeup_links`` MCP tool. + """ + if write_up_id is None: + raise ValueError("No write_up_id specified") + with self.build_kaggle_client() as kaggle: + request = self._build_get_resolved_writeup_links_request(int(write_up_id)) + client = getattr(kaggle.discussions, "writeups_client", None) or getattr( + kaggle.discussions, "write_ups_client", None + ) + if client is None: + raise ValueError( + "kagglesdk is missing the WriteUpsClient; please upgrade kagglesdk." + ) + method = self._require_sdk_method( + client, + "get_resolved_writeup_links", + "WriteUpsClient.get_resolved_writeup_links", + ) + return method(request) + + def hackathon_resolve_writeup_links_cli(self, writeup_id=None, csv_display=False, quiet=False): + """CLI wrapper for ``kaggle hackathons writeups resolve-links ``.""" + if writeup_id is None: + raise ValueError("No writeup_id specified") + try: + wid = int(writeup_id) + except (TypeError, ValueError): + raise ValueError(f"writeup_id must be an integer (got {writeup_id!r})") + response = self.hackathon_resolve_writeup_links(wid) + links = ( + getattr(response, "resolved_links", None) + or getattr(response, "links", None) + or [] + ) + if not links: + print("No links found") + return + if csv_display: + self.print_csv(links, self.hackathon_writeup_link_fields) + else: + self.print_table(links, self.hackathon_writeup_link_fields) + def dataset_list( self, sort_by: Optional[str] = None, diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index cb34a1c7..60dcd8d7 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -55,6 +55,7 @@ def main() -> None: parse_models(subparsers) parse_files(subparsers) parse_forums(subparsers) + parse_hackathons(subparsers) parse_benchmarks(subparsers) parse_config(subparsers) parse_auth(subparsers) @@ -1610,6 +1611,97 @@ def parse_forums(subparsers) -> None: parser_forums_topics_show.set_defaults(func=api.forums_topic_show_cli) +def parse_hackathons(subparsers) -> None: + parser_hackathons = subparsers.add_parser( + "hackathons", formatter_class=argparse.RawTextHelpFormatter, help=Help.group_hackathons, aliases=["h"] + ) + subparsers_hackathons = parser_hackathons.add_subparsers(title="commands", dest="command") + subparsers_hackathons.required = True + subparsers_hackathons.choices = Help.hackathons_choices + + # hackathons get + parser_get = subparsers_hackathons.add_parser( + "get", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_hackathons_get, + ) + parser_get_optional = parser_get._action_groups.pop() + parser_get_required = parser_get.add_argument_group("required arguments") + parser_get_required.add_argument("competition", help=Help.param_competition_nonempty) + parser_get_optional.add_argument( + "-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv + ) + parser_get_optional.add_argument("-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet) + parser_get._action_groups.append(parser_get_optional) + parser_get.set_defaults(func=api.hackathon_get_overview_cli) + + # hackathons writeups ... + parser_writeups = subparsers_hackathons.add_parser( + "writeups", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_hackathons_writeups, + ) + subparsers_writeups = parser_writeups.add_subparsers(title="commands", dest="command") + subparsers_writeups.required = True + subparsers_writeups.choices = Help.hackathons_writeups_choices + + # hackathons writeups list + parser_writeups_list = subparsers_writeups.add_parser( + "list", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_hackathons_writeups_list, + ) + parser_writeups_list_optional = parser_writeups_list._action_groups.pop() + parser_writeups_list_required = parser_writeups_list.add_argument_group("required arguments") + parser_writeups_list_required.add_argument("competition", help=Help.param_competition_nonempty) + parser_writeups_list_optional.add_argument( + "-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv + ) + parser_writeups_list_optional.add_argument( + "-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet + ) + parser_writeups_list._action_groups.append(parser_writeups_list_optional) + parser_writeups_list.set_defaults(func=api.hackathon_list_writeups_cli) + + # hackathons writeups download [-p ] + parser_writeups_download = subparsers_writeups.add_parser( + "download", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_hackathons_writeups_download, + ) + parser_writeups_download_optional = parser_writeups_download._action_groups.pop() + parser_writeups_download_required = parser_writeups_download.add_argument_group("required arguments") + parser_writeups_download_required.add_argument("competition", help=Help.param_competition_nonempty) + parser_writeups_download_optional.add_argument( + "-p", "--path", dest="path", required=False, help=Help.param_hackathons_writeups_download_path + ) + parser_writeups_download_optional.add_argument( + "-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet + ) + parser_writeups_download._action_groups.append(parser_writeups_download_optional) + parser_writeups_download.set_defaults(func=api.hackathon_download_writeups_cli) + + # hackathons writeups resolve-links + parser_writeups_resolve = subparsers_writeups.add_parser( + "resolve-links", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_hackathons_writeups_resolve_links, + ) + parser_writeups_resolve_optional = parser_writeups_resolve._action_groups.pop() + parser_writeups_resolve_required = parser_writeups_resolve.add_argument_group("required arguments") + parser_writeups_resolve_required.add_argument( + "writeup_id", help=Help.param_hackathons_writeup_id + ) + parser_writeups_resolve_optional.add_argument( + "-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv + ) + parser_writeups_resolve_optional.add_argument( + "-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet + ) + parser_writeups_resolve._action_groups.append(parser_writeups_resolve_optional) + parser_writeups_resolve.set_defaults(func=api.hackathon_resolve_writeup_links_cli) + + class Help(object): kaggle_choices = [ "competitions", @@ -1624,6 +1716,8 @@ class Help(object): "f", "forums", "fo", + "hackathons", + "h", "benchmarks", "b", "config", @@ -1676,6 +1770,8 @@ class Help(object): benchmarks_tasks_choices = ["push", "run", "list", "status", "download", "models", "delete"] forums_choices = ["list", "topics"] forums_topics_choices = ["show"] + hackathons_choices = ["get", "writeups"] + hackathons_writeups_choices = ["list", "download", "resolve-links"] entity_topics_choices = ["show"] config_choices = ["view", "set", "unset"] auth_choices = ["login", "print-access-token", "revoke"] @@ -1700,6 +1796,10 @@ class Help(object): + ", ".join(forums_choices) + "}\nforums topics {" + ", ".join(forums_topics_choices) + + "}\nhackathons {" + + ", ".join(hackathons_choices) + + "}\nhackathons writeups {" + + ", ".join(hackathons_writeups_choices) + "}\nbenchmarks {" + ", ".join(benchmarks_choices) + "}\nbenchmarks topics {show}" @@ -1716,6 +1816,7 @@ class Help(object): group_model_instances = "Commands related to Kaggle model variations" group_model_instance_versions = "Commands related to Kaggle model variations versions" group_forums = "Commands related to Kaggle discussion forums" + group_hackathons = "Commands related to Kaggle hackathons" group_files = "Commands related files" group_benchmarks = "Commands related to Kaggle benchmarks" group_benchmarks_tasks = "Commands related to benchmark tasks" @@ -1744,6 +1845,17 @@ class Help(object): command_forums_topics = "List topics in a forum" command_forums_topics_show = "Display a topic with all its comments in tree form" + # Hackathons commands + command_hackathons_get = "Get the overview page content for a hackathon competition" + command_hackathons_writeups = "Manage hackathon write-up submissions" + command_hackathons_writeups_list = "List hackathon write-up submissions for a competition" + command_hackathons_writeups_download = ( + "Download a CSV with all submitted write-ups for a closed hackathon competition" + ) + command_hackathons_writeups_resolve_links = ( + "Resolve all links inside a hackathon write-up (download URLs, summaries, metadata)" + ) + # Datasets commands command_datasets_list = "List available datasets" command_datasets_topics = "List discussion topics for a dataset" @@ -1837,6 +1949,14 @@ class Help(object): param_search = "Term(s) to search for" param_mine = "Display only my items" + # Hackathons params + param_hackathons_writeups_download_path = ( + "Path to write the downloaded CSV to.\n" + "If omitted, defaults to ./-writeups.csv in the current working directory.\n" + "If the path is an existing directory, the CSV is written to /-writeups.csv." + ) + param_hackathons_writeup_id = "ID of a hackathon write-up (integer)" + # Forums params param_forum = ( "Forum slug (e.g. 'getting-started', 'product-feedback').\n" 'Use "kaggle forums" to list available forums.' diff --git a/src/kaggle/test/test_hackathons_cli.py b/src/kaggle/test/test_hackathons_cli.py new file mode 100644 index 00000000..e21d6c38 --- /dev/null +++ b/src/kaggle/test/test_hackathons_cli.py @@ -0,0 +1,308 @@ +"""Tests for ``kaggle hackathons`` CLI commands. + +The hackathon endpoints (``get_hackathon_overview``, +``list_hackathon_write_ups``, ``export_hackathon_write_ups_csv``, +``get_resolved_writeup_links``) may not yet exist on the installed +``kagglesdk``. The CLI wrappers build their request objects via lazy +imports and look up the SDK methods with ``getattr``, so the tests here +mock ``KaggleApi.build_kaggle_client`` and the lazy ``_build_*`` request +helpers — no SDK methods need to actually exist for the tests to run. +""" + +import argparse +from unittest.mock import MagicMock, patch + +import pytest + +from kaggle.api.kaggle_api_extended import KaggleApi +from kaggle import cli as kaggle_cli + + +# ---- Fixtures & helpers ---- + + +@pytest.fixture +def api(): + a = KaggleApi() + a.authenticate = MagicMock() + mock_client = MagicMock() + a.build_kaggle_client = MagicMock() + a.build_kaggle_client.return_value.__enter__.return_value = mock_client + a._mock_client = mock_client + a._mock_competitions = mock_client.competitions.competition_api_client + a._mock_hackathon = mock_client.competitions.hackathon_client + # The current SDK has no WriteUpsClient — pin one onto the mock so the + # production lookup (`getattr(...)`) finds it. + a._mock_writeups = MagicMock() + mock_client.discussions.writeups_client = a._mock_writeups + # Stub the lazy request builders so the tests don't depend on the SDK + # exposing the new request types. + a._build_hackathon_overview_request = MagicMock(side_effect=lambda c: _Req(c)) + a._build_list_hackathon_writeups_request = MagicMock(side_effect=lambda c: _Req(c)) + a._build_export_hackathon_writeups_csv_request = MagicMock(side_effect=lambda c: _Req(c)) + a._build_get_resolved_writeup_links_request = MagicMock( + side_effect=lambda wid: _Req(write_up_id=wid) + ) + return a + + +class _Req: + def __init__(self, competition_name=None, write_up_id=None): + self.competition_name = competition_name + self.write_up_id = write_up_id + + +def _make_overview_response(pages): + r = MagicMock() + r.pages = pages + return r + + +def _make_page(name, content=""): + p = MagicMock() + p.name = name + p.content = content + return p + + +def _make_writeup(id, title, team_name="The Team", url="https://kaggle.com/w/1", competition_id=42): + w = MagicMock() + w.id = id + team = MagicMock() + team.name = team_name + w.team = team + write_up = MagicMock() + write_up.title = title + write_up.url = url + w.write_up = write_up + w.competition_id = competition_id + w.template = False + return w + + +def _make_writeups_response(writeups, total_count=0, next_page_token=""): + r = MagicMock() + r.hackathon_write_ups = writeups + r.total_count = total_count + r.next_page_token = next_page_token + return r + + +def _make_csv_response(csv_body): + r = MagicMock() + r.csv = csv_body + r.csv_content = None + r.content = None + return r + + +def _make_link(url, type_, title): + link = MagicMock() + link.url = url + link.type = type_ + link.title = title + return link + + +# ---- hackathons get ---- + + +class TestHackathonsGet: + def test_prints_overview_pages(self, api, capsys): + pages = [_make_page("Overview", "Some content"), _make_page("Rules", "Be nice")] + api._mock_competitions.get_hackathon_overview.return_value = _make_overview_response(pages) + + api.hackathon_get_overview_cli("titanic") + out = capsys.readouterr().out + assert "Overview" in out + assert "Rules" in out + assert "Some content" in out + + def test_csv_mode(self, api, capsys): + api._mock_competitions.get_hackathon_overview.return_value = _make_overview_response( + [_make_page("Overview", "x")] + ) + api.hackathon_get_overview_cli("titanic", csv_display=True) + out = capsys.readouterr().out + assert "name" in out.lower() or "Overview" in out + + def test_no_pages(self, api, capsys): + api._mock_competitions.get_hackathon_overview.return_value = _make_overview_response([]) + api.hackathon_get_overview_cli("titanic") + assert "No hackathon overview pages found" in capsys.readouterr().out + + def test_missing_competition(self, api): + with pytest.raises(ValueError, match="No competition specified"): + api.hackathon_get_overview_cli(None) + + def test_missing_sdk_method(self, api): + # Strip the method off the client to force the missing-method path. + del api._mock_competitions.get_hackathon_overview + api._mock_competitions.mock_add_spec(["other_method"]) + with pytest.raises(ValueError, match="newer kagglesdk"): + api.hackathon_get_overview("titanic") + + +# ---- hackathons writeups list ---- + + +class TestHackathonsWriteupsList: + def test_prints_writeups(self, api, capsys): + writeups = [ + _make_writeup(1, "Best Solution"), + _make_writeup(2, "Runner Up", team_name="Team B"), + ] + api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response( + writeups, total_count=2 + ) + api.hackathon_list_writeups_cli("hackathon-2026") + out = capsys.readouterr().out + assert "Best Solution" in out + assert "Runner Up" in out + assert "Total: 2" in out + + def test_no_writeups(self, api, capsys): + api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response([]) + api.hackathon_list_writeups_cli("hackathon-2026") + assert "No hackathon write-ups found" in capsys.readouterr().out + + def test_csv_mode(self, api, capsys): + api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response( + [_make_writeup(1, "Best Solution")], total_count=1 + ) + api.hackathon_list_writeups_cli("hackathon-2026", csv_display=True) + out = capsys.readouterr().out + assert "Best Solution" in out + assert "id" in out + + def test_quiet_suppresses_total(self, api, capsys): + api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response( + [_make_writeup(1, "A")], total_count=1, next_page_token="next" + ) + api.hackathon_list_writeups_cli("c", quiet=True) + out = capsys.readouterr().out + assert "Total" not in out + assert "Next page token" not in out + + def test_missing_competition(self, api): + with pytest.raises(ValueError, match="No competition specified"): + api.hackathon_list_writeups_cli(None) + + +# ---- hackathons writeups download ---- + + +class TestHackathonsWriteupsDownload: + def test_default_path(self, api, tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response( + "id,team,score\n1,Alpha,99\n" + ) + api.hackathon_download_writeups_cli("hackathon-2026") + out = capsys.readouterr().out + expected = tmp_path / "hackathon-2026-writeups.csv" + assert expected.exists() + assert expected.read_text() == "id,team,score\n1,Alpha,99\n" + assert "Downloaded" in out + + def test_explicit_path(self, api, tmp_path): + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response( + "id\n1\n" + ) + outfile = tmp_path / "out.csv" + api.hackathon_download_writeups_cli("hackathon-2026", path=str(outfile), quiet=True) + assert outfile.exists() + assert outfile.read_text() == "id\n1\n" + + def test_directory_path_appends_default_name(self, api, tmp_path): + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response( + "id\n1\n" + ) + api.hackathon_download_writeups_cli("hackathon-2026", path=str(tmp_path), quiet=True) + assert (tmp_path / "hackathon-2026-writeups.csv").exists() + + def test_empty_csv_raises(self, api): + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response("") + with pytest.raises(ValueError, match="Empty CSV"): + api.hackathon_download_writeups_cli("hackathon-2026") + + def test_missing_competition(self, api): + with pytest.raises(ValueError, match="No competition specified"): + api.hackathon_download_writeups_cli(None) + + +# ---- hackathons writeups resolve-links ---- + + +class TestHackathonsWriteupsResolveLinks: + def test_prints_links(self, api, capsys): + resp = MagicMock() + resp.resolved_links = [ + _make_link("https://kaggle.com/datasets/x/y", "DATASET", "x/y"), + _make_link("https://github.com/foo", "EXTERNAL", "foo"), + ] + api._mock_writeups.get_resolved_writeup_links.return_value = resp + api.hackathon_resolve_writeup_links_cli("123") + out = capsys.readouterr().out + assert "https://kaggle.com/datasets/x/y" in out + assert "https://github.com/foo" in out + + def test_no_links(self, api, capsys): + resp = MagicMock() + resp.resolved_links = [] + resp.links = [] + api._mock_writeups.get_resolved_writeup_links.return_value = resp + api.hackathon_resolve_writeup_links_cli("123") + assert "No links found" in capsys.readouterr().out + + def test_non_integer_id(self, api): + with pytest.raises(ValueError, match="must be an integer"): + api.hackathon_resolve_writeup_links_cli("not-a-number") + + def test_missing_id(self, api): + with pytest.raises(ValueError, match="No writeup_id"): + api.hackathon_resolve_writeup_links_cli(None) + + +# ---- argparse wiring ---- + + +class TestCliArgParsing: + """Ensure each `kaggle hackathons …` subcommand parses correctly and + routes to the right API method. + """ + + def _make_parser(self): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + with patch.object(kaggle_cli, "api", MagicMock()): + kaggle_cli.parse_hackathons(subparsers) + return parser + + def test_get_routes(self): + parser = self._make_parser() + args = parser.parse_args(["hackathons", "get", "titanic"]) + assert args.competition == "titanic" + + def test_writeups_list_routes(self): + parser = self._make_parser() + args = parser.parse_args(["hackathons", "writeups", "list", "titanic"]) + assert args.competition == "titanic" + + def test_writeups_download_routes(self): + parser = self._make_parser() + args = parser.parse_args( + ["hackathons", "writeups", "download", "titanic", "-p", "/tmp/out.csv"] + ) + assert args.competition == "titanic" + assert args.path == "/tmp/out.csv" + + def test_writeups_resolve_links_routes(self): + parser = self._make_parser() + args = parser.parse_args(["hackathons", "writeups", "resolve-links", "42"]) + assert args.writeup_id == "42" + + def test_alias_h(self): + parser = self._make_parser() + args = parser.parse_args(["h", "get", "titanic"]) + assert args.competition == "titanic" diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..c5cf15fe --- /dev/null +++ b/tools/README.md @@ -0,0 +1,74 @@ +# tools/ + +Developer tooling that lives outside the shipped `kaggle` package. + +## `check_mcp_cli_parity.py` + +Parity gate that ensures every MCP tool advertised by +`Kaggle.Sdk/mcp/McpClient.cs` (in the private `Kaggle/kaggleazure` repo) has +either a matching `kaggle` CLI command or an explicit "skip" reason. Runs in +CI on every PR; fails loudly when a new MCP tool is added without a CLI +counterpart. + +### How it works + +1. Loads `McpClient.cs` from a local path (`--mcp-client`) or raw URL + (`--mcp-client-url`). The local file wins if it exists. +2. Parses every `[McpServerTool(Name = "")]` annotation. +3. Walks the `kaggle` argparse tree (built from `src/kaggle/cli.py`) and + collects the full set of registered subcommand paths. +4. Loads `tools/mcp_cli_mapping.yaml` and verifies that every MCP tool maps + either to a real CLI command path or a `skip: ` string. +5. Prints a markdown coverage table and exits non-zero if anything is + missing or broken. + +### Local usage + +If you have the `kaggleazure` repo checked out as a sibling directory, the +defaults Just Work: + +```bash +python3 tools/check_mcp_cli_parity.py +``` + +Otherwise, point at a local copy or fetch via URL (the latter requires +`GITHUB_TOKEN` because `Kaggle/kaggleazure` is private): + +```bash +# Explicit local path +python3 tools/check_mcp_cli_parity.py \ + --mcp-client /path/to/Kaggle.Sdk/mcp/McpClient.cs + +# Fetch over HTTPS (needs a token with `repo` scope) +GITHUB_TOKEN=ghp_xxx python3 tools/check_mcp_cli_parity.py \ + --mcp-client-url https://raw.githubusercontent.com/Kaggle/kaggleazure/ci/Kaggle.Sdk/mcp/McpClient.cs +``` + +The script has zero third-party dependencies — only the Python standard +library plus whatever `kaggle.cli` already imports. + +### Adding a new MCP tool + +When `McpClient.cs` gains a new `[McpServerTool(Name = "...")]`, the gate +will fail with a "MISSING" entry until you add a row to +`tools/mcp_cli_mapping.yaml`: + +```yaml +# A real CLI command — must match a registered argparse subcommand chain. +my_new_tool: my-group my-subcommand + +# Or, if there is intentionally no CLI surface, a skip with a reason. +my_new_tool: "skip: server-side handshake, exposed via `kaggle foo bar`" +``` + +Reasons matter: empty `skip:` entries are rejected. Use them to capture +why the gap exists (work-in-progress, browser-only flow, internal RPC, etc.) +so future readers don't have to guess. + +After editing the mapping, re-run the script locally to confirm it exits 0. + +### CI integration + +`.github/workflows/mcp-cli-parity.yaml` runs the script on every PR using +`--mcp-client-url` and `secrets.KAGGLEAZURE_READ_TOKEN` so it doesn't need +the private repo checked out. diff --git a/tools/check_mcp_cli_parity.py b/tools/check_mcp_cli_parity.py new file mode 100644 index 00000000..ecba85e9 --- /dev/null +++ b/tools/check_mcp_cli_parity.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""MCP ↔ CLI parity gate. + +Compares the set of MCP tools advertised by ``Kaggle.Sdk/mcp/McpClient.cs`` +against the set of ``kaggle`` CLI commands wired in ``src/kaggle/cli.py``. + +Each MCP tool must have an entry in ``tools/mcp_cli_mapping.yaml``. The +entry is either a CLI command path (e.g. ``hackathons writeups download``) +that points to a real subparser, or a ``skip: `` string for tools +that intentionally have no CLI surface (auth flows, browser-only RPCs, or +tools we have not yet wrapped). + +Exits non-zero when: + * an MCP tool has no mapping entry (NEW unmapped tool — fail loud), OR + * a mapped tool points to a CLI path that does not exist. + +Always prints a markdown coverage table to stdout. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Dict, Iterable, List, Set, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_MAPPING = REPO_ROOT / "tools" / "mcp_cli_mapping.yaml" +DEFAULT_LOCAL_MCP = REPO_ROOT.parent / "kaggleazure" / "Kaggle.Sdk" / "mcp" / "McpClient.cs" +DEFAULT_REMOTE_MCP = ( + "https://raw.githubusercontent.com/Kaggle/kaggleazure/ci/Kaggle.Sdk/mcp/McpClient.cs" +) + +MCP_TOOL_PATTERN = re.compile(r'McpServerTool\(Name\s*=\s*"([^"]+)"') + + +# --------------------------------------------------------------------------- +# MCP tool extraction +# --------------------------------------------------------------------------- + + +def fetch_mcp_client_source(local_path: str | None, url: str | None) -> str: + """Return the McpClient.cs source. Local file wins if present.""" + if local_path: + p = Path(local_path) + if p.is_file(): + return p.read_text(encoding="utf-8") + if url: + headers = {"User-Agent": "kaggle-cli-parity-check"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("KAGGLEAZURE_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted URL) + return resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + hint = "" + if exc.code in (401, 403, 404) and not token: + hint = ( + "\n Hint: Kaggle/kaggleazure is a private repo. Set GITHUB_TOKEN " + "(with `repo` scope) so the script can fetch the raw file." + ) + raise SystemExit(f"Failed to fetch McpClient.cs from {url}: HTTP {exc.code}{hint}") + except urllib.error.URLError as exc: + raise SystemExit(f"Failed to fetch McpClient.cs from {url}: {exc}") + raise SystemExit( + "No McpClient.cs source available. Pass --mcp-client or --mcp-client-url ." + ) + + +def extract_mcp_tools(source: str) -> List[str]: + """Return the sorted, de-duplicated list of MCP tool names.""" + return sorted(set(MCP_TOOL_PATTERN.findall(source))) + + +# --------------------------------------------------------------------------- +# CLI command extraction +# --------------------------------------------------------------------------- + + +def collect_cli_commands() -> Set[str]: + """Build the kaggle argparse tree and return all command paths. + + A command path is the space-joined chain of subparser names from the + top-level command down to a leaf (e.g. ``"hackathons writeups list"``). + Aliases are emitted alongside the canonical name so that mappings may + reference either form. + """ + # Make ``kaggle`` importable even if this script is run from any cwd. + src_dir = REPO_ROOT / "src" + if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + + # ``kaggle`` calls ``api.authenticate()`` at import time. Pin fake creds + # and stub out the access-token lookup so the import never hits the network. + os.environ.pop("KAGGLE_API_TOKEN", None) + os.environ.setdefault("KAGGLE_USERNAME", "parity-check") + os.environ.setdefault("KAGGLE_KEY", "parity-check") + from unittest.mock import patch + + with patch("kagglesdk.get_access_token_from_env", return_value=(None, None)): + import kaggle.cli as kaggle_cli # noqa: WPS433 — runtime import is intentional + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + for name in ( + "parse_competitions", + "parse_datasets", + "parse_kernels", + "parse_models", + "parse_files", + "parse_forums", + "parse_hackathons", + "parse_benchmarks", + "parse_config", + "parse_auth", + ): + getattr(kaggle_cli, name)(subparsers) + + paths: Set[str] = set() + _collect_subparser_paths(parser, [], paths) + return paths + + +def _collect_subparser_paths(parser, prefix: List[str], paths: Set[str]) -> None: + """Recurse into argparse subparser actions to collect leaf command paths.""" + for action in parser._actions: # noqa: SLF001 — argparse exposes no public API + if not isinstance(action, argparse._SubParsersAction): # noqa: SLF001 + continue + # ``action._name_parser_map`` always holds the {name: parser} dict + # even when ``cli.py`` overwrites ``action.choices`` with a list of + # display strings. + name_parser_map = action._name_parser_map # noqa: SLF001 + for name, sub_parser in name_parser_map.items(): + child_prefix = prefix + [name] + path = " ".join(child_prefix) + paths.add(path) + _collect_subparser_paths(sub_parser, child_prefix, paths) + + +# --------------------------------------------------------------------------- +# Mapping file (mini YAML parser — keeps the script dependency-free) +# --------------------------------------------------------------------------- + + +def load_mapping(path: Path) -> Dict[str, str]: + """Parse a tiny ``key: value`` YAML file. Comments + blank lines OK.""" + if not path.is_file(): + raise SystemExit(f"Mapping file not found: {path}") + mapping: Dict[str, str] = {} + for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + if ":" not in line: + raise SystemExit(f"{path}:{line_no}: expected 'key: value', got: {raw!r}") + key, _, value = line.partition(":") + key = key.strip() + value = value.strip().strip('"').strip("'") + if not key: + raise SystemExit(f"{path}:{line_no}: empty key") + if key in mapping: + raise SystemExit(f"{path}:{line_no}: duplicate key {key!r}") + mapping[key] = value + return mapping + + +# --------------------------------------------------------------------------- +# Validation + reporting +# --------------------------------------------------------------------------- + + +def validate( + mcp_tools: Iterable[str], mapping: Dict[str, str], cli_paths: Set[str] +) -> Tuple[List[str], List[str], List[str], List[str], List[str]]: + """Return (missing, mapped_ok, mapped_bad, skipped, stale_mapping_keys).""" + missing: List[str] = [] + mapped_ok: List[str] = [] + mapped_bad: List[str] = [] + skipped: List[str] = [] + mcp_set = set(mcp_tools) + + for tool in mcp_tools: + entry = mapping.get(tool) + if entry is None: + missing.append(tool) + continue + if entry.startswith("skip:"): + reason = entry[len("skip:") :].strip() + if not reason: + missing.append(tool) + else: + skipped.append(tool) + continue + if entry in cli_paths: + mapped_ok.append(tool) + else: + mapped_bad.append(tool) + + stale = sorted(set(mapping) - mcp_set) + return missing, mapped_ok, mapped_bad, skipped, stale + + +def render_report( + mcp_tools: List[str], + mapping: Dict[str, str], + cli_paths: Set[str], + missing: List[str], + mapped_ok: List[str], + mapped_bad: List[str], + skipped: List[str], + stale: List[str], +) -> str: + total = len(mcp_tools) + lines: List[str] = [] + lines.append("# MCP ↔ CLI Parity Report\n") + lines.append(f"- Total MCP tools: **{total}**") + lines.append(f"- Mapped to CLI command: **{len(mapped_ok)}**") + lines.append(f"- Skipped (with reason): **{len(skipped)}**") + lines.append(f"- Missing mapping entry: **{len(missing)}**") + lines.append(f"- Mapping points at non-existent CLI path: **{len(mapped_bad)}**") + if stale: + lines.append(f"- Stale mapping keys (no matching MCP tool): **{len(stale)}**") + lines.append("") + lines.append("| MCP tool | Status | CLI path / reason |") + lines.append("| --- | --- | --- |") + for tool in mcp_tools: + entry = mapping.get(tool) + if entry is None: + status = "MISSING" + detail = "_add an entry to tools/mcp_cli_mapping.yaml_" + elif entry.startswith("skip:"): + status = "skip" + detail = entry[len("skip:") :].strip() + elif entry in cli_paths: + status = "ok" + detail = f"`kaggle {entry}`" + else: + status = "BROKEN" + detail = f"`{entry}` (not a registered CLI path)" + lines.append(f"| `{tool}` | {status} | {detail} |") + if stale: + lines.append("") + lines.append("## Stale mapping entries") + lines.append("") + lines.append("These mapping keys do not correspond to any MCP tool — " + "remove or update them:") + for key in stale: + lines.append(f"- `{key}` → `{mapping[key]}`") + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(argv: List[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mcp-client", + default=str(DEFAULT_LOCAL_MCP), + help=f"Local path to McpClient.cs (default: {DEFAULT_LOCAL_MCP})", + ) + parser.add_argument( + "--mcp-client-url", + default=DEFAULT_REMOTE_MCP, + help=f"Raw URL of McpClient.cs to fetch when local copy is absent " + f"(default: {DEFAULT_REMOTE_MCP})", + ) + parser.add_argument( + "--mapping", + default=str(DEFAULT_MAPPING), + help=f"Path to mcp_cli_mapping.yaml (default: {DEFAULT_MAPPING})", + ) + args = parser.parse_args(argv) + + source = fetch_mcp_client_source(args.mcp_client, args.mcp_client_url) + mcp_tools = extract_mcp_tools(source) + mapping = load_mapping(Path(args.mapping)) + cli_paths = collect_cli_commands() + + missing, mapped_ok, mapped_bad, skipped, stale = validate(mcp_tools, mapping, cli_paths) + print(render_report(mcp_tools, mapping, cli_paths, missing, mapped_ok, mapped_bad, skipped, stale)) + + failed = bool(missing) or bool(mapped_bad) + if failed: + if missing: + print(f"\nERROR: {len(missing)} MCP tool(s) without a mapping entry:", file=sys.stderr) + for tool in missing: + print(f" - {tool}", file=sys.stderr) + if mapped_bad: + print( + f"\nERROR: {len(mapped_bad)} mapping(s) point to non-existent CLI paths:", + file=sys.stderr, + ) + for tool in mapped_bad: + print(f" - {tool}: {mapping[tool]!r}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/mcp_cli_mapping.yaml b/tools/mcp_cli_mapping.yaml new file mode 100644 index 00000000..32acd92c --- /dev/null +++ b/tools/mcp_cli_mapping.yaml @@ -0,0 +1,95 @@ +# MCP tool ↔ kaggle CLI command mapping. +# +# Each MCP tool advertised by Kaggle.Sdk/mcp/McpClient.cs MUST appear here. +# The value is either: +# - a kaggle CLI command path (e.g. "hackathons writeups download"), which +# must match a registered argparse subcommand chain, OR +# - "skip: " — skip the parity check for tools that have no CLI +# surface (browser-only flows, internal RPCs, or work-in-progress). +# +# Adding a new MCP tool? `tools/check_mcp_cli_parity.py` will fail with +# "MISSING" until you add a row here. See `tools/README.md` for details. + +# ── auth / browser-only ───────────────────────────────────────────────── +authorize: "skip: browser-based OAuth, exposed via `kaggle auth login`" + +# ── competitions ──────────────────────────────────────────────────────── +search_competitions: competitions list +get_competition: "skip: not yet implemented (TODO)" +list_competition_pages: competitions pages +list_competition_data_files: competitions files +list_competition_data_tree_files: "skip: not yet implemented (TODO)" +get_competition_data_files_summary: "skip: not yet implemented (TODO)" +download_competition_data_file: competitions download +download_competition_data_files: competitions download +download_competition_leaderboard: competitions leaderboard +get_competition_leaderboard: competitions leaderboard +get_competition_submission: competitions submissions +search_competition_submissions: competitions submissions +submit_to_competition: competitions submit +create_code_competition_submission: competitions submit +start_competition_submission_upload: "skip: internal upload-handshake RPC, exposed transparently via `kaggle competitions submit`" +list_submission_episodes: competitions episodes +get_episode_replay: competitions replay +get_episode_agent_logs: competitions logs + +# ── datasets ──────────────────────────────────────────────────────────── +search_datasets: datasets list +get_dataset_info: "skip: not yet implemented (TODO)" +get_dataset_metadata: datasets metadata +update_dataset_metadata: datasets metadata +get_dataset_status: datasets status +list_dataset_files: datasets files +list_dataset_tree_files: "skip: not yet implemented (TODO)" +get_dataset_files_summary: "skip: not yet implemented (TODO)" +download_dataset: datasets download +upload_dataset_file: "skip: not yet implemented as a standalone CLI (use `kaggle datasets create` / `kaggle datasets version`)" + +# ── notebooks (kernels) ───────────────────────────────────────────────── +search_notebooks: kernels list +get_notebook_info: kernels get +list_notebook_files: kernels files +save_notebook: kernels push +create_notebook_session: "skip: server-side notebook session, exposed via `kaggle kernels push`" +cancel_notebook_session: "skip: not yet implemented (TODO)" +get_notebook_session_status: kernels status +list_notebook_session_output: kernels output +download_notebook_output: kernels output +download_notebook_output_zip: "skip: not yet implemented (TODO)" + +# ── models / variations ───────────────────────────────────────────────── +list_models: models list +get_model: models get +create_model: models create +update_model: models update +list_model_variations: models variations list +get_model_variation: models variations get +update_model_variation: models variations update +list_model_variation_versions: models variations versions list +list_model_variation_version_files: models variations versions files +download_model_variation_version: models variations versions download + +# ── forums / discussions / writeups ───────────────────────────────────── +list_forums: forums list +get_forum: "skip: no per-forum CLI; `kaggle forums list` enumerates all forums" +list_forum_topics: forums topics +get_forum_topic: forums topics show +get_writeup: "skip: not yet implemented (TODO)" +get_writeup_by_slug: "skip: not yet implemented (TODO)" +get_writeup_by_topic: "skip: not yet implemented (TODO)" +get_resolved_writeup_links: hackathons writeups resolve-links + +# ── hackathons ────────────────────────────────────────────────────────── +get_hackathon_overview: hackathons get +list_hackathon_tracks: "skip: not yet implemented (TODO)" +list_hackathon_write_ups: hackathons writeups list +download_hackathon_write_ups: hackathons writeups download +get_hackathon_write_up: "skip: not yet implemented (TODO)" + +# ── benchmarks ────────────────────────────────────────────────────────── +get_benchmark_leaderboard: "skip: not yet implemented (TODO)" +create_benchmark_task_from_prompt: "skip: not yet implemented (TODO)" + +# ── search / profile (no CLI) ─────────────────────────────────────────── +search_content: "skip: cross-content site search, no CLI equivalent" +get_user_profile: "skip: no CLI equivalent" From 8499c2126d55f88abe0cc01270eb71e11b3de761 Mon Sep 17 00:00:00 2001 From: mooneyp Date: Tue, 5 May 2026 14:58:07 +0000 Subject: [PATCH 2/4] Apply black formatting to fix Cloud Build lint step The kaggle-cli-branch-3-{11,12,13,14} checks were failing because the new files in this PR weren't run through black. Reformatted the four flagged files; no behavior changes. Co-authored-by: kaggle-agent --- src/kaggle/api/kaggle_api_extended.py | 33 +++++++------------------- src/kaggle/cli.py | 8 ++----- src/kaggle/test/test_hackathons_cli.py | 20 ++++------------ tools/check_mcp_cli_parity.py | 14 ++++------- 4 files changed, 19 insertions(+), 56 deletions(-) diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 3b161e87..109b13d8 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -2661,8 +2661,7 @@ def _require_sdk_method(self, client, method_name: str, hint: str): method = getattr(client, method_name, None) if method is None: raise ValueError( - f"This command requires a newer kagglesdk that exposes " - f"`{hint}`. Please upgrade kagglesdk." + f"This command requires a newer kagglesdk that exposes " f"`{hint}`. Please upgrade kagglesdk." ) return method @@ -2672,9 +2671,7 @@ def _build_hackathon_overview_request(self, competition: str): ApiGetHackathonOverviewRequest, ) except ImportError as exc: - raise ValueError( - "kagglesdk is missing ApiGetHackathonOverviewRequest; please upgrade kagglesdk." - ) from exc + raise ValueError("kagglesdk is missing ApiGetHackathonOverviewRequest; please upgrade kagglesdk.") from exc request = ApiGetHackathonOverviewRequest() request.competition_name = competition return request @@ -2685,9 +2682,7 @@ def _build_list_hackathon_writeups_request(self, competition: str): ApiListHackathonWriteUpsRequest, ) except ImportError as exc: - raise ValueError( - "kagglesdk is missing ApiListHackathonWriteUpsRequest; please upgrade kagglesdk." - ) from exc + raise ValueError("kagglesdk is missing ApiListHackathonWriteUpsRequest; please upgrade kagglesdk.") from exc request = ApiListHackathonWriteUpsRequest() request.competition_name = competition return request @@ -2711,9 +2706,7 @@ def _build_get_resolved_writeup_links_request(self, write_up_id: int): GetResolvedWriteUpLinksRequest, ) except ImportError as exc: - raise ValueError( - "kagglesdk is missing GetResolvedWriteUpLinksRequest; please upgrade kagglesdk." - ) from exc + raise ValueError("kagglesdk is missing GetResolvedWriteUpLinksRequest; please upgrade kagglesdk.") from exc request = GetResolvedWriteUpLinksRequest() request.write_up_id = write_up_id return request @@ -2813,9 +2806,7 @@ class _Flat: flat.template = getattr(w, "template", False) return flat - def hackathon_download_writeups( - self, competition: str, path: Optional[str] = None, quiet: bool = False - ) -> str: + def hackathon_download_writeups(self, competition: str, path: Optional[str] = None, quiet: bool = False) -> str: """Download the CSV of hackathon write-ups for a competition. Mirrors the ``download_hackathon_write_ups`` MCP tool. Writes the CSV @@ -2865,9 +2856,7 @@ def hackathon_download_writeups( print(f"Downloaded hackathon write-ups CSV to {outfile}") return outfile - def hackathon_download_writeups_cli( - self, competition=None, path=None, quiet=False - ): + def hackathon_download_writeups_cli(self, competition=None, path=None, quiet=False): """CLI wrapper for ``kaggle hackathons writeups download ``.""" if competition is None: raise ValueError("No competition specified") @@ -2886,9 +2875,7 @@ def hackathon_resolve_writeup_links(self, write_up_id: int): kaggle.discussions, "write_ups_client", None ) if client is None: - raise ValueError( - "kagglesdk is missing the WriteUpsClient; please upgrade kagglesdk." - ) + raise ValueError("kagglesdk is missing the WriteUpsClient; please upgrade kagglesdk.") method = self._require_sdk_method( client, "get_resolved_writeup_links", @@ -2905,11 +2892,7 @@ def hackathon_resolve_writeup_links_cli(self, writeup_id=None, csv_display=False except (TypeError, ValueError): raise ValueError(f"writeup_id must be an integer (got {writeup_id!r})") response = self.hackathon_resolve_writeup_links(wid) - links = ( - getattr(response, "resolved_links", None) - or getattr(response, "links", None) - or [] - ) + links = getattr(response, "resolved_links", None) or getattr(response, "links", None) or [] if not links: print("No links found") return diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 60dcd8d7..dbd76562 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -1628,9 +1628,7 @@ def parse_hackathons(subparsers) -> None: parser_get_optional = parser_get._action_groups.pop() parser_get_required = parser_get.add_argument_group("required arguments") parser_get_required.add_argument("competition", help=Help.param_competition_nonempty) - parser_get_optional.add_argument( - "-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv - ) + parser_get_optional.add_argument("-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv) parser_get_optional.add_argument("-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet) parser_get._action_groups.append(parser_get_optional) parser_get.set_defaults(func=api.hackathon_get_overview_cli) @@ -1689,9 +1687,7 @@ def parse_hackathons(subparsers) -> None: ) parser_writeups_resolve_optional = parser_writeups_resolve._action_groups.pop() parser_writeups_resolve_required = parser_writeups_resolve.add_argument_group("required arguments") - parser_writeups_resolve_required.add_argument( - "writeup_id", help=Help.param_hackathons_writeup_id - ) + parser_writeups_resolve_required.add_argument("writeup_id", help=Help.param_hackathons_writeup_id) parser_writeups_resolve_optional.add_argument( "-v", "--csv", dest="csv_display", action="store_true", help=Help.param_csv ) diff --git a/src/kaggle/test/test_hackathons_cli.py b/src/kaggle/test/test_hackathons_cli.py index e21d6c38..9eddb9b4 100644 --- a/src/kaggle/test/test_hackathons_cli.py +++ b/src/kaggle/test/test_hackathons_cli.py @@ -40,9 +40,7 @@ def api(): a._build_hackathon_overview_request = MagicMock(side_effect=lambda c: _Req(c)) a._build_list_hackathon_writeups_request = MagicMock(side_effect=lambda c: _Req(c)) a._build_export_hackathon_writeups_csv_request = MagicMock(side_effect=lambda c: _Req(c)) - a._build_get_resolved_writeup_links_request = MagicMock( - side_effect=lambda wid: _Req(write_up_id=wid) - ) + a._build_get_resolved_writeup_links_request = MagicMock(side_effect=lambda wid: _Req(write_up_id=wid)) return a @@ -152,9 +150,7 @@ def test_prints_writeups(self, api, capsys): _make_writeup(1, "Best Solution"), _make_writeup(2, "Runner Up", team_name="Team B"), ] - api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response( - writeups, total_count=2 - ) + api._mock_competitions.list_hackathon_write_ups.return_value = _make_writeups_response(writeups, total_count=2) api.hackathon_list_writeups_cli("hackathon-2026") out = capsys.readouterr().out assert "Best Solution" in out @@ -206,18 +202,14 @@ def test_default_path(self, api, tmp_path, monkeypatch, capsys): assert "Downloaded" in out def test_explicit_path(self, api, tmp_path): - api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response( - "id\n1\n" - ) + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response("id\n1\n") outfile = tmp_path / "out.csv" api.hackathon_download_writeups_cli("hackathon-2026", path=str(outfile), quiet=True) assert outfile.exists() assert outfile.read_text() == "id\n1\n" def test_directory_path_appends_default_name(self, api, tmp_path): - api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response( - "id\n1\n" - ) + api._mock_hackathon.export_hackathon_write_ups_csv.return_value = _make_csv_response("id\n1\n") api.hackathon_download_writeups_cli("hackathon-2026", path=str(tmp_path), quiet=True) assert (tmp_path / "hackathon-2026-writeups.csv").exists() @@ -291,9 +283,7 @@ def test_writeups_list_routes(self): def test_writeups_download_routes(self): parser = self._make_parser() - args = parser.parse_args( - ["hackathons", "writeups", "download", "titanic", "-p", "/tmp/out.csv"] - ) + args = parser.parse_args(["hackathons", "writeups", "download", "titanic", "-p", "/tmp/out.csv"]) assert args.competition == "titanic" assert args.path == "/tmp/out.csv" diff --git a/tools/check_mcp_cli_parity.py b/tools/check_mcp_cli_parity.py index ecba85e9..b1353246 100644 --- a/tools/check_mcp_cli_parity.py +++ b/tools/check_mcp_cli_parity.py @@ -31,9 +31,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_MAPPING = REPO_ROOT / "tools" / "mcp_cli_mapping.yaml" DEFAULT_LOCAL_MCP = REPO_ROOT.parent / "kaggleazure" / "Kaggle.Sdk" / "mcp" / "McpClient.cs" -DEFAULT_REMOTE_MCP = ( - "https://raw.githubusercontent.com/Kaggle/kaggleazure/ci/Kaggle.Sdk/mcp/McpClient.cs" -) +DEFAULT_REMOTE_MCP = "https://raw.githubusercontent.com/Kaggle/kaggleazure/ci/Kaggle.Sdk/mcp/McpClient.cs" MCP_TOOL_PATTERN = re.compile(r'McpServerTool\(Name\s*=\s*"([^"]+)"') @@ -68,9 +66,7 @@ def fetch_mcp_client_source(local_path: str | None, url: str | None) -> str: raise SystemExit(f"Failed to fetch McpClient.cs from {url}: HTTP {exc.code}{hint}") except urllib.error.URLError as exc: raise SystemExit(f"Failed to fetch McpClient.cs from {url}: {exc}") - raise SystemExit( - "No McpClient.cs source available. Pass --mcp-client or --mcp-client-url ." - ) + raise SystemExit("No McpClient.cs source available. Pass --mcp-client or --mcp-client-url .") def extract_mcp_tools(source: str) -> List[str]: @@ -248,8 +244,7 @@ def render_report( lines.append("") lines.append("## Stale mapping entries") lines.append("") - lines.append("These mapping keys do not correspond to any MCP tool — " - "remove or update them:") + lines.append("These mapping keys do not correspond to any MCP tool — " "remove or update them:") for key in stale: lines.append(f"- `{key}` → `{mapping[key]}`") return "\n".join(lines) + "\n" @@ -270,8 +265,7 @@ def main(argv: List[str] | None = None) -> int: parser.add_argument( "--mcp-client-url", default=DEFAULT_REMOTE_MCP, - help=f"Raw URL of McpClient.cs to fetch when local copy is absent " - f"(default: {DEFAULT_REMOTE_MCP})", + help=f"Raw URL of McpClient.cs to fetch when local copy is absent " f"(default: {DEFAULT_REMOTE_MCP})", ) parser.add_argument( "--mapping", From 5c8d811eadba58583fd8f23770e1072e24a92f58 Mon Sep 17 00:00:00 2001 From: mooneyp Date: Tue, 5 May 2026 15:06:46 +0000 Subject: [PATCH 3/4] Drop extra blank line to satisfy newer black in Cloud Build My local black was 24.10.0 (the pyproject floor) and was happy, but Cloud Build's hatch env pulls the latest black which is stricter about blank lines between imports and the next block. Co-authored-by: kaggle-agent --- src/kaggle/test/test_hackathons_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/kaggle/test/test_hackathons_cli.py b/src/kaggle/test/test_hackathons_cli.py index 9eddb9b4..2e74f0f4 100644 --- a/src/kaggle/test/test_hackathons_cli.py +++ b/src/kaggle/test/test_hackathons_cli.py @@ -17,7 +17,6 @@ from kaggle.api.kaggle_api_extended import KaggleApi from kaggle import cli as kaggle_cli - # ---- Fixtures & helpers ---- From 0796f8b25877d2c7f9ab2ad3cb297d12b6a02b9c Mon Sep 17 00:00:00 2001 From: mooneyp Date: Tue, 5 May 2026 16:09:39 +0000 Subject: [PATCH 4/4] Bump kagglesdk floor and inline shipped hackathon APIs Per review: the right fix for "kagglesdk doesn't have these yet" is to bump the floor, not work around it. Bumped to >= 0.1.23 (latest) and dropped the lazy-import + getattr plumbing for the two endpoints that ship there, so `hackathon_get_overview` and `hackathon_list_writeups` now use top-of-file imports and the tests stub the SDK client directly instead of mocking helper indirection. The CSV-export and resolve-links endpoints still aren't in any released kagglesdk, so those wrappers keep a small lazy import (with a TODO) and the test fixture stubs those two modules in `sys.modules`. Co-authored-by: kaggle-agent --- pyproject.toml | 2 +- src/kaggle/api/kaggle_api_extended.py | 116 ++++++------------------- src/kaggle/test/test_hackathons_cli.py | 50 +++++------ 3 files changed, 50 insertions(+), 118 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c10ea8f6..40275b6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = ["Kaggle", "API"] requires-python = ">= 3.11" dependencies = [ "bleach", - "kagglesdk >= 0.1.21, < 1.0", # sync with kagglehub + "kagglesdk >= 0.1.23, < 1.0", # sync with kagglehub "python-slugify", "requests", "python-dateutil", diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 109b13d8..6af600a6 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -97,6 +97,8 @@ ApiListCompetitionTopicsResponse, ApiListTopicMessagesRequest, ApiListTopicMessagesResponse, + ApiGetHackathonOverviewRequest, + ApiListHackathonWriteUpsRequest, ) from kagglesdk.discussions.types.discussions_api_service import ( ApiDiscussionComment, @@ -2652,65 +2654,6 @@ def entity_topic_show_cli( hackathon_overview_page_fields = ["name"] hackathon_writeup_link_fields = ["url", "type", "title"] - def _require_sdk_method(self, client, method_name: str, hint: str): - """Return ``client.method_name`` or raise a clear error. - - The hackathon endpoints landed in kagglesdk after this CLI release - was cut, so older SDK installs may not have them yet. - """ - method = getattr(client, method_name, None) - if method is None: - raise ValueError( - f"This command requires a newer kagglesdk that exposes " f"`{hint}`. Please upgrade kagglesdk." - ) - return method - - def _build_hackathon_overview_request(self, competition: str): - try: - from kagglesdk.competitions.types.competition_api_service import ( # type: ignore - ApiGetHackathonOverviewRequest, - ) - except ImportError as exc: - raise ValueError("kagglesdk is missing ApiGetHackathonOverviewRequest; please upgrade kagglesdk.") from exc - request = ApiGetHackathonOverviewRequest() - request.competition_name = competition - return request - - def _build_list_hackathon_writeups_request(self, competition: str): - try: - from kagglesdk.competitions.types.competition_api_service import ( # type: ignore - ApiListHackathonWriteUpsRequest, - ) - except ImportError as exc: - raise ValueError("kagglesdk is missing ApiListHackathonWriteUpsRequest; please upgrade kagglesdk.") from exc - request = ApiListHackathonWriteUpsRequest() - request.competition_name = competition - return request - - def _build_export_hackathon_writeups_csv_request(self, competition: str): - try: - from kagglesdk.competitions.types.hackathon_service import ( # type: ignore - ExportHackathonWriteUpsCsvRequest, - ) - except ImportError as exc: - raise ValueError( - "kagglesdk is missing ExportHackathonWriteUpsCsvRequest; please upgrade kagglesdk." - ) from exc - request = ExportHackathonWriteUpsCsvRequest() - request.competition_name = competition - return request - - def _build_get_resolved_writeup_links_request(self, write_up_id: int): - try: - from kagglesdk.discussions.types.writeups_service import ( # type: ignore - GetResolvedWriteUpLinksRequest, - ) - except ImportError as exc: - raise ValueError("kagglesdk is missing GetResolvedWriteUpLinksRequest; please upgrade kagglesdk.") from exc - request = GetResolvedWriteUpLinksRequest() - request.write_up_id = write_up_id - return request - def hackathon_get_overview(self, competition: str): """Get the overview page content for a hackathon competition. @@ -2718,15 +2661,10 @@ def hackathon_get_overview(self, competition: str): """ if not competition: raise ValueError("No competition specified") + request = ApiGetHackathonOverviewRequest() + request.competition_name = competition with self.build_kaggle_client() as kaggle: - request = self._build_hackathon_overview_request(competition) - client = kaggle.competitions.competition_api_client - method = self._require_sdk_method( - client, - "get_hackathon_overview", - "CompetitionApiClient.get_hackathon_overview", - ) - return method(request) + return kaggle.competitions.competition_api_client.get_hackathon_overview(request) def hackathon_get_overview_cli(self, competition=None, csv_display=False, quiet=False): """CLI wrapper for ``kaggle hackathons get ``.""" @@ -2757,15 +2695,10 @@ def hackathon_list_writeups(self, competition: str): """ if not competition: raise ValueError("No competition specified") + request = ApiListHackathonWriteUpsRequest() + request.competition_name = competition with self.build_kaggle_client() as kaggle: - request = self._build_list_hackathon_writeups_request(competition) - client = kaggle.competitions.competition_api_client - method = self._require_sdk_method( - client, - "list_hackathon_write_ups", - "CompetitionApiClient.list_hackathon_write_ups", - ) - return method(request) + return kaggle.competitions.competition_api_client.list_hackathon_write_ups(request) def hackathon_list_writeups_cli(self, competition=None, csv_display=False, quiet=False): """CLI wrapper for ``kaggle hackathons writeups list ``.""" @@ -2815,15 +2748,16 @@ def hackathon_download_writeups(self, competition: str, path: Optional[str] = No """ if not competition: raise ValueError("No competition specified") + # Not yet exported by any released kagglesdk; import lazily so the + # rest of the module still loads. Drop this once kagglesdk ships it. + from kagglesdk.competitions.types.hackathon_service import ( # type: ignore + ExportHackathonWriteUpsCsvRequest, + ) + + request = ExportHackathonWriteUpsCsvRequest() + request.competition_name = competition with self.build_kaggle_client() as kaggle: - request = self._build_export_hackathon_writeups_csv_request(competition) - client = kaggle.competitions.hackathon_client - method = self._require_sdk_method( - client, - "export_hackathon_write_ups_csv", - "HackathonClient.export_hackathon_write_ups_csv", - ) - response = method(request) + response = kaggle.competitions.hackathon_client.export_hackathon_write_ups_csv(request) csv_body = ( getattr(response, "csv", None) @@ -2869,19 +2803,21 @@ def hackathon_resolve_writeup_links(self, write_up_id: int): """ if write_up_id is None: raise ValueError("No write_up_id specified") + # Not yet exported by any released kagglesdk; import lazily so the + # rest of the module still loads. Drop this once kagglesdk ships it. + from kagglesdk.discussions.types.writeups_service import ( # type: ignore + GetResolvedWriteUpLinksRequest, + ) + + request = GetResolvedWriteUpLinksRequest() + request.write_up_id = int(write_up_id) with self.build_kaggle_client() as kaggle: - request = self._build_get_resolved_writeup_links_request(int(write_up_id)) client = getattr(kaggle.discussions, "writeups_client", None) or getattr( kaggle.discussions, "write_ups_client", None ) if client is None: raise ValueError("kagglesdk is missing the WriteUpsClient; please upgrade kagglesdk.") - method = self._require_sdk_method( - client, - "get_resolved_writeup_links", - "WriteUpsClient.get_resolved_writeup_links", - ) - return method(request) + return client.get_resolved_writeup_links(request) def hackathon_resolve_writeup_links_cli(self, writeup_id=None, csv_display=False, quiet=False): """CLI wrapper for ``kaggle hackathons writeups resolve-links ``.""" diff --git a/src/kaggle/test/test_hackathons_cli.py b/src/kaggle/test/test_hackathons_cli.py index 2e74f0f4..aae64300 100644 --- a/src/kaggle/test/test_hackathons_cli.py +++ b/src/kaggle/test/test_hackathons_cli.py @@ -1,15 +1,14 @@ """Tests for ``kaggle hackathons`` CLI commands. -The hackathon endpoints (``get_hackathon_overview``, -``list_hackathon_write_ups``, ``export_hackathon_write_ups_csv``, -``get_resolved_writeup_links``) may not yet exist on the installed -``kagglesdk``. The CLI wrappers build their request objects via lazy -imports and look up the SDK methods with ``getattr``, so the tests here -mock ``KaggleApi.build_kaggle_client`` and the lazy ``_build_*`` request -helpers — no SDK methods need to actually exist for the tests to run. +The two CSV-export and resolve-links endpoints are not yet shipped by any +released ``kagglesdk``, so those wrappers still import their request types +lazily. The fixture pins a ``WriteUpsClient`` onto the mocked +``kaggle.discussions`` so the resolve-links code path resolves cleanly. """ import argparse +import sys +import types from unittest.mock import MagicMock, patch import pytest @@ -17,6 +16,23 @@ from kaggle.api.kaggle_api_extended import KaggleApi from kaggle import cli as kaggle_cli + +def _install_missing_sdk_modules(): + """Inject stub modules for the two request types kagglesdk hasn't shipped yet.""" + hackathon_mod = "kagglesdk.competitions.types.hackathon_service" + if not hasattr(sys.modules.get(hackathon_mod), "ExportHackathonWriteUpsCsvRequest"): + mod = sys.modules.get(hackathon_mod) or types.ModuleType(hackathon_mod) + mod.ExportHackathonWriteUpsCsvRequest = type("ExportHackathonWriteUpsCsvRequest", (), {}) + sys.modules[hackathon_mod] = mod + writeups_mod = "kagglesdk.discussions.types.writeups_service" + if writeups_mod not in sys.modules: + mod = types.ModuleType(writeups_mod) + mod.GetResolvedWriteUpLinksRequest = type("GetResolvedWriteUpLinksRequest", (), {}) + sys.modules[writeups_mod] = mod + + +_install_missing_sdk_modules() + # ---- Fixtures & helpers ---- @@ -27,28 +43,15 @@ def api(): mock_client = MagicMock() a.build_kaggle_client = MagicMock() a.build_kaggle_client.return_value.__enter__.return_value = mock_client - a._mock_client = mock_client a._mock_competitions = mock_client.competitions.competition_api_client a._mock_hackathon = mock_client.competitions.hackathon_client # The current SDK has no WriteUpsClient — pin one onto the mock so the # production lookup (`getattr(...)`) finds it. a._mock_writeups = MagicMock() mock_client.discussions.writeups_client = a._mock_writeups - # Stub the lazy request builders so the tests don't depend on the SDK - # exposing the new request types. - a._build_hackathon_overview_request = MagicMock(side_effect=lambda c: _Req(c)) - a._build_list_hackathon_writeups_request = MagicMock(side_effect=lambda c: _Req(c)) - a._build_export_hackathon_writeups_csv_request = MagicMock(side_effect=lambda c: _Req(c)) - a._build_get_resolved_writeup_links_request = MagicMock(side_effect=lambda wid: _Req(write_up_id=wid)) return a -class _Req: - def __init__(self, competition_name=None, write_up_id=None): - self.competition_name = competition_name - self.write_up_id = write_up_id - - def _make_overview_response(pages): r = MagicMock() r.pages = pages @@ -132,13 +135,6 @@ def test_missing_competition(self, api): with pytest.raises(ValueError, match="No competition specified"): api.hackathon_get_overview_cli(None) - def test_missing_sdk_method(self, api): - # Strip the method off the client to force the missing-method path. - del api._mock_competitions.get_hackathon_overview - api._mock_competitions.mock_add_spec(["other_method"]) - with pytest.raises(ValueError, match="newer kagglesdk"): - api.hackathon_get_overview("titanic") - # ---- hackathons writeups list ----