diff --git a/docs/competition_creation.md b/docs/competition_creation.md index fd2af504..79cec1c9 100644 --- a/docs/competition_creation.md +++ b/docs/competition_creation.md @@ -168,7 +168,7 @@ competition you host. **Usage:** ```bash -kaggle competitions pages create --name -f \ +kaggle competitions pages create --page-name -f \ [--mime-type ] [--post-title ""] [--publish] ``` @@ -178,7 +178,7 @@ kaggle competitions pages create <competition> --name <page-name> -f <path> \ **Options:** -- `--name <page-name>` (required): Page name (e.g. `description`, `rules`, +- `--page-name <page-name>` (required): Page name (e.g. `description`, `rules`, `evaluation`, `data-description`, `prizes`). Conventional names are recognized by the competition page UI; new names are allowed but won't be shown in the standard tabs. @@ -195,20 +195,75 @@ kaggle competitions pages create <competition> --name <page-name> -f <path> \ **Example:** ```bash -# Stage a draft of the rules page. -kaggle competitions pages create my-comp --name rules -f ./rules.md \ +# Create the rules page in a staged (not-yet-published) state. +kaggle competitions pages create my-comp --page-name rules -f ./rules.md \ --mime-type text/markdown --post-title "Competition Rules" -# Replace with a published version once you're happy. -kaggle competitions pages create my-comp --name rules -f ./rules-final.md --publish +# When you're ready to make it visible to participants: +kaggle competitions pages update my-comp --page-name rules --publish ``` -**Note:** `pages create` does not update an existing page in place — it creates -a new page. Use [`kaggle competitions pages delete`](#kaggle-competitions-pages-delete) -to remove a page. +Each page exists as a single record; `--publish` / `--unpublish` toggles its +visibility rather than creating separate draft and live copies. To swap in new +content later, use +[`kaggle competitions pages update`](#kaggle-competitions-pages-update) — a +second `create` for the same page name will be rejected. You can list and inspect existing pages with `kaggle competitions pages` -(or the explicit `kaggle competitions pages list`). +(or the explicit `kaggle competitions pages list`), modify one in place with +[`kaggle competitions pages update`](#kaggle-competitions-pages-update), or +remove one with [`kaggle competitions pages delete`](#kaggle-competitions-pages-delete). + +--- + +## `kaggle competitions pages update` + +Updates fields on an existing competition page. Only the flags you supply are +sent (the FieldMask is built from which arguments are non-default), so this is +also how you publish or unpublish a page in place. + +**Usage:** + +```bash +kaggle competitions pages update <competition> --page-name <current-name> \ + [-f <path>] [--new-name <name>] [--mime-type <type>] \ + [--post-title "<title>"] [--publish | --unpublish] +``` + +**Arguments:** + +- `<competition>`: The competition slug. + +**Options:** + +- `--page-name <current-name>` (required): The page's current name (used as the + identifier; rename via `--new-name`). +- `-f, --file <path>` (optional): Path to a file with the new page body. +- `--new-name <name>` (optional): Rename the page. +- `--mime-type <type>` (optional): New MIME type of the content. +- `--post-title "<title>"` (optional): New title shown above the page content. +- `--publish` / `--unpublish` (optional, mutually exclusive): Publish or + unpublish the page. + +At least one update flag is required. + +**Examples:** + +```bash +# Publish a staged page without changing its content. +kaggle competitions pages update my-comp --page-name rules --publish + +# Swap in new content and update the visible title in one call. +kaggle competitions pages update my-comp --page-name rules \ + -f ./rules-v2.md --post-title "Competition Rules (v2)" + +# Rename a page. +kaggle competitions pages update my-comp --page-name evaluation \ + --new-name scoring +``` + +**Note:** a small set of pages is reserved by the backend and cannot be +renamed; attempting to rename one returns an error from the server. --- diff --git a/pyproject.toml b/pyproject.toml index 8cce5197..2b501878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = ["Kaggle", "API"] requires-python = ">= 3.11" dependencies = [ "bleach", - "kagglesdk >= 0.1.32, < 1.0", # sync with kagglehub + "kagglesdk >= 0.1.33, < 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 bb970068..b772a788 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -100,6 +100,7 @@ ApiListCompetitionPagesResponse, ApiCreateCompetitionPageRequest, ApiDeleteCompetitionPageRequest, + ApiUpdateCompetitionPageRequest, ApiCompetitionPage, ApiCreateCompetitionRequest, ApiCreateCompetitionResponse, @@ -2431,6 +2432,114 @@ def competition_create_page_cli( status = "published" if page.is_published else "staged (unpublished)" print(f'Page "{page.name}" created on competition "{competition_name}" — {status}.') + def competition_update_page( + self, + competition_name: str, + page_name: str, + content_path: Optional[str] = None, + new_name: Optional[str] = None, + mime_type: Optional[str] = None, + post_title: Optional[str] = None, + is_published: Optional[bool] = None, + ) -> ApiCompetitionPage: + """Update fields on an existing competition page. + + Only fields supplied here are sent to the server (the FieldMask is built + from which arguments are non-None). Pass ``new_name`` to rename the page. + + Args: + competition_name (str): The competition name (slug). + page_name (str): Current page name (identifier). + content_path (Optional[str]): Path to a file whose contents become + the new page body. + new_name (Optional[str]): New page name (renames the page). + mime_type (Optional[str]): New MIME type for the content. + post_title (Optional[str]): New title shown above the content. + is_published (Optional[bool]): True publishes the page, False + unpublishes it. + + Returns: + ApiCompetitionPage: the updated page. + """ + page = ApiCompetitionPage() + paths: List[str] = [] + + if content_path is not None: + if not os.path.isfile(content_path): + raise ValueError("Content file not found: " + content_path) + with open(content_path, "r", encoding="utf-8") as f: + page.content = f.read() + paths.append("content") + if new_name is not None: + page.name = new_name + paths.append("name") + if mime_type is not None: + page.mime_type = mime_type + paths.append("mime_type") + if post_title is not None: + page.post_title = post_title + paths.append("post_title") + if is_published is not None: + page.is_published = is_published + paths.append("is_published") + + if not paths: + raise ValueError( + "Nothing to update — pass at least one of -f, --new-name, " + "--mime-type, --post-title, --publish/--unpublish." + ) + + with self.build_kaggle_client() as kaggle: + request = ApiUpdateCompetitionPageRequest() + request.competition_name = competition_name + request.page_name = page_name + request.page = page + request.update_mask = field_mask_pb2.FieldMask(paths=paths) + return kaggle.competitions.competition_api_client.update_competition_page(request) + + def competition_update_page_cli( + self, + competition=None, + competition_opt=None, + page_name=None, + file_path=None, + new_name=None, + mime_type=None, + post_title=None, + publish=False, + unpublish=False, + quiet=False, + ): + """CLI wrapper for competition_update_page.""" + competition_name = competition or competition_opt + if competition_name is None: + competition_name = self.get_config_value(self.CONFIG_NAME_COMPETITION) + if competition_name is not None and not quiet: + print("Using competition: " + competition_name) + if competition_name is None: + raise ValueError("No competition specified") + if not page_name: + raise ValueError("--page-name is required") + if publish and unpublish: + raise ValueError("--publish and --unpublish are mutually exclusive") + + is_published: Optional[bool] = None + if publish: + is_published = True + elif unpublish: + is_published = False + + page = self.competition_update_page( + competition_name=competition_name, + page_name=page_name, + content_path=file_path, + new_name=new_name, + mime_type=mime_type, + post_title=post_title, + is_published=is_published, + ) + print(f'Page "{page.name}" updated on competition "{competition_name}".') + def competition_delete_page( self, competition_name: str, diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index c48d9a29..1390a752 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -467,6 +467,65 @@ def parse_competitions(subparsers) -> None: parser_competitions_pages_create._action_groups.append(parser_competitions_pages_create_optional) parser_competitions_pages_create.set_defaults(func=api.competition_create_page_cli) + # Competitions pages update + parser_competitions_pages_update = subparsers_competitions_pages.add_parser( + "update", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_competitions_pages_update, + ) + parser_competitions_pages_update_optional = parser_competitions_pages_update._action_groups.pop() + parser_competitions_pages_update_optional.add_argument( + "competition", nargs="?", default=None, help=Help.param_competition + ) + parser_competitions_pages_update_optional.add_argument( + "-c", "--competition", dest="competition_opt", required=False, help=argparse.SUPPRESS + ) + parser_competitions_pages_update_optional.add_argument( + "--page-name", + dest="page_name", + required=True, + help="Current page name (identifier).", + ) + parser_competitions_pages_update_optional.add_argument( + "-f", "--file", dest="file_path", required=False, help="Path to a file with the new page body." + ) + parser_competitions_pages_update_optional.add_argument( + "--new-name", + dest="new_name", + required=False, + help="Rename the page to this value.", + ) + parser_competitions_pages_update_optional.add_argument( + "--mime-type", + dest="mime_type", + required=False, + help='New MIME type of the content (e.g. "text/markdown").', + ) + parser_competitions_pages_update_optional.add_argument( + "--post-title", + dest="post_title", + required=False, + help="New title shown above the page content.", + ) + publish_group = parser_competitions_pages_update_optional.add_mutually_exclusive_group() + publish_group.add_argument( + "--publish", + dest="publish", + action="store_true", + help="Publish the page.", + ) + publish_group.add_argument( + "--unpublish", + dest="unpublish", + action="store_true", + help="Unpublish the page (stage it).", + ) + parser_competitions_pages_update_optional.add_argument( + "-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet + ) + parser_competitions_pages_update._action_groups.append(parser_competitions_pages_update_optional) + parser_competitions_pages_update.set_defaults(func=api.competition_update_page_cli) + # Competitions pages delete parser_competitions_pages_delete = subparsers_competitions_pages.add_parser( "delete", @@ -2122,7 +2181,7 @@ class Help(object): forums_choices = ["list", "topics"] forums_topics_choices = ["list", "show"] entity_topics_choices = ["list", "show"] - entity_pages_choices = ["list", "create", "delete"] + entity_pages_choices = ["list", "create", "update", "delete"] config_choices = ["view", "set", "unset"] auth_choices = ["login", "print-access-token", "revoke"] @@ -2196,6 +2255,7 @@ class Help(object): command_competitions_episode_logs = "Download agent logs for a simulation episode" command_competitions_pages = "List pages for a competition" command_competitions_pages_create = "Create a new page on a competition you host" + command_competitions_pages_update = "Update fields on an existing competition page" command_competitions_pages_delete = "Delete a page from a competition you host" command_competitions_launch = "Launch a competition you host, optionally at a future UTC time" command_competitions_init = "Initialize folder with a competition-metadata.json template" diff --git a/tests/unit/test_competition_create.py b/tests/unit/test_competition_create.py index 2c3208b3..2180dbe9 100644 --- a/tests/unit/test_competition_create.py +++ b/tests/unit/test_competition_create.py @@ -12,7 +12,6 @@ from kagglesdk.competitions.types.competition_enums import CompetitionPrivacy from kagglesdk.competitions.types.competition import RewardTypeId - _MINIMAL_META = { "title": "My Awesome Comp", "slug": "my-awesome-comp", diff --git a/tests/unit/test_competition_update_page.py b/tests/unit/test_competition_update_page.py new file mode 100644 index 00000000..849a7843 --- /dev/null +++ b/tests/unit/test_competition_update_page.py @@ -0,0 +1,187 @@ +# coding=utf-8 +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, "../..") + +from kaggle.api.kaggle_api_extended import KaggleApi + + +def _mock_updated_page(name="rules", is_published=False): + page = MagicMock() + page.name = name + page.is_published = is_published + return page + + +class TestCompetitionUpdatePage(unittest.TestCase): + """Tests for competition_update_page and its CLI wrapper.""" + + def setUp(self): + self.api = KaggleApi.__new__(KaggleApi) + self._tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") + self._tmp.write("# Updated rules\nNew content.\n") + self._tmp.close() + self.content_path = self._tmp.name + + def tearDown(self): + os.unlink(self.content_path) + + def _patch_client(self, mock_client, returned_page=None): + mock_kaggle = MagicMock() + mock_kaggle.competitions.competition_api_client.update_competition_page.return_value = ( + returned_page or _mock_updated_page() + ) + mock_client.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + return mock_kaggle + + @patch.object(KaggleApi, "build_kaggle_client") + def test_update_content_only_sets_content_mask(self, mock_client): + mock_kaggle = self._patch_client(mock_client) + + self.api.competition_update_page( + competition_name="my-comp", + page_name="rules", + content_path=self.content_path, + ) + + request = mock_kaggle.competitions.competition_api_client.update_competition_page.call_args[0][0] + self.assertEqual(request.competition_name, "my-comp") + self.assertEqual(request.page_name, "rules") + self.assertEqual(request.page.content, "# Updated rules\nNew content.\n") + self.assertEqual(list(request.update_mask.paths), ["content"]) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_rename_only_sets_name_mask(self, mock_client): + mock_kaggle = self._patch_client(mock_client, _mock_updated_page(name="new-rules")) + + self.api.competition_update_page( + competition_name="my-comp", + page_name="rules", + new_name="new-rules", + ) + + request = mock_kaggle.competitions.competition_api_client.update_competition_page.call_args[0][0] + self.assertEqual(request.page.name, "new-rules") + self.assertEqual(list(request.update_mask.paths), ["name"]) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_multiple_fields_set_combined_mask(self, mock_client): + mock_kaggle = self._patch_client(mock_client) + + self.api.competition_update_page( + competition_name="my-comp", + page_name="rules", + content_path=self.content_path, + mime_type="text/markdown", + post_title="Rules", + is_published=True, + ) + + request = mock_kaggle.competitions.competition_api_client.update_competition_page.call_args[0][0] + self.assertEqual( + sorted(request.update_mask.paths), + sorted(["content", "mime_type", "post_title", "is_published"]), + ) + self.assertEqual(request.page.content, "# Updated rules\nNew content.\n") + self.assertEqual(request.page.mime_type, "text/markdown") + self.assertEqual(request.page.post_title, "Rules") + self.assertTrue(request.page.is_published) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_unpublish_sets_is_published_false(self, mock_client): + mock_kaggle = self._patch_client(mock_client) + + self.api.competition_update_page( + competition_name="my-comp", + page_name="rules", + is_published=False, + ) + + request = mock_kaggle.competitions.competition_api_client.update_competition_page.call_args[0][0] + self.assertEqual(list(request.update_mask.paths), ["is_published"]) + self.assertFalse(request.page.is_published) + + def test_no_fields_raises(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_update_page(competition_name="my-comp", page_name="rules") + self.assertIn("Nothing to update", str(ctx.exception)) + + def test_missing_content_file_raises(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_update_page( + competition_name="my-comp", + page_name="rules", + content_path="/tmp/does-not-exist-9999.md", + ) + self.assertIn("Content file not found", str(ctx.exception)) + + @patch.object(KaggleApi, "competition_update_page") + def test_cli_forwards_publish_flag(self, mock_update): + mock_update.return_value = _mock_updated_page(is_published=True) + + self.api.competition_update_page_cli( + competition="my-comp", + page_name="rules", + file_path=self.content_path, + publish=True, + ) + + kwargs = mock_update.call_args.kwargs + self.assertEqual(kwargs["competition_name"], "my-comp") + self.assertEqual(kwargs["page_name"], "rules") + self.assertEqual(kwargs["content_path"], self.content_path) + self.assertTrue(kwargs["is_published"]) + + @patch.object(KaggleApi, "competition_update_page") + def test_cli_forwards_unpublish_flag(self, mock_update): + mock_update.return_value = _mock_updated_page(is_published=False) + + self.api.competition_update_page_cli( + competition="my-comp", + page_name="rules", + unpublish=True, + ) + + self.assertFalse(mock_update.call_args.kwargs["is_published"]) + + @patch.object(KaggleApi, "competition_update_page") + def test_cli_default_is_published_none(self, mock_update): + mock_update.return_value = _mock_updated_page() + + self.api.competition_update_page_cli( + competition="my-comp", + page_name="rules", + file_path=self.content_path, + ) + + self.assertIsNone(mock_update.call_args.kwargs["is_published"]) + + def test_cli_publish_and_unpublish_mutually_exclusive(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_update_page_cli( + competition="my-comp", + page_name="rules", + publish=True, + unpublish=True, + ) + self.assertIn("mutually exclusive", str(ctx.exception)) + + def test_cli_missing_competition_raises(self): + self.api.config_values = {} + with self.assertRaises(ValueError) as ctx: + self.api.competition_update_page_cli(page_name="rules", file_path=self.content_path) + self.assertIn("No competition specified", str(ctx.exception)) + + def test_cli_missing_page_name_raises(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_update_page_cli(competition="my-comp", file_path=self.content_path) + self.assertIn("--page-name is required", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main()