Skip to content

Commit 5be8041

Browse files
diningPhilosopher64prabhakk-mw
authored andcommitted
Jupyterlab comm implementation with tests
1 parent 64b055c commit 5be8041

29 files changed

+2235
-80
lines changed

src/jupyter_matlab_kernel/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ipykernel.kernelapp import IPKernelApp
66

77
from jupyter_matlab_kernel import mwi_logger
8-
from jupyter_matlab_kernel.kernel_factory import KernelFactory
8+
from jupyter_matlab_kernel.kernels.kernel_factory import KernelFactory
99

1010
logger = mwi_logger.get(init=True)
1111

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright 2025 The MathWorks, Inc.
2+
3+
from .jsp_kernel import MATLABKernelUsingJSP
4+
from .mpm_kernel import MATLABKernelUsingMPM
5+
from .kernel_factory import KernelFactory
6+
7+
__all__ = ["MATLABKernelUsingJSP", "MATLABKernelUsingMPM", "KernelFactory"]

src/jupyter_matlab_kernel/base_kernel.py renamed to src/jupyter_matlab_kernel/kernels/base_kernel.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
)
2929
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
3030

31+
from jupyter_matlab_kernel.kernels.labextension_comm import LabExtensionCommunication
32+
33+
3134
_MATLAB_STARTUP_TIMEOUT = mwi_settings.get_process_startup_timeout()
3235

3336

@@ -75,7 +78,7 @@ def _fetch_jupyter_base_url(parent_pid: str, logger: Logger) -> str:
7578
return ""
7679

7780

78-
def _get_parent_pid() -> int:
81+
def _get_parent_pid(logger) -> int:
7982
"""
8083
Retrieves the parent process ID (PID) of the Kernel process.
8184
@@ -89,6 +92,8 @@ def _get_parent_pid() -> int:
8992
"""
9093
parent_pid = os.getppid()
9194

95+
logger.info("Type ", type(parent_pid), " parent pid ", parent_pid)
96+
9297
# Note: conda environments do not require this, and for these environments
9398
# sys.prefix == sys.base_prefix
9499
is_virtual_env = sys.prefix != sys.base_prefix
@@ -141,6 +146,15 @@ def __init__(self, *args, **kwargs):
141146
# Communication helper for interaction with backend MATLAB proxy
142147
self.mwi_comm_helper = None
143148

149+
self.labext_comm = LabExtensionCommunication(self)
150+
151+
# Override only comm handlers to keep implementation clean by separating
152+
# JupyterLab extension communication logic from core kernel functionality.
153+
# Other handlers (interrupt_request, execute_request, etc.) remain in base class.
154+
self.shell_handlers["comm_open"] = self.labext_comm.comm_open
155+
self.shell_handlers["comm_msg"] = self.labext_comm.comm_msg
156+
self.shell_handlers["comm_close"] = self.labext_comm.comm_close
157+
144158
# ipykernel Interface API
145159
# https://ipython.readthedocs.io/en/stable/development/wrapperkernels.html
146160

src/jupyter_matlab_kernel/jsp_kernel.py renamed to src/jupyter_matlab_kernel/kernels/jsp_kernel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import aiohttp.client_exceptions
1414
import requests
1515

16-
from jupyter_matlab_kernel import base_kernel as base
16+
from jupyter_matlab_kernel.kernels import base_kernel as base
1717
from jupyter_matlab_kernel import mwi_logger, test_utils
1818
from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper
1919
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
@@ -89,7 +89,7 @@ def start_matlab_proxy(logger=_logger):
8989
pass
9090

9191
# Use parent process id of the kernel to filter Jupyter Server from the list.
92-
jupyter_server_pid = base._get_parent_pid()
92+
jupyter_server_pid = base._get_parent_pid(logger)
9393
logger.debug(f"Resolved jupyter server pid: {jupyter_server_pid}")
9494

9595
nb_server = dict()

src/jupyter_matlab_kernel/kernel_factory.py renamed to src/jupyter_matlab_kernel/kernels/kernel_factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import os
44
from typing import Union
55

6-
from jupyter_matlab_kernel.jsp_kernel import MATLABKernelUsingJSP
7-
from jupyter_matlab_kernel.mpm_kernel import MATLABKernelUsingMPM
6+
from jupyter_matlab_kernel.kernels.jsp_kernel import MATLABKernelUsingJSP
7+
from jupyter_matlab_kernel.kernels.mpm_kernel import MATLABKernelUsingMPM
88

