diff --git a/docs/kernels.md b/docs/kernels.md index d148f522..1ee8968c 100644 --- a/docs/kernels.md +++ b/docs/kernels.md @@ -299,6 +299,30 @@ 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` + +Requests cancellation of one active kernel session. + +**Usage:** + +```bash +kaggle kernels cancel +``` + +**Arguments:** + +* ``: The numeric version number printed after `kaggle kernels push` succeeds. + +**Example:** + +```bash +kaggle kernels cancel 341475263 +``` + +**Purpose:** + +Use this command to stop a queued or running session without deleting the kernel or its version history. + ## `kaggle kernels delete` Deletes a kernel from Kaggle. diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index 87e0b2bd..a85eaf3a 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -209,6 +209,8 @@ ApiGetKernelRequest, ApiListKernelSessionOutputRequest, ApiGetKernelSessionStatusRequest, + ApiCancelKernelSessionRequest, + ApiCancelKernelSessionResponse, ApiSaveKernelResponse, ApiKernelMetadata, ApiDeleteKernelRequest, @@ -7332,6 +7334,38 @@ def kernels_status_cli(self, kernel, kernel_opt=None): else: print('%s has status "%s"' % (kernel, status)) + def kernels_cancel(self, kernel_session_id: int) -> ApiCancelKernelSessionResponse: + """Cancels one active kernel session. + + Args: + kernel_session_id: The numeric session id emitted as ``versionNumber`` by ``kernels_push``. + + Returns: + The cancellation response from Kaggle. + + Raises: + ValueError: If the session id is not positive or Kaggle rejects the cancellation. + """ + if kernel_session_id < 1: + raise ValueError("Kernel session id must be positive") + + request = ApiCancelKernelSessionRequest() + request.kernel_session_id = kernel_session_id + with self.build_kaggle_client() as kaggle: + response: ApiCancelKernelSessionResponse = kaggle.kernels.kernels_api_client.cancel_kernel_session(request) + if response.error_message: + raise ValueError(response.error_message) + return response + + def kernels_cancel_cli(self, kernel_session_id: int) -> None: + """Cancels one active kernel session from the command line. + + Args: + kernel_session_id: The numeric session id emitted as ``versionNumber`` by ``kernels_push``. + """ + self.kernels_cancel(kernel_session_id) + print(f"Kernel session {kernel_session_id} cancellation requested") + def kernels_logs(self, kernel: str | None) -> str: """Retrieves the execution log for a specified kernel. diff --git a/src/kaggle/cli.py b/src/kaggle/cli.py index 289e55fa..eab4b558 100644 --- a/src/kaggle/cli.py +++ b/src/kaggle/cli.py @@ -1437,6 +1437,15 @@ 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("kernel_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 @@ -2548,6 +2557,7 @@ class Help(object): "pull", "output", "status", + "cancel", "logs", "update", "delete", @@ -2710,6 +2720,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" @@ -2958,6 +2969,7 @@ class Help(object): ) param_kernel_logs_follow = "Stream live execution logs from the running session (like tail -f)" param_kernel_logs_interval = argparse.SUPPRESS # Deprecated; live streaming is push-based. + param_kernel_session_id = "Numeric session id emitted as the kernel version number by 'kaggle kernels push'" # Models params param_model = "Model URL suffix in format /" diff --git a/tests/unit/test_cli_kernels.py b/tests/unit/test_cli_kernels.py index 8e17c2db..610c34d2 100644 --- a/tests/unit/test_cli_kernels.py +++ b/tests/unit/test_cli_kernels.py @@ -1,4 +1,6 @@ # coding=utf-8 +from unittest.mock import MagicMock + import pytest @@ -247,6 +249,20 @@ def test_kernels_status_parser_with_option_kernel_succeeds(parser): assert kwargs["kernel_opt"] == "owner/kernel-name" +def test_kernels_cancel_parser_succeeds(parser): + func, kwargs = parser.dispatch(["kernels", "cancel", "341475263"]) + assert func.__name__ == "kernels_cancel_cli" + assert kwargs["kernel_session_id"] == 341475263 + + +def test_kernels_cancel_cli_delegates_to_api(api, capsys): + api.kernels_cancel = MagicMock() + api.kernels_cancel_cli(341475263) + + api.kernels_cancel.assert_called_once_with(341475263) + assert capsys.readouterr().out == "Kernel session 341475263 cancellation requested\n" + + def test_kernels_logs_parser_default_succeeds(parser): func, kwargs = parser.dispatch(["kernels", "logs"]) assert func.__name__ == "kernels_logs_cli" diff --git a/tests/unit/test_kernels_cancel.py b/tests/unit/test_kernels_cancel.py new file mode 100644 index 00000000..1d2c0eb7 --- /dev/null +++ b/tests/unit/test_kernels_cancel.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from kaggle.api.kaggle_api_extended import KaggleApi +from kagglesdk.kernels.types.kernels_api_service import ApiCancelKernelSessionResponse + + +class TestKernelsCancel: + def setup_method(self): + self.api = KaggleApi.__new__(KaggleApi) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_kernels_cancel_sends_the_session_id(self, mock_client): + response = ApiCancelKernelSessionResponse() + client = MagicMock() + client.kernels.kernels_api_client.cancel_kernel_session.return_value = response + mock_client.return_value.__enter__ = MagicMock(return_value=client) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + + result = self.api.kernels_cancel(341475263) + + assert result is response + request = client.kernels.kernels_api_client.cancel_kernel_session.call_args.args[0] + assert request.kernel_session_id == 341475263 + + @pytest.mark.parametrize("kernel_session_id", [0, -1]) + def test_kernels_cancel_rejects_non_positive_session_ids(self, kernel_session_id): + with pytest.raises(ValueError, match="must be positive"): + self.api.kernels_cancel(kernel_session_id) + + @patch.object(KaggleApi, "build_kaggle_client") + def test_kernels_cancel_surfaces_api_rejection(self, mock_client): + response = ApiCancelKernelSessionResponse() + response.error_message = "Session is not active" + client = MagicMock() + client.kernels.kernels_api_client.cancel_kernel_session.return_value = response + mock_client.return_value.__enter__ = MagicMock(return_value=client) + mock_client.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises(ValueError, match="Session is not active"): + self.api.kernels_cancel(341475263)