diff --git a/CHANGELOG.md b/CHANGELOG.md index 8157ccde..3145aeca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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` diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 87e0b2bd..c5c5b0e4 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -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//rules" + ), + 404: ( + "The resource was not found. Check the spelling of the reference you passed.\n" + "References are usually '/', which you can find in the resource's URL.\n" + "To search for one, try 'kaggle search '." + ), + 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( diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 289e55fa..c96f5260 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -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", @@ -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 @@ -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() @@ -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: @@ -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="") @@ -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 --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" diff --git a/tests/unit/test_cli_errors.py b/tests/unit/test_cli_errors.py new file mode 100644 index 00000000..40dfab5c --- /dev/null +++ b/tests/unit/test_cli_errors.py @@ -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()