diff --git a/ipykernel/kernelbase.py b/ipykernel/kernelbase.py index 02a0dd405..3e9ce0f7c 100644 --- a/ipykernel/kernelbase.py +++ b/ipykernel/kernelbase.py @@ -36,7 +36,6 @@ # jupyter_client < 5, use local now() now = datetime.now -import psutil import zmq from IPython.core.error import StdinNotImplementedError from jupyter_client.session import Session @@ -62,6 +61,19 @@ from .iostream import OutStream from .utils import LazyDict, _async_in_context +psutil: t.Any | None +try: + import psutil as _psutil +except ImportError: + psutil = None +else: + psutil = _psutil + +if psutil is None: + _NO_SUCH_PROCESS: tuple[type[BaseException], ...] = () +else: + _NO_SUCH_PROCESS = (psutil.NoSuchProcess,) + _AWAITABLE_MESSAGE: str = ( "For consistency across implementations, it is recommended that `{func_name}`" " either be a coroutine function (`async def`) or return an awaitable object" @@ -97,7 +109,7 @@ class Kernel(SingletonConfigurable): # attribute to override with a GUI eventloop = Any(None) - processes: dict[str, psutil.Process] = {} + processes: dict[int, t.Any] = {} @observe("eventloop") def _update_eventloop(self, change): @@ -1172,13 +1184,18 @@ async def usage_request(self, stream, ident, parent): if not self.session: return reply_content = {"hostname": socket.gethostname(), "pid": os.getpid()} + if psutil is None: + reply_content["cpu_count"] = os.cpu_count() + reply_msg = self.session.send(stream, "usage_reply", reply_content, parent, ident) + self.log.debug("%s", reply_msg) + return + current_process = psutil.Process() all_processes = [current_process, *current_process.children(recursive=True)] # Ensure 1) self.processes is updated to only current subprocesses # and 2) we reuse processes when possible (needed for accurate CPU) self.processes = { - process.pid: self.processes.get(process.pid, process) # type:ignore[misc,call-overload] - for process in all_processes + process.pid: self.processes.get(process.pid, process) for process in all_processes } reply_content["kernel_cpu"] = sum( [ @@ -1196,7 +1213,7 @@ async def usage_request(self, stream, ident, parent): cpu_percent = psutil.cpu_percent() # https://psutil.readthedocs.io/en/latest/index.html?highlight=cpu#psutil.cpu_percent # The first time cpu_percent is called it will return a meaningless 0.0 value which you are supposed to ignore. - if cpu_percent is not None and cpu_percent != 0.0: # type:ignore[redundant-expr] + if cpu_percent is not None and cpu_percent != 0.0: reply_content["host_cpu_percent"] = cpu_percent reply_content["cpu_count"] = psutil.cpu_count(logical=True) reply_content["host_virtual_memory"] = dict(psutil.virtual_memory()._asdict()) @@ -1476,7 +1493,7 @@ def _signal_children(self, signum): p.kill() else: p.send_signal(signum) - except psutil.NoSuchProcess: + except _NO_SUCH_PROCESS: pass def _process_children(self): @@ -1486,6 +1503,9 @@ def _process_children(self): - including parents and self with killpg - including all children that may have forked-off a new group """ + if psutil is None: + return [] + kernel_process = psutil.Process() all_children = kernel_process.children(recursive=True) if os.name == "nt": diff --git a/pyproject.toml b/pyproject.toml index 4df9c0a3a..4b6e5849f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dependencies = [ "matplotlib-inline>=0.1", 'appnope>=0.1.2;platform_system=="Darwin"', "pyzmq>=25", - "psutil>=5.7", "packaging>=22", ] @@ -59,6 +58,7 @@ test = [ "flaky", "ipyparallel", "pre-commit", + "psutil>=5.7", "pytest-asyncio>=0.23.5", "pytest-timeout" ] diff --git a/tests/test_kernel_direct.py b/tests/test_kernel_direct.py index f4a2e59b7..146ae3ead 100644 --- a/tests/test_kernel_direct.py +++ b/tests/test_kernel_direct.py @@ -4,6 +4,7 @@ # Distributed under the terms of the Modified BSD License. import os +import signal import warnings import pytest @@ -152,6 +153,32 @@ async def test_connect_request(kernel): await kernel.connect_request(kernel.shell_stream, "foo", {}) +async def test_usage_request_without_psutil(kernel, monkeypatch): + import ipykernel.kernelbase as kernelbase + + monkeypatch.setattr(kernelbase, "psutil", None) + reply = await kernel.test_control_message("usage_request", {}) + content = reply["content"] + + assert reply["header"]["msg_type"] == "usage_reply" + assert content["hostname"] + assert content["pid"] == os.getpid() + assert content["cpu_count"] == os.cpu_count() + assert "host_cpu_percent" not in content + assert "host_virtual_memory" not in content + assert "kernel_memory" not in content + + +async def test_child_process_fallbacks_without_psutil(kernel, monkeypatch): + import ipykernel.kernelbase as kernelbase + + monkeypatch.setattr(kernelbase, "psutil", None) + + assert kernel._process_children() == [] + kernel._signal_children(signal.SIGTERM) + await kernel._progressively_terminate_all_children() + + async def test_send_interrupt_children(kernel): kernel._send_interrupt_children()