Skip to content

Commit 722618c

Browse files
committed
Defer debugpy and psutil imports until they are actually needed
Both imports were paid for on every kernel startup even though most sessions never debug or ask for usage information. * `IPythonKernel.debugger` is now a lazily-created property; the debugger (and the debugpy import) is only built on the first debug request. `poll_stopped_queue` is scheduled at that point rather than in `start()`. * `debugger_class` was a `Type` trait, which traitlets resolves — and therefore imports — as soon as the kernel is instantiated. It is replaced by a plain `debugger_class_name` string; `debugger_class` remains as a deprecated property, and subclasses still overriding it keep working with a DeprecationWarning. * `psutil` is imported through `_get_psutil()`, which caches the (possibly None) result on first use. On a local test (where I have optimisation in IPython and traitlets as well), this brings the startup time from 220ms to 170ms
1 parent 342cf58 commit 722618c

3 files changed

Lines changed: 132 additions & 38 deletions

File tree

ipykernel/ipkernel.py

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
import sys
1111
import threading
1212
import typing as t
13+
import warnings
1314
from contextlib import contextmanager
1415
from functools import partial
1516

1617
import comm
1718
from IPython.core import release
1819
from IPython.utils.tokenutil import line_at_cursor, token_at_cursor
1920
from traitlets import Any, Bool, HasTraits, Instance, List, Type, default, observe, observe_compat
21+
from traitlets.utils.importstring import import_item
2022
from zmq.eventloop.zmqstream import ZMQStream
2123

2224
from .comm.comm import BaseComm
@@ -74,9 +76,15 @@ class IPythonKernel(KernelBase):
7476
shell = Instance("IPython.core.interactiveshell.InteractiveShellABC", allow_none=True)
7577
shell_class = Type(ZMQInteractiveShell)
7678

77-
# use fully-qualified name to ensure lazy import and prevent the issue from
78-
# https://github.com/ipython/ipykernel/issues/1198
79-
debugger_class = Type("ipykernel.debugger.Debugger")
79+
# Do not use a Type() trait: traitlets resolves (and thus imports) a Type
80+
# trait's string default as soon as the owning HasTraits instance is
81+
# created, which would force the expensive debugpy import on every
82+
# kernel startup. Resolved lazily instead, see the `debugger` property.
83+
debugger_class_name = "ipykernel.debugger.Debugger"
84+
85+
# Set by the deprecated `debugger_class` setter, takes precedence over
86+
# `debugger_class_name` when not None.
87+
_debugger_class: type | None = None
8088

8189
compiler_class = Type(XCachingCompiler)
8290

@@ -117,24 +125,14 @@ def __init__(self, **kwargs):
117125
"""Initialize the kernel."""
118126
super().__init__(**kwargs)
119127

120-
from .debugger import _is_debugpy_available
121-
122128
self._kernel_modules = [
123129
m.__file__ for m in sys.modules.copy().values() if hasattr(m, "__file__") and m.__file__
124130
]
125131

126-
# Initialize the Debugger
127-
if _is_debugpy_available:
128-
self.debugger = self.debugger_class(
129-
self.log,
130-
self.debugpy_stream,
131-
self._publish_debug_event,
132-
self.debug_shell_socket,
133-
self.session,
134-
self._kernel_modules,
135-
self.debug_just_my_code,
136-
self.filter_internal_frames,
137-
)
132+
# The debugger itself (and the debugpy import it requires) is
133+
# created lazily on first use, see the `debugger` property below.
134+
self._debugger = None
135+
self._debugger_init_attempted = False
138136

139137
# Initialize the InteractiveShell subclass
140138
self.shell = self.shell_class.instance(
@@ -216,10 +214,97 @@ def __init__(self, **kwargs):
216214
"file_extension": ".py",
217215
}
218216