99

1010
class KernelFactory:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright 2025 The MathWorks, Inc.
2+
3+
from .communication import LabExtensionCommunication
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2025 The MathWorks, Inc.
2+
3+
from ipykernel.comm import Comm
4+
5+
6+
class LabExtensionCommunication:
7+
def __init__(self, kernel):
8+
self.comms = {}
9+
self.kernel = kernel
10+
self.log = kernel.log
11+
12+
def comm_open(self, stream, ident, msg):
13+
"""Handler to execute when labextension sends a message with 'comm_open' type ."""
14+
content = msg["content"]
15+
comm_id = content["comm_id"]
16+
target_name = content["target_name"]
17+
self.log.debug(
18+
f"Received comm_open message with id: {comm_id} and target_name: {target_name}"
19+
)
20+
comm = Comm(comm_id=comm_id, primary=False, target_name=target_name)
21+
self.comms[comm_id] = comm
22+
self.log.info("Successfully created communication channel with labextension")
23+
24+
async def comm_msg(self, stream, ident, msg):
25+
"""Handler to execute when labextension sends a message with 'comm_msg' type."""
26+
27+
content = msg["content"]
28+
data = content["data"]
29+
action_type = data["action"]
30+
payload = data["data"]
31+
comm_id = content["comm_id"]
32+
comm = self.comms.get(comm_id)
33+
34+
if not comm:
35+
self.log.error(
36+
"Received comm_msg but no communication channel is available"
37+
)
38+
raise Exception("No Communcation channel available")
39+
40+
self.log.debug(
41+
f"Received action_type:{action_type} with data:{payload} from the lab extension"
42+
)
43+
44+
def comm_close(self, stream, ident, msg):
45+
"""Handler to execute when labextension sends a message with 'comm_close' type."""
46+
content = msg["content"]
47+
comm_id = content["comm_id"]
48+
comm = self.comms.get(comm_id)
49+
50+
if comm:
51+
self.log.info(f"Comm closed with id: {comm_id}")
52+
del self.comms[comm_id]
53+
54+
else:
55+
self.log.warning(f"Attempted to close unknown comm_id: {comm_id}")

src/jupyter_matlab_kernel/mpm_kernel.py renamed to src/jupyter_matlab_kernel/kernels/mpm_kernel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import matlab_proxy_manager.lib.api as mpm_lib
1010
from requests.exceptions import HTTPError
1111

12-
from jupyter_matlab_kernel import base_kernel as base
12+
from jupyter_matlab_kernel.kernels import base_kernel as base
1313
from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper
1414
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
1515

src/jupyter_matlab_kernel/mwi_comm_helpers.py

Lines changed: 56 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2023-2024 The MathWorks, Inc.
1+
# Copyright 2023-2025 The MathWorks, Inc.
22
# Helper functions to communicate with matlab-proxy and MATLAB
33

44
import http
@@ -264,6 +264,9 @@ async def _send_feval_request_to_matlab(self, http_client, fname, nargout, *args
264264
self.logger.error("Error occurred during communication with matlab-proxy")
265265
raise resp.raise_for_status()
266266

267+
async def send_eval_request_to_matlab(self, mcode):
268+
return await self._send_eval_request_to_matlab(self._http_shell_client, mcode)
269+
267270
async def _send_eval_request_to_matlab(self, http_client, mcode):
268271
self.logger.debug("Sending Eval request to MATLAB")
269272
# Add the MATLAB code shipped with kernel to the Path
@@ -286,6 +289,7 @@ async def _send_eval_request_to_matlab(self, http_client, mcode):
286289
self.logger.debug(f"Response:\n{response_data}")
287290
try:
288291
eval_response = response_data["messages"]["EvalResponse"][0]
292+
289293
except KeyError:
290294
# In certain cases when the HTTPResponse is received, it does not
291295
# contain the expected data. In these cases most likely MATLAB has
@@ -296,49 +300,8 @@ async def _send_eval_request_to_matlab(self, http_client, mcode):
296300
)
297301
raise MATLABConnectionError()
298302

