Skip to content
Closed
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

* 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`)
* Add `deadline` (Competition Deadline) to the competition settings command and bump `kagglesdk` to `>= 0.1.36`
Expand Down
27 changes: 27 additions & 0 deletions docs/kernels.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,33 @@ kaggle kernels status kerneler/sqlite-global-default

This command tells you whether the latest run of your kernel is still running, completed successfully, or failed.

## `kaggle kernels cancel`

Cancels an active kernel session.

**Usage:**

```bash
kaggle kernels cancel <SESSION_ID>
```

**Arguments:**

* `<SESSION_ID>`: The numeric ID of the active kernel session to cancel.

**Example:**

Cancel the session with ID `123456`:

```bash
kaggle kernels cancel 123456
```

**Purpose:**

Use this command to stop a stale or runaway kernel session without deleting the kernel itself.


## `kaggle kernels delete`

Deletes a kernel from Kaggle.
Expand Down
48 changes: 48 additions & 0 deletions src/kaggle/api/kaggle_api_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,34 @@ def __repr__(self):
return ""


ISSUE_TRACKER_URL = "https://github.com/Kaggle/kaggle-cli/issues"

# Actionable next steps keyed by HTTP status, shown under the raw error message.
_HTTP_ERROR_HINTS = {
403: (
"You don't have access to this resource.\n"
"If this is a competition, you may need to accept its rules first at\n"
" https://www.kaggle.com/competitions/<competition>/rules"
),
404: (
"The resource was not found. Check the spelling of the reference you passed.\n"
"References are usually '<owner>/<slug>', which you can find in the resource's URL.\n"
"To search for one, try 'kaggle search <query>'."
),
429: "You have been rate limited. Wait a little while before retrying.",
}


def format_http_error(error: HTTPError) -> str:
"""Builds a user-facing message for an HTTP error, with a hint when we have one."""
message = str(error)
response = error.response
hint = _HTTP_ERROR_HINTS.get(response.status_code) if response is not None else None
if response is not None and response.status_code >= 500:
hint = "This is a problem on Kaggle's side, not with your command. Please try again later."
return f"{message}\n\n{hint}" if hint else message


def print_auth_help() -> None:
"""Print friendly instructions for setting up Kaggle authentication."""
print(
Expand Down Expand Up @@ -7332,6 +7360,26 @@ def kernels_status_cli(self, kernel, kernel_opt=None):
else:
print('%s has status "%s"' % (kernel, status))

def kernels_cancel(self, session_id: int) -> None:
"""Cancels an active kernel session.

Args:
session_id: The numeric ID of the kernel session to cancel.
"""
if session_id <= 0:
raise ValueError("Session ID must be a positive integer.")

from kagglesdk.kernels.types.kernels_api_service import ApiCancelKernelSessionRequest

with self.build_kaggle_client() as kaggle:
request = ApiCancelKernelSessionRequest(kernel_session_id=session_id)
kaggle.kernels.kernels_api_client.cancel_kernel_session(request)

def kernels_cancel_cli(self, session_id: int) -> None:
"""A client wrapper for kernels_cancel."""
self.kernels_cancel(session_id)
print(f"Cancellation requested for session {session_id}.")

def kernels_logs(self, kernel: str | None) -> str:
"""Retrieves the execution log for a specified kernel.

Expand Down
59 changes: 56 additions & 3 deletions src/kaggle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@
import kaggle
from kaggle import KaggleApi
from kaggle import api
from kaggle.api.kaggle_api_extended import print_auth_help, OutputFormat
from kaggle.api.kaggle_api_extended import ISSUE_TRACKER_URL, format_http_error, print_auth_help, OutputFormat

# from rest import ApiException
ApiException = IOError


def main() -> None:
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, epilog=Help.examples)

parser.add_argument(
"-v",
Expand All @@ -49,6 +49,12 @@ def main() -> None:
action="store_true",
help="Disable out-of-date API version warning",
)
parser.add_argument(
"--debug",
dest="debug",
action="store_true",
help="Print the full traceback when an unexpected error occurs",
)

subparsers = parser.add_subparsers(title="commands", help=Help.kaggle, dest="command")
subparsers.required = True
Expand All @@ -72,6 +78,7 @@ def main() -> None:
if command_args["disable_version_warning"]:
KaggleApi.already_printed_version_warning = True
del command_args["disable_version_warning"]
debug = command_args.pop("debug")
if not api._authenticated:
api.authenticate()

Expand All @@ -82,7 +89,7 @@ def main() -> None:
if e.response is not None and e.response.status_code == 401:
print_auth_help()
else:
print(e, file=sys.stderr)
print(format_http_error(e), file=sys.stderr)
out = None
error = True
except ApiException as e:
Expand All @@ -96,6 +103,19 @@ def main() -> None:
except KeyboardInterrupt:
print("User cancelled operation")
out = None
except Exception as e:
# Anything reaching here is a bug in the CLI rather than user error, so
# show a short message instead of a traceback the user cannot act on.
if debug:
raise
print(f"{type(e).__name__}: {e}", file=sys.stderr)
print(
"\nIf this is unexpected, you can re-run with `kaggle --debug [command]` to see the full\n"
f"traceback. If you think this is a bug, please report it at {ISSUE_TRACKER_URL}",
file=sys.stderr,
)
out = None
error = True
if out is not None:
print(out, end="")

Expand Down Expand Up @@ -1437,6 +1457,17 @@ def parse_kernels(subparsers) -> None:
parser_kernels_status._action_groups.append(parser_kernels_status_optional)
parser_kernels_status.set_defaults(func=api.kernels_status_cli)

# Kernels cancel
parser_kernels_cancel = subparsers_kernels.add_parser(
"cancel", formatter_class=argparse.RawTextHelpFormatter, help=Help.command_kernels_cancel
)
parser_kernels_cancel_optional = parser_kernels_cancel._action_groups.pop()
parser_kernels_cancel_optional.add_argument(
"session_id", type=int, help=Help.param_kernel_session_id
)
parser_kernels_cancel._action_groups.append(parser_kernels_cancel_optional)
parser_kernels_cancel.set_defaults(func=api.kernels_cancel_cli)

# Kernels logs
parser_kernels_logs = subparsers_kernels.add_parser(
"logs", formatter_class=argparse.RawTextHelpFormatter, help=Help.command_kernels_logs
Expand Down Expand Up @@ -2632,6 +2663,26 @@ class Help(object):
kaggle += "\nauth {" + ", ".join(auth_choices) + "}"
kaggle += "\nquota"

examples = """examples:
Log in (opens a browser; only needed once):
kaggle auth login

Find something to work on:
kaggle competitions list
kaggle datasets list -s "air quality"

Download competition data into the current directory:
kaggle competitions download -c titanic

Submit to a competition:
kaggle competitions submit -c titanic -f submission.csv -m "my first entry"

Download a dataset and unzip it:
kaggle datasets download -d zillow/zecon --unzip

Run 'kaggle <command> --help' (e.g. 'kaggle competitions --help') for the
options a specific command accepts."""

group_competitions = "Commands related to Kaggle competitions"
group_datasets = "Commands related to Kaggle datasets"
group_kernels = "Commands related to Kaggle kernels"
Expand Down Expand Up @@ -2710,6 +2761,7 @@ class Help(object):
command_kernels_pull = "Pull down code from a kernel"
command_kernels_output = "Get data output from the latest kernel run"
command_kernels_status = "Display the status of the latest kernel run"
command_kernels_cancel = "Cancel an active kernel session"
command_kernels_logs = "Print the execution logs from the latest kernel run"
command_kernels_delete = "Delete a kernel"
command_kernels_topics = "List discussion topics for a kernel"
Expand Down Expand Up @@ -2919,6 +2971,7 @@ class Help(object):
param_kernel_parent = "Find children of the specified parent kernel"
param_kernel_competition = "Find kernels for a given competition slug"
param_kernel_dataset = "Find kernels for a given dataset slug. Format is " "{username/dataset-slug}"
param_kernel_session_id = "The numeric ID of the kernel session to cancel"
param_kernel_timeout = (
"Limit the run time of a kernel to the given number "
"of seconds. The global maximum time will not be "
Expand Down
141 changes: 141 additions & 0 deletions tests/unit/test_cli_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# coding=utf-8
import argparse
import io
import sys
import unittest
from unittest.mock import patch

from requests.exceptions import HTTPError
from requests.models import Response

import kaggle.cli as cli
from kaggle.api.kaggle_api_extended import format_http_error


def _http_error(status_code):
response = Response()
response.status_code = status_code
response.url = "https://www.kaggle.com/api/v1/competitions/nope"
return HTTPError(f"{status_code} Client Error for url: {response.url}", response=response)


def _run_main(argv, func):
"""Runs cli.main() with a stubbed-out command implementation."""
parse_args = argparse.ArgumentParser.parse_args

def parse_args_with_stub(self, *args, **kwargs):
namespace = parse_args(self, *args, **kwargs)
namespace.func = func
return namespace

stdout, stderr = io.StringIO(), io.StringIO()
with patch.object(sys, "argv", argv):
with patch("kaggle.cli.api") as mock_api:
mock_api._authenticated = True
with patch.object(argparse.ArgumentParser, "parse_args", parse_args_with_stub):
with patch("sys.stdout", stdout), patch("sys.stderr", stderr):
try:
cli.main()
exit_code = 0
except SystemExit as e:
exit_code = e.code
return exit_code, stdout.getvalue(), stderr.getvalue()


class TestFormatHttpError(unittest.TestCase):
def test_keeps_the_original_message(self):
self.assertIn("404 Client Error", format_http_error(_http_error(404)))

def test_404_suggests_checking_the_reference(self):
self.assertIn("kaggle search", format_http_error(_http_error(404)))

def test_403_mentions_competition_rules(self):
self.assertIn("rules", format_http_error(_http_error(403)))

def test_429_explains_rate_limiting(self):
self.assertIn("rate limited", format_http_error(_http_error(429)))

def test_5xx_is_attributed_to_kaggle(self):
self.assertIn("Kaggle's side", format_http_error(_http_error(503)))

def test_status_without_a_hint_is_left_alone(self):
self.assertEqual(
"400 Client Error for url: " + _http_error(400).response.url, format_http_error(_http_error(400))
)

def test_error_without_a_response_is_left_alone(self):
self.assertEqual("boom", format_http_error(HTTPError("boom")))


class TestMainErrorHandling(unittest.TestCase):
def test_http_error_prints_hint_and_exits_nonzero(self):
def raise_404(**kwargs):
raise _http_error(404)

exit_code, _, stderr = _run_main(["kaggle", "quota"], raise_404)

self.assertEqual(1, exit_code)
self.assertIn("404 Client Error", stderr)
self.assertIn("kaggle search", stderr)

def test_401_still_prints_auth_help(self):
def raise_401(**kwargs):
raise _http_error(401)

exit_code, stdout, _ = _run_main(["kaggle", "quota"], raise_401)

self.assertEqual(1, exit_code)
self.assertIn("Authentication required", stdout)

def test_unexpected_error_reports_a_bug_instead_of_a_traceback(self):
def raise_bug(**kwargs):
raise KeyError("some_missing_field")

exit_code, _, stderr = _run_main(["kaggle", "quota"], raise_bug)

self.assertEqual(1, exit_code)
self.assertIn("KeyError", stderr)
self.assertIn("--debug", stderr)
self.assertIn("github.com/Kaggle/kaggle-cli/issues", stderr)
self.assertNotIn("Traceback", stderr)

def test_debug_flag_lets_the_traceback_through(self):
def raise_bug(**kwargs):
raise KeyError("some_missing_field")

with self.assertRaises(KeyError):
_run_main(["kaggle", "--debug", "quota"], raise_bug)

def test_debug_flag_is_not_passed_to_the_command(self):
received = {}

def record(**kwargs):
received.update(kwargs)
return None

_run_main(["kaggle", "--debug", "quota"], record)

self.assertNotIn("debug", received)

def test_value_error_is_still_reported_without_a_bug_notice(self):
def raise_value_error(**kwargs):
raise ValueError("bad input")

exit_code, _, stderr = _run_main(["kaggle", "quota"], raise_value_error)

self.assertEqual(1, exit_code)
self.assertIn("bad input", stderr)
self.assertNotIn("If you think this is a bug", stderr)


class TestHelpExamples(unittest.TestCase):
def test_examples_cover_the_common_first_commands(self):
examples = cli.Help.examples

self.assertIn("kaggle auth login", examples)
self.assertIn("kaggle competitions download -c titanic", examples)
self.assertIn("kaggle competitions submit", examples)


if __name__ == "__main__":
unittest.main()
11 changes: 11 additions & 0 deletions tests/unit/test_cli_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,17 @@ def test_kernels_status_parser_with_option_kernel_succeeds(parser):
assert kwargs["kernel_opt"] == "owner/kernel-name"


def test_kernels_cancel_parser_missing_session_id_fails(parser):
with pytest.raises(SystemExit):
parser.dispatch(["kernels", "cancel"])


def test_kernels_cancel_parser_succeeds(parser):
func, kwargs = parser.dispatch(["kernels", "cancel", "12345"])
assert func.__name__ == "kernels_cancel_cli"
assert kwargs["session_id"] == 12345


def test_kernels_logs_parser_default_succeeds(parser):
func, kwargs = parser.dispatch(["kernels", "logs"])
assert func.__name__ == "kernels_logs_cli"
Expand Down
Loading