219-
def dispatch_debugpy(self, msg):
220-
from .debugger import _is_debugpy_available
217+
@property
218+
def debugger_class(self):
219+
"""Deprecated, use :attr:`debugger_class_name` instead.
220+
221+
.. deprecated:: 7.4
222+
Accessing this attribute imports the debugger module (and thus
223+
debugpy), which is exactly what ``debugger_class_name`` exists to
224+
avoid.
225+
"""
226+
warnings.warn(
227+
"IPythonKernel.debugger_class is deprecated in ipykernel 7.4,"
228+
" use IPythonKernel.debugger_class_name instead.",
229+
DeprecationWarning,
230+
stacklevel=2,
231+
)
232+
return self._resolve_debugger_class()
233+
234+
@debugger_class.setter
235+
def debugger_class(self, value):
236+
warnings.warn(
237+
"IPythonKernel.debugger_class is deprecated in ipykernel 7.4,"
238+
" set IPythonKernel.debugger_class_name to the fully qualified"
239+
" name of the class instead.",
240+
DeprecationWarning,
241+
stacklevel=2,
242+
)
243+
self._debugger_class = value
244+
245+
def _resolve_debugger_class(self):
246+
"""Return the class to instantiate the debugger from.
247+
248+
Honors the deprecated ``debugger_class`` attribute, whether it was set
249+
on an instance or overridden by a subclass, before falling back to
250+
``debugger_class_name``.
251+
"""
252+
if self._debugger_class is not None:
253+
return self._debugger_class
254+
for klass in type(self).__mro__:
255+
if klass is IPythonKernel:
256+
break
257+
if "debugger_class" in klass.__dict__:
258+
warnings.warn(
259+
f"{klass.__module__}.{klass.__qualname__} overrides"
260+
" `debugger_class`, which is deprecated in ipykernel 7.4;"
261+
" override `debugger_class_name` with the fully qualified"
262+
" name of the class instead.",
263+
DeprecationWarning,
264+
stacklevel=3,
265+
)
266+
# The subclass attribute shadows the property defined here, so
267+
# this resolves the override (a plain class or a Type trait).
268+
return self.debugger_class
269+
return import_item(self.debugger_class_name)
270+
271+
@property
272+
def debugger(self):
273+
"""The debugger instance, created lazily on first use.
221274
222-
if _is_debugpy_available:
275+
Importing debugpy is expensive, so we avoid it until a debug
276+
request actually comes in.
277+
"""
278+
if self._debugger is None and not self._debugger_init_attempted:
279+
self._debugger_init_attempted = True
280+
from .debugger import _is_debugpy_available
281+
282+
if _is_debugpy_available:
283+
debugger_class = self._resolve_debugger_class()
284+
self._debugger = debugger_class(
285+
self.log,
286+
self.debugpy_stream,
287+
self._publish_debug_event,
288+
self.debug_shell_socket,
289+
self.session,
290+
self._kernel_modules,
291+
self.debug_just_my_code,
292+
self.filter_internal_frames,
293+
)
294+
asyncio.run_coroutine_threadsafe(
295+
self.poll_stopped_queue(), self.control_thread.io_loop.asyncio_loop
296+
)
297+
return self._debugger
298+
299+
@debugger.setter
300+
def debugger(self, value):
301+
# `debugger` used to be a plain instance attribute assigned in
302+
# __init__; keep it writable for subclasses that replace it.
303+
self._debugger = value
304+
self._debugger_init_attempted = True
305+
306+
def dispatch_debugpy(self, msg):
307+
if self.debugger is not None:
223308
# The first frame is the socket id, we can drop it
224309
frame = msg[1].bytes.decode("utf-8")
225310
self.log.debug("Debugpy received: %s", frame)
@@ -245,10 +330,6 @@ def start(self):
245330
else:
246331
self.debugpy_stream.on_recv(self.dispatch_debugpy, copy=False)
247332
super().start()
248-
if self.debugpy_stream:
249-
asyncio.run_coroutine_threadsafe(
250-
self.poll_stopped_queue(), self.control_thread.io_loop.asyncio_loop
251-
)
252333

253334
def set_parent(self, ident, parent, channel="shell"):
254335
"""Overridden from parent to tell the display hook and output streams
@@ -535,9 +616,7 @@ def do_complete(self, code, cursor_pos):
535616

536617
async def do_debug_request(self, msg):
537618
"""Handle a debug request."""
538-
from .debugger import _is_debugpy_available
539-
540-
if _is_debugpy_available:
619+
if self.debugger is not None:
541620
return await self.debugger.process_request(msg)
542621
return None
543622

ipykernel/kernelbase.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,29 @@
6161
from .iostream import OutStream
6262
from .utils import LazyDict, _async_in_context
6363

64-
psutil: t.Any | None
65-
try:
66-
import psutil as _psutil
67-
except ImportError:
68-
psutil = None
69-
else:
70-
psutil = _psutil
64+
psutil: t.Any | None = None
65+
_NO_SUCH_PROCESS: tuple[type[BaseException], ...] = ()
66+
_psutil_import_attempted = False
67+
68+
69+
def _get_psutil() -> t.Any | None:
70+
"""Import psutil on first use, caching the (possibly None) result.
71+
72+
psutil is optional and its import is not cheap, so we avoid paying for
73+
it unless something actually needs process/resource-usage information.
74+
"""
75+
global psutil, _NO_SUCH_PROCESS, _psutil_import_attempted # noqa: PLW0603
76+
if not _psutil_import_attempted:
77+
_psutil_import_attempted = True
78+
try:
79+
import psutil as _psutil
80+
except ImportError:
81+
pass
82+
else:
83+
psutil = _psutil
84+
_NO_SUCH_PROCESS = (psutil.NoSuchProcess,)
85+
return psutil
7186

72-
if psutil is None:
73-
_NO_SUCH_PROCESS: tuple[type[BaseException], ...] = ()
74-
else:
75-
_NO_SUCH_PROCESS = (psutil.NoSuchProcess,)
7687

7788
_AWAITABLE_MESSAGE: str = (
7889
"For consistency across implementations, it is recommended that `{func_name}`"
@@ -1184,6 +1195,7 @@ async def usage_request(self, stream, ident, parent):
11841195
if not self.session:
11851196
return
11861197
reply_content = {"hostname": socket.gethostname(), "pid": os.getpid()}
1198+
psutil = _get_psutil()
11871199
if psutil is None:
11881200
reply_content["cpu_count"] = os.cpu_count()
11891201
reply_msg = self.session.send(stream, "usage_reply", reply_content, parent, ident)
@@ -1503,6 +1515,7 @@ def _process_children(self):
15031515
- including parents and self with killpg
15041516
- including all children that may have forked-off a new group
15051517
"""
1518+
psutil = _get_psutil()
15061519
if psutil is None:
15071520
return []
15081521

tests/test_kernel_direct.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ async def test_usage_request_without_psutil(kernel, monkeypatch):
157157
import ipykernel.kernelbase as kernelbase
158158

159159
monkeypatch.setattr(kernelbase, "psutil", None)
160+
monkeypatch.setattr(kernelbase, "_psutil_import_attempted", True)
160161
reply = await kernel.test_control_message("usage_request", {})
161162
content = reply["content"]
162163

@@ -173,6 +174,7 @@ async def test_child_process_fallbacks_without_psutil(kernel, monkeypatch):
173174
import ipykernel.kernelbase as kernelbase
174175

175176
monkeypatch.setattr(kernelbase, "psutil", None)
177+
monkeypatch.setattr(kernelbase, "_psutil_import_attempted", True)
176178

177179
assert kernel._process_children() == []
178180
kernel._signal_children(signal.SIGTERM)

0 commit comments

Comments
 (0)