299-
# If the eval request succeeded, return the json decoded result.
300-
if not eval_response["isError"]:
301-
result_filepath = eval_response["responseStr"].strip()
302-
303-
# If the filepath in the response is not empty, read the result from
304-
# file and delete the file.
305-
if result_filepath != "":
306-
self.logger.debug(f"Found file with results: {result_filepath}")
307-
self.logger.debug("Reading contents of the file")
308-
with open(result_filepath, "r") as f:
309-
result = f.read().strip()
310-
self.logger.debug("Reading completed")
311-
try:
312-
import os
313-
314-
self.logger.debug(f"Deleting file: {result_filepath}")
315-
os.remove(result_filepath)
316-
except Exception:
317-
self.logger.error("Deleting file failed")
318-
else:
319-
self.logger.debug("No result in EvalResponse")
320-
result = ""
321-
322-
# If result is empty, populate dummy json
323-
if result == "":
324-
result = "[]"
325-
return json.loads(result)
326-
327-
# Handle the error cases
328-
if eval_response["messageFaults"]:
329-
# This happens when "Interrupt Kernel" is issued from a different
330-
# kernel. There may be other cases also.
331-
self.logger.error(
332-
f'Error during execution of Eval request in MATLAB:\n{eval_response["messageFaults"][0]["message"]}'
333-
)
334-
error_message = (
335-
"Failed to execute. Operation may have been interrupted by user."
336-
)
337-
else:
338-
# This happens when "Interrupt Kernel" is issued from the same kernel.
339-
# The responseStr contains the error message
340-
error_message = eval_response["responseStr"].strip()
341-
raise Exception(error_message)
303+
return eval_response
304+
342305
else:
343306
self.logger.error("Error during communication with matlab-proxy")
344307
raise resp.raise_for_status()
@@ -376,6 +339,54 @@ async def _send_jupyter_request_to_matlab(self, request_type, inputs, http_clien
376339
args = args + "," + str(cursor_pos)
377340

378341
eval_mcode = f"processJupyterKernelRequest({args})"
379-
resp = await self._send_eval_request_to_matlab(http_client, eval_mcode)
342+
eval_response = await self._send_eval_request_to_matlab(
343+
http_client, eval_mcode
344+
)
345+
resp = await self._read_eval_response_from_file(eval_response)
380346

381347
return resp
348+
349+
async def _read_eval_response_from_file(self, eval_response):
350+
# If the eval request succeeded, return the json decoded result.
351+
if not eval_response["isError"]:
352+
result_filepath = eval_response["responseStr"].strip()
353+
354+
# If the filepath in the response is not empty, read the result from
355+
# file and delete the file.
356+
if result_filepath != "":
357+
self.logger.debug(f"Found file with results: {result_filepath}")
358+
self.logger.debug("Reading contents of the file")
359+
with open(result_filepath, "r") as f:
360+
result = f.read().strip()
361+
self.logger.debug("Reading completed")
362+
try:
363+
import os
364+
365+
self.logger.debug(f"Deleting file: {result_filepath}")
366+
os.remove(result_filepath)
367+
except Exception:
368+
self.logger.error("Deleting file failed")
369+
else:
370+
self.logger.debug("No result in EvalResponse")
371+
result = ""
372+
373+
# If result is empty, populate dummy json
374+
if result == "":
375+
result = "[]"
376+
return json.loads(result)
377+
378+
# Handle the error cases
379+
if eval_response["messageFaults"]:
380+
# This happens when "Interrupt Kernel" is issued from a different
381+
# kernel. There may be other cases also.
382+
self.logger.error(
383+
f'Error during execution of Eval request in MATLAB:\n{eval_response["messageFaults"][0]["message"]}'
384+
)
385+
error_message = (
386+
"Failed to execute. Operation may have been interrupted by user."
387+
)
388+
else:
389+
# This happens when "Interrupt Kernel" is issued from the same kernel.
390+
# The responseStr contains the error message
391+
error_message = eval_response["responseStr"].strip()
392+
raise Exception(error_message)

src/jupyter_matlab_labextension/jest.config.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'node',
5-
testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
5+
testMatch: ['**/tests/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
6+
testPathIgnorePatterns: ['/node_modules/', '/src/tests/jest-setup.ts'],
67
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7-
};
8+
setupFilesAfterEnv: ['<rootDir>/src/tests/jest-setup.ts'],
9+
transform: {
10+
'^.+\\.(ts|tsx)$': 'ts-jest'
11+
},
12+
transformIgnorePatterns: [
13+
'/node_modules/(?!(@jupyterlab)/)' // Transform @jupyterlab packages
14+
],
15+
moduleNameMapper: {
16+
// Mock @jupyterlab/ui-components to avoid ES modules issues
17+
'@jupyterlab/ui-components': '<rootDir>/src/tests/mocks/ui-components.js'
18+
}
19+
};

0 commit comments

Comments
 (0)