diff --git a/CHANGELOG.md b/CHANGELOG.md index 3145aeca..5b503f08 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 * Suggest a next step on 403/404/429/5xx API errors, report unexpected errors as bugs instead of a traceback (with a new `--debug` flag), and list common examples in `kaggle --help` * Add `kaggle benchmarks quota` to show Model Proxy (AI inference) spend quota, and bump `kagglesdk` to `>= 0.1.37` * Add `kaggle competitions submission-download ` to download the submitted file for a single submission (requires `kagglesdk >= 0.1.36`) 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 3bff8261..8a623d27 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -113,6 +113,7 @@ ApiUpdateCompetitionPageRequest, ApiGetCompetitionSettingsRequest, ApiListCompetitionHostsRequest, + ApiAddCompetitionHostRequest, ApiUpdateCompetitionSettingsRequest, ApiCreateCompetitionDataRequest, ApiCreateCompetitionDataResponse, @@ -3040,6 +3041,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 c96f5260..21e0b758 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -685,6 +685,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", @@ -2538,6 +2566,7 @@ class Help(object): "logs", "pages", "hosts", + "host-add", "data", "settings", "solution", @@ -2711,6 +2740,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" @@ -2877,6 +2907,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..58d032c4 --- /dev/null +++ b/tests/unit/test_competition_add_host.py @@ -0,0 +1,139 @@ +# 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=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): + """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()