Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Changelog

### Next

* Add `kaggle competitions host-add <comp> -u <user>` 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 <id>` to download the submitted file for a single submission (requires `kagglesdk >= 0.1.36`)
Expand Down
42 changes: 42 additions & 0 deletions docs/competition_creation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <competition> -u <user> [-y]
```

**Arguments:**

- `<competition>`: The competition slug.

**Options:**

- `-u, --user <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
Expand Down
52 changes: 52 additions & 0 deletions src/kaggle/api/kaggle_api_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
ApiUpdateCompetitionPageRequest,
ApiGetCompetitionSettingsRequest,
ApiListCompetitionHostsRequest,
ApiAddCompetitionHostRequest,
ApiUpdateCompetitionSettingsRequest,
ApiCreateCompetitionDataRequest,
ApiCreateCompetitionDataResponse,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/kaggle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <competition>` 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",
Expand Down Expand Up @@ -2538,6 +2566,7 @@ class Help(object):
"logs",
"pages",
"hosts",
"host-add",
"data",
"settings",
"solution",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_cli_competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
139 changes: 139 additions & 0 deletions tests/unit/test_competition_add_host.py
Original file line number Diff line number Diff line change
@@ -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()
Loading