Skip to content

Commit c142f19

Browse files
diningPhilosopher64prabhakk-mw
authored andcommitted
Infrastructure to enable communication between LabExtension and Kernel.
1 parent 1a8affb commit c142f19

37 files changed

+2243
-319
lines changed

src/jupyter_matlab_kernel/__main__.py

Lines changed: 1 addition & 1 deletion
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
# Use ipykernel infrastructure to launch the MATLAB Kernel.
33

44
if __name__ == "__main__":

src/jupyter_matlab_kernel/base_kernel.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import time
1414
from logging import Logger
1515
from pathlib import Path
16-
from typing import Optional
1716

1817
import aiohttp
1918
import aiohttp.client_exceptions
@@ -28,6 +27,9 @@
2827
)
2928
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
3029

30+
from jupyter_matlab_kernel.comms import LabExtensionCommunication
31+
32+
3133
_MATLAB_STARTUP_TIMEOUT = mwi_settings.get_process_startup_timeout()
3234

3335

@@ -141,6 +143,18 @@ def __init__(self, *args, **kwargs):
141143
# Communication helper for interaction with backend MATLAB proxy
142144
self.mwi_comm_helper = None
143145

146+
self.labext_comm = LabExtensionCommunication(self)
147+
148+
# Custom handling of comm messages for jupyterlab extension communication.
149+
# https://jupyter-client.readthedocs.io/en/latest/messaging.html#custom-messages
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

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 .labextension import LabExtensionCommunication
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 .labextension 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+
15+
# As per jupyter messaging protocol https://jupyter-client.readthedocs.io/en/latest/messaging.html#custom-messages
16+
# 'content' will be present in msg, 'comm_id' and 'target_name' will be present in content.
17+
18+
content = msg["content"]
19+
comm_id = content["comm_id"]
20+
target_name = content["target_name"]
21+
self.log.debug(
22+
f"Received comm_open message with id: {comm_id} and target_name: {target_name}"
23+
)
24+
comm = Comm(comm_id=comm_id, primary=False, target_name=target_name)
25+
self.comms[comm_id] = comm
26+
self.log.debug(
27+
f"Successfully created communication channel with labextension on: {comm_id}"
28+
)
29+
30+
async def comm_msg(self, stream, ident, msg):
31+
"""Handler to execute when labextension sends a message with 'comm_msg' type."""
32+
# As per jupyter messaging protocol https://jupyter-client.readthedocs.io/en/latest/messaging.html#custom-messages
33+
# 'content' will be present in msg, 'comm_id' and 'data' will be present in content.
34+
payload = msg["content"]["data"]
35+
action_type, action_data = payload["action"], payload["data"]
36+
37+
self.log.debug(
38+
f"Received action_type:{action_type} with data:{action_data} from the lab extension"
39+
)
40+
41+
def comm_close(self, stream, ident, msg):
42+
"""Handler to execute when labextension sends a message with 'comm_close' type."""
43+
44+
# As per jupyter messaging protocol https://jupyter-client.readthedocs.io/en/latest/messaging.html#custom-messages
45+
# 'content' will be present in msg, 'comm_id' and 'data' will be present in content.
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.debug(f"Attempted to close unknown comm_id: {comm_id}")

src/jupyter_matlab_kernel/jsp_kernel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2024 The MathWorks, Inc.
1+
# Copyright 2024-2025 The MathWorks, Inc.
22

33
"""This module contains derived class implementation of MATLABKernel that uses
44
Jupyter Server to manage interactions with matlab-proxy & MATLAB.

src/jupyter_matlab_kernel/mwi_comm_helpers.py

Lines changed: 137 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
@@ -179,6 +179,13 @@ async def send_shutdown_request_to_matlab(self):
179179
)
180180

181181
async def send_interrupt_request_to_matlab(self):
182+
"""Send an interrupt request to MATLAB to stop current execution.
183+
184+
The interrupt request is sent through the control channel using a specific message format.
185+
186+
Raises:
187+
HTTPError: If the interrupt request fails or matlab-proxy communication errors occur
188+
"""
182189
self.logger.debug("Sending interrupt request to MATLAB")
183190
req_body = {
184191
"messages": {
@@ -201,6 +208,24 @@ async def send_interrupt_request_to_matlab(self):
201208
resp.raise_for_status()
202209

203210
async def _send_feval_request_to_matlab(self, http_client, fname, nargout, *args):
211+
"""Execute a MATLAB function call (feval) through the matlab-proxy.
212+
213+
Sends a function evaluation request to MATLAB, handling path setup and synchronous execution.
214+
215+
Args:
216+
http_client (aiohttp.ClientSession): HTTP client for sending the request
217+
fname (str): Name of the MATLAB function to call
218+
nargout (int): Number of output arguments expected
219+
*args: Variable arguments to pass to the MATLAB function
220+
221+
Returns:
222+
list: Results from the MATLAB function execution if successful
223+
Empty list if no outputs or nargout=0
224+
225+
Raises:
226+
MATLABConnectionError: If MATLAB connection is lost or response is invalid
227+
Exception: If function execution fails or is interrupted by user
228+
"""
204229
self.logger.debug("Sending FEval request to MATLAB")
205230
# Add the MATLAB code shipped with kernel to the Path
206231
path = [str(pathlib.Path(__file__).parent / "matlab")]
@@ -264,7 +289,36 @@ async def _send_feval_request_to_matlab(self, http_client, fname, nargout, *args
264289
self.logger.error("Error occurred during communication with matlab-proxy")
265290
raise resp.raise_for_status()
266291

292+
async def send_eval_request_to_matlab(self, mcode):
293+
"""Send an evaluation request to MATLAB using the shell client.
294+
295+
Args:
296+
mcode (str): MATLAB code to be evaluated
297+
298+
Returns:
299+
dict: The evaluation response from MATLAB containing results or error information
300+
301+
Raises:
302+
MATLABConnectionError: If MATLAB connection is not available
303+
HTTPError: If there is an error in communication with matlab-proxy
304+
"""
305+
return await self._send_eval_request_to_matlab(self._http_shell_client, mcode)
306+
267307
async def _send_eval_request_to_matlab(self, http_client, mcode):
308+
"""Internal method to send and process an evaluation request to MATLAB.
309+
310+
Args:
311+
http_client (aiohttp.ClientSession): HTTP client to use for the request
312+
mcode (str): MATLAB code to be evaluated
313+
314+
Returns:
315+
dict: The evaluation response containing results or error information
316+
from the MATLAB execution
317+
318+
Raises:
319+
MATLABConnectionError: If MATLAB connection is not available or response is invalid
320+
HTTPError: If there is an error in communication with matlab-proxy
321+
"""
268322
self.logger.debug("Sending Eval request to MATLAB")
269323
# Add the MATLAB code shipped with kernel to the Path
270324
path = str(pathlib.Path(__file__).parent / "matlab")
@@ -286,6 +340,7 @@ async def _send_eval_request_to_matlab(self, http_client, mcode):
286340
self.logger.debug(f"Response:\n{response_data}")
287341
try:
288342
eval_response = response_data["messages"]["EvalResponse"][0]
343+
289344
except KeyError:
290345
# In certain cases when the HTTPResponse is received, it does not
291346
# contain the expected data. In these cases most likely MATLAB has
@@ -296,54 +351,27 @@ async def _send_eval_request_to_matlab(self, http_client, mcode):
296351
)
297352
raise MATLABConnectionError()
298353

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)
354+
return eval_response
355+
342356
else:
343357
self.logger.error("Error during communication with matlab-proxy")
344358
raise resp.raise_for_status()
345359

346360
async def _send_jupyter_request_to_matlab(self, request_type, inputs, http_client):
361+
"""Process and send a Jupyter request to MATLAB using either feval or eval execution.
362+
363+
Args:
364+
request_type (str): Type of request (execute, complete, shutdown)
365+
inputs (list): List of input arguments for the request
366+
http_client (aiohttp.ClientSession): HTTP client to use for the request
367+
368+
Returns:
369+
dict: Response from MATLAB containing results of the request execution
370+
371+
Raises:
372+
MATLABConnectionError: If MATLAB connection is not available
373+
Exception: If request execution fails or is interrupted
374+
"""
347375
execution_request_type = "feval"
348376

349377
inputs.insert(0, request_type)
@@ -353,10 +381,14 @@ async def _send_jupyter_request_to_matlab(self, request_type, inputs, http_clien
353381
f"Using {execution_request_type} request type for communication with EC"
354382
)
355383

384+
resp = None
356385
if execution_request_type == "feval":
357386
resp = await self._send_feval_request_to_matlab(
358387
http_client, "processJupyterKernelRequest", 1, *inputs
359388
)
389+
390+
# The 'else' condition is an artifact and is present here incase we ever want to test
391+
# eval execution.
360392
else:
361393
user_mcode = inputs[2]
362394
# Construct a string which can be evaluated in MATLAB. For example
@@ -376,6 +408,66 @@ async def _send_jupyter_request_to_matlab(self, request_type, inputs, http_clien
376408
args = args + "," + str(cursor_pos)
377409

378410
eval_mcode = f"processJupyterKernelRequest({args})"
379-
resp = await self._send_eval_request_to_matlab(http_client, eval_mcode)
411+
eval_response = await self._send_eval_request_to_matlab(
412+
http_client, eval_mcode
413+
)
414+
resp = await self._read_eval_response_from_file(eval_response)
380415

381416
return resp
417+
418+
async def _read_eval_response_from_file(self, eval_response):
419+
"""Read and process MATLAB evaluation results from a response file.
420+
421+
Args:
422+
eval_response (dict): Response dictionary from MATLAB eval request containing
423+
file path and error information
424+
425+
Returns:
426+
dict: JSON decoded results from the response file
427+
428+
Raises:
429+
Exception: If evaluation failed or was interrupted by user
430+
"""
431+
# If the eval request succeeded, return the json decoded result.
432+
if not eval_response["isError"]:
433+
result_filepath = eval_response["responseStr"].strip()
434+
435+
# If the filepath in the response is not empty, read the result from
436+
# file and delete the file.
437+
if result_filepath != "":
438+
self.logger.debug(f"Found file with results: {result_filepath}")
439+
self.logger.debug("Reading contents of the file")
440+
with open(result_filepath, "r") as f:
441+
result = f.read().strip()
442+
self.logger.debug("Reading completed")
443+
try:
444+
import os
445+
446+
self.logger.debug(f"Deleting file: {result_filepath}")
447+
os.remove(result_filepath)
448+
except Exception:
449+
self.logger.error("Deleting file failed")
450+
else:
451+
self.logger.debug("No result in EvalResponse")
452+
result = ""
453+
454+
# If result is empty, populate dummy json
455+
if result == "":
456+
result = "[]"
457+
return json.loads(result)
458+
459+
# Handle the error cases
460+
if eval_response["messageFaults"]:
461+
# This happens when "Interrupt Kernel" is issued from a different
462+
# kernel. There may be other cases also.
463+
self.logger.error(
464+
f'Error during execution of Eval request in MATLAB:\n{eval_response["messageFaults"][0]["message"]}'
465+
)
466+
error_message = (
467+
"Failed to execute. Operation may have been interrupted by user."
468+
)
469+
else:
470+
# This happens when "Interrupt Kernel" is issued from the same kernel.
471+
# The responseStr contains the error message
472+
error_message = eval_response["responseStr"].strip()
473+
raise Exception(error_message)

0 commit comments

Comments
 (0)