diff --git a/ipykernel/kernelbase.py b/ipykernel/kernelbase.py index 073ce01d1..f6017bdb2 100644 --- a/ipykernel/kernelbase.py +++ b/ipykernel/kernelbase.py @@ -44,6 +44,7 @@ from traitlets.traitlets import ( Any, Bool, + CaselessStrEnum, Dict, Float, Instance, @@ -203,6 +204,24 @@ def _default_ident(self): """, ).tag(config=True) + advertise_debugger = CaselessStrEnum( + ["auto", "true", "false"], + default_value="auto", + help="""Whether to advertise "debugger" in the kernel_info_reply's + supported_features. + + "auto" (the default) imports the debugger module to find out whether + debugpy is actually usable. That import is not cheap, and every + frontend sends a kernel_info_request at startup, so a deployment that + already knows the answer can set this to "true" or "false" to answer + kernel_info without touching debugpy at all. + + Note that this only controls what is advertised. Setting it to "true" + where debugpy is unavailable does not make debugging work: debug + requests will still fail. + """, + ).tag(config=True) + # track associations with current request # Private interface @@ -1001,13 +1020,26 @@ async def connect_request(self, stream, ident, parent): self.log.debug("%s", msg) @property - def kernel_info(self): + def _debugger_advertised(self) -> bool: + """Whether to list "debugger" in kernel_info's supported_features. + + Only the "auto" setting has to import the debugger module (and so + debugpy) to answer; see the ``advertise_debugger`` trait. + """ + if self.advertise_debugger == "true": + return True + if self.advertise_debugger == "false": + return False from .debugger import _is_debugpy_available + return _is_debugpy_available + + @property + def kernel_info(self): supported_features: list[str] = [] if self._supports_kernel_subshells: supported_features.append("kernel subshells") - if _is_debugpy_available: + if self._debugger_advertised: supported_features.append("debugger") return { diff --git a/tests/test_kernel_direct.py b/tests/test_kernel_direct.py index 146ae3ead..78bc29568 100644 --- a/tests/test_kernel_direct.py +++ b/tests/test_kernel_direct.py @@ -5,6 +5,7 @@ import os import signal +import sys import warnings import pytest @@ -187,3 +188,25 @@ async def test_send_interrupt_children(kernel): # async def test_direct_usage_request(kernel): # reply = await kernel.test_control_message("usage_request", {}) # assert reply['header']['msg_type'] == 'usage_reply' + + +@pytest.mark.parametrize( + ("setting", "advertised"), + [("true", True), ("false", False)], +) +def test_advertise_debugger_explicit(kernel, monkeypatch, setting, advertised): + """ "true"/"false" answer kernel_info without importing the debugger.""" + # Poisoning the entry makes any `from .debugger import ...` raise. + monkeypatch.setitem(sys.modules, "ipykernel.debugger", None) + kernel.advertise_debugger = setting + + assert ("debugger" in kernel.kernel_info["supported_features"]) is advertised + + +def test_advertise_debugger_auto(kernel): + """ "auto" (the default) reports what the debugger module says.""" + from ipykernel.debugger import _is_debugpy_available + + assert kernel.advertise_debugger == "auto" + features = kernel.kernel_info["supported_features"] + assert ("debugger" in features) is _is_debugpy_available