From 043a162bb47c20e67cec5d856ccf523f1990ade4 Mon Sep 17 00:00:00 2001 From: Bovard Doerschuk-Tiberi Date: Fri, 7 Aug 2026 19:59:16 +0000 Subject: [PATCH 1/2] feat(competitions): add host-add command Wire the SDK's AddCompetitionHost endpoint up as `kaggle competitions host-add -u `, granting host access on a competition to another Kaggle user. Named `host-add` rather than `hosts add` because `hosts` takes the competition as a positional argument, and argparse cannot distinguish a competition slug from a subcommand token. Making `hosts` a subcommand group would break the existing `kaggle competitions hosts ` form (the same limitation already affects `competitions pages `), so the flat hyphenated spelling matches submission-download and team-submissions instead. Granting host access is a permission change, so it prompts for confirmation by default, with -y to skip for scripting. --- CHANGELOG.md | 1 + docs/competition_creation.md | 42 ++++++++ src/kaggle/api/kaggle_api_extended.py | 52 ++++++++++ src/kaggle/cli.py | 31 ++++++ tests/unit/test_cli_competitions.py | 22 ++++ tests/unit/test_competition_add_host.py | 128 ++++++++++++++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 tests/unit/test_competition_add_host.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d8d278..944df495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Changelog ### Next +* Add `kaggle competitions host-add -u ` to grant host access on a competition to a Kaggle user * Add `kaggle competitions submission-download ` to download the submitted file for a single submission (requires `kagglesdk >= 0.1.36`) * Add `deadline` (Competition Deadline) to the competition settings command and bump `kagglesdk` to `>= 0.1.36` * Document that `NvidiaTeslaP100` is unusable for GPU compute with the default Kaggle image (PyTorch cu128 omits Pascal `sm_60` kernels) diff --git a/docs/competition_creation.md b/docs/competition_creation.md index bba50dcf..550209f3 100644 --- a/docs/competition_creation.md +++ b/docs/competition_creation.md @@ -7,6 +7,7 @@ public competition-creation API endpoints (kagglesdk 0.1.31+): - [`kaggle competitions create`](#kaggle-competitions-create) - [`kaggle competitions pages create`](#kaggle-competitions-pages-create) - [`kaggle competitions hosts`](#kaggle-competitions-hosts) +- [`kaggle competitions host-add`](#kaggle-competitions-host-add) - [`kaggle competitions settings get`](#kaggle-competitions-settings-get) - [`kaggle competitions settings update`](#kaggle-competitions-settings-update) - [`kaggle competitions data update`](#kaggle-competitions-data-update) @@ -358,6 +359,47 @@ Output columns: `userName`, `displayName`, `id`, `profileUrl`. --- +## `kaggle competitions host-add` + +Grants host access on a competition you host to another Kaggle user. Hosts can +edit settings, upload data, and launch the competition, so you are asked to +confirm before the change is made. + +**Usage:** + +```bash +kaggle competitions host-add -u [-y] +``` + +**Arguments:** + +- ``: The competition slug. + +**Options:** + +- `-u, --user `: Kaggle user name (URL slug, e.g. `kerneler`) of the user + to add as a host. Required. +- `-y, --yes`: Skip the confirmation prompt. +- `-q, --quiet`: Suppress the "Using competition" message. + +**Examples:** + +```bash +# Prompts for confirmation before granting access. +kaggle competitions host-add my-comp -u alice + +# Skip the prompt (for scripts). +kaggle competitions host-add my-comp -u alice -y +``` + +Verify the result with `kaggle competitions hosts my-comp`. + +> **Note:** This command is named `host-add` rather than `hosts add` because +> `hosts` takes the competition as a positional argument, and argparse cannot +> distinguish a competition slug from a subcommand name. + +--- + ## `kaggle competitions settings get` Shows the unified settings blob for a competition you host — the same set of diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 8b0cae28..b3a59fcb 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -112,6 +112,7 @@ ApiUpdateCompetitionPageRequest, ApiGetCompetitionSettingsRequest, ApiListCompetitionHostsRequest, + ApiAddCompetitionHostRequest, ApiUpdateCompetitionSettingsRequest, ApiCreateCompetitionDataRequest, ApiCreateCompetitionDataResponse, @@ -2912,6 +2913,57 @@ def competition_list_hosts_cli( else: print("No hosts found") + def competition_add_host( + self, + competition_name: str, + user_name: str, + no_confirm: bool = False, + ) -> bool: + """Grant host access on a competition to a Kaggle user. + + Args: + competition_name (str): The competition name (slug). + user_name (str): Kaggle user name (URL slug, e.g. 'kerneler') of the + user to add as a host. + no_confirm (bool): If True, skip the confirmation prompt. + + Returns: + bool: True if the host was added, False if cancelled. + """ + if not no_confirm: + if not self.confirmation(f"add '{user_name}' as a host of competition '{competition_name}'"): + print("Add host cancelled") + return False + + with self.build_kaggle_client() as kaggle: + request = ApiAddCompetitionHostRequest() + request.competition_name = competition_name + request.user_name = user_name + kaggle.competitions.competition_api_client.add_competition_host(request) + return True + + def competition_add_host_cli( + self, + competition=None, + competition_opt=None, + user_name=None, + no_confirm=False, + quiet=False, + ): + """CLI wrapper for competition_add_host.""" + 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 user_name: + raise ValueError("--user is required") + + if self.competition_add_host(competition_name, user_name, no_confirm=no_confirm): + print(f"User '{user_name}' added as a host of competition '{competition_name}'.") + def competition_create_page( self, competition_name: str, diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 06c9709c..d589b971 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -665,6 +665,34 @@ def parse_competitions(subparsers) -> None: parser_competitions_hosts._action_groups.append(parser_competitions_hosts_optional) parser_competitions_hosts.set_defaults(func=api.competition_list_hosts_cli) + # Competitions host-add (grant host access to a user) + # Flat rather than a `hosts add` subcommand: argparse can't disambiguate the + # `hosts` parent positional from a subcommand token, which would break the + # existing `kaggle competitions hosts ` form. + parser_competitions_host_add = subparsers_competitions.add_parser( + "host-add", + formatter_class=argparse.RawTextHelpFormatter, + help=Help.command_competitions_host_add, + ) + parser_competitions_host_add_optional = parser_competitions_host_add._action_groups.pop() + parser_competitions_host_add_optional.add_argument( + "competition", nargs="?", default=None, help=Help.param_competition + ) + parser_competitions_host_add_optional.add_argument( + "-c", "--competition", dest="competition_opt", required=False, help=argparse.SUPPRESS + ) + parser_competitions_host_add_optional.add_argument( + "-u", "--user", dest="user_name", required=True, help=Help.param_competitions_host_add_user + ) + parser_competitions_host_add_optional.add_argument( + "-y", "--yes", dest="no_confirm", action="store_true", help=Help.param_yes + ) + parser_competitions_host_add_optional.add_argument( + "-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet + ) + parser_competitions_host_add._action_groups.append(parser_competitions_host_add_optional) + parser_competitions_host_add.set_defaults(func=api.competition_add_host_cli) + # Competitions data (group: update) parser_competitions_data = subparsers_competitions.add_parser( "data", @@ -2509,6 +2537,7 @@ class Help(object): "logs", "pages", "hosts", + "host-add", "data", "settings", "solution", @@ -2662,6 +2691,7 @@ class Help(object): 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_hosts = "List hosts (users with host access) for a competition" + command_competitions_host_add = "Grant host access on a competition you host to a Kaggle user" command_competitions_data = "Manage a competition's data files" command_competitions_data_update = "Update (version) the data files for a competition you host" command_competitions_settings = "Manage settings for a competition you host" @@ -2827,6 +2857,7 @@ class Help(object): "to show options)\nIf empty, the default competition " 'will be used (use "kaggle config set competition")"' ) + param_competitions_host_add_user = "Kaggle user name (URL slug, e.g. 'kerneler') of the user to add as a host" param_competition_nonempty = 'Competition URL suffix (use "kaggle competitions list" to show ' "options)" param_competition_leaderboard_view = "Show the top of the leaderboard" param_competition_leaderboard_download = "Download entire leaderboard" diff --git a/tests/unit/test_cli_competitions.py b/tests/unit/test_cli_competitions.py index 73757cc0..4ceeac60 100644 --- a/tests/unit/test_cli_competitions.py +++ b/tests/unit/test_cli_competitions.py @@ -387,6 +387,28 @@ def test_competitions_hosts_positional_succeeds(parser): assert kwargs["competition"] == "my-comp" +def test_competitions_host_add_missing_user_fails(parser): + with pytest.raises(SystemExit): + parser.dispatch(["competitions", "host-add", "my-comp"]) + + +def test_competitions_host_add_positional_succeeds(parser): + func, kwargs = parser.dispatch(["competitions", "host-add", "my-comp", "-u", "alice"]) + assert func.__name__ == "competition_add_host_cli" + assert kwargs["competition"] == "my-comp" + assert kwargs["user_name"] == "alice" + assert kwargs["no_confirm"] is False + + +def test_competitions_host_add_dash_c_and_yes_succeeds(parser): + func, kwargs = parser.dispatch(["competitions", "host-add", "-c", "my-comp", "--user", "alice", "-y"]) + assert func.__name__ == "competition_add_host_cli" + assert kwargs.get("competition") is None + assert kwargs["competition_opt"] == "my-comp" + assert kwargs["user_name"] == "alice" + assert kwargs["no_confirm"] is True + + def test_competitions_data_update_missing_args_fails(parser): with pytest.raises(SystemExit): parser.dispatch(["competitions", "data", "update", "my-comp"]) diff --git a/tests/unit/test_competition_add_host.py b/tests/unit/test_competition_add_host.py new file mode 100644 index 00000000..bece574c --- /dev/null +++ b/tests/unit/test_competition_add_host.py @@ -0,0 +1,128 @@ +# coding=utf-8 +import io +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +sys.path.insert(0, "../..") + +from kaggle.api.kaggle_api_extended import KaggleApi + + +class TestCompetitionAddHost(unittest.TestCase): + """Tests for competition_add_host and its CLI wrapper.""" + + def setUp(self): + self.api = KaggleApi.__new__(KaggleApi) + self.api.config_values = {} + + def _patch_client(self, mock_client): + mock_kaggle = MagicMock() + mock_client.return_value.__enter__ = MagicMock(return_value=mock_kaggle) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + return mock_kaggle + + def _add_host_call(self, mock_kaggle): + return mock_kaggle.competitions.competition_api_client.add_competition_host + + @patch.object(KaggleApi, "build_kaggle_client") + def test_add_host_builds_request(self, mock_client): + mock_kaggle = self._patch_client(mock_client) + + result = self.api.competition_add_host("my-comp", "alice", no_confirm=True) + + request = self._add_host_call(mock_kaggle).call_args[0][0] + self.assertEqual(request.competition_name, "my-comp") + self.assertEqual(request.user_name, "alice") + self.assertTrue(result) + + @patch.object(KaggleApi, "confirmation", return_value=True) + @patch.object(KaggleApi, "build_kaggle_client") + def test_add_host_prompts_when_not_confirmed(self, mock_client, mock_confirm): + mock_kaggle = self._patch_client(mock_client) + + result = self.api.competition_add_host("my-comp", "alice") + + mock_confirm.assert_called_once() + self._add_host_call(mock_kaggle).assert_called_once() + self.assertTrue(result) + + @patch.object(KaggleApi, "confirmation", return_value=False) + @patch.object(KaggleApi, "build_kaggle_client") + def test_add_host_declined_makes_no_request(self, mock_client, mock_confirm): + """Declining the prompt must not reach the API.""" + mock_kaggle = self._patch_client(mock_client) + + with redirect_stdout(io.StringIO()) as out: + result = self.api.competition_add_host("my-comp", "alice") + + self.assertFalse(result) + self._add_host_call(mock_kaggle).assert_not_called() + self.assertIn("Add host cancelled", out.getvalue()) + + @patch.object(KaggleApi, "confirmation") + @patch.object(KaggleApi, "build_kaggle_client") + def test_add_host_no_confirm_skips_prompt(self, mock_client, mock_confirm): + self._patch_client(mock_client) + + self.api.competition_add_host("my-comp", "alice", no_confirm=True) + + mock_confirm.assert_not_called() + + @patch.object(KaggleApi, "competition_add_host", return_value=True) + def test_cli_uses_positional_competition(self, mock_add): + with redirect_stdout(io.StringIO()) as out: + self.api.competition_add_host_cli(competition="my-comp", user_name="alice", no_confirm=True) + + mock_add.assert_called_once_with("my-comp", "alice", no_confirm=True) + self.assertIn("alice", out.getvalue()) + self.assertIn("my-comp", out.getvalue()) + + @patch.object(KaggleApi, "competition_add_host", return_value=True) + def test_cli_uses_competition_opt(self, mock_add): + with redirect_stdout(io.StringIO()): + self.api.competition_add_host_cli(competition_opt="my-comp", user_name="alice", no_confirm=True) + + mock_add.assert_called_once_with("my-comp", "alice", no_confirm=True) + + @patch.object(KaggleApi, "competition_add_host", return_value=True) + def test_cli_falls_back_to_configured_competition(self, mock_add): + self.api.config_values = {self.api.CONFIG_NAME_COMPETITION: "configured-comp"} + + with redirect_stdout(io.StringIO()) as out: + self.api.competition_add_host_cli(user_name="alice", no_confirm=True) + + mock_add.assert_called_once_with("configured-comp", "alice", no_confirm=True) + self.assertIn("Using competition: configured-comp", out.getvalue()) + + @patch.object(KaggleApi, "competition_add_host", return_value=True) + def test_cli_quiet_suppresses_using_competition(self, mock_add): + self.api.config_values = {self.api.CONFIG_NAME_COMPETITION: "configured-comp"} + + with redirect_stdout(io.StringIO()) as out: + self.api.competition_add_host_cli(user_name="alice", no_confirm=True, quiet=True) + + self.assertNotIn("Using competition", out.getvalue()) + + def test_cli_without_competition_raises(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_add_host_cli(user_name="alice") + self.assertIn("No competition specified", str(ctx.exception)) + + def test_cli_without_user_raises(self): + with self.assertRaises(ValueError) as ctx: + self.api.competition_add_host_cli(competition="my-comp") + self.assertIn("--user is required", str(ctx.exception)) + + @patch.object(KaggleApi, "competition_add_host", return_value=False) + def test_cli_cancelled_prints_no_success_message(self, mock_add): + """A declined add must not report success.""" + with redirect_stdout(io.StringIO()) as out: + self.api.competition_add_host_cli(competition="my-comp", user_name="alice") + + self.assertNotIn("added as a host", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 7f81d739d2fbca9f85c4ed617f502d48a8f05bc1 Mon Sep 17 00:00:00 2001 From: Bovard Doerschuk-Tiberi Date: Tue, 11 Aug 2026 17:43:30 +0000 Subject: [PATCH 2/2] test(competitions): assert host-add confirmation prompt text The existing test only checked that confirmation() was called. Since this command grants host access, also assert the prompt names the exact user and competition, so an interpolation mistake can't slip through. --- tests/unit/test_competition_add_host.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/test_competition_add_host.py b/tests/unit/test_competition_add_host.py index bece574c..58d032c4 100644 --- a/tests/unit/test_competition_add_host.py +++ b/tests/unit/test_competition_add_host.py @@ -48,6 +48,17 @@ def test_add_host_prompts_when_not_confirmed(self, mock_client, mock_confirm): self._add_host_call(mock_kaggle).assert_called_once() self.assertTrue(result) + @patch.object(KaggleApi, "confirmation", return_value=True) + @patch.object(KaggleApi, "build_kaggle_client") + def test_add_host_prompt_names_user_and_competition(self, mock_client, mock_confirm): + """The prompt must name the user being granted access and the competition.""" + self._patch_client(mock_client) + + self.api.competition_add_host("my-comp", "alice") + + action = mock_confirm.call_args[0][0] + self.assertEqual(action, "add 'alice' as a host of competition 'my-comp'") + @patch.object(KaggleApi, "confirmation", return_value=False) @patch.object(KaggleApi, "build_kaggle_client") def test_add_host_declined_makes_no_request(self, mock_client, mock_confirm):