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

* 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
28 changes: 28 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
46 changes: 43 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want better log/debug support. See line 967 of kaggle_api_extended.py.

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 @@ -2632,6 +2652,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
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()
Loading