Skip to content

Commit 3fa7c1e

Browse files
krisctlprabhakk-mw
authored andcommitted
Adds support for notebook magic %matlab [OPTIONS].
Enables notebooks to specify the use of a dedicated MATLAB. For more information, see (Technical Overview of MATLAB Kernel for Jupyter)[https://github.com/mathworks/jupyter-matlab-proxy/tree/main/src/jupyter_matlab_kernel#technical-overview] fixes #103 fixes #44
1 parent d208385 commit 3fa7c1e

File tree

21 files changed

+1074
-337
lines changed

21 files changed

+1074
-337
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ This opens a Jupyter notebook that supports MATLAB.
171171

172172
- **Licensing:** When you execute MATLAB code in a notebook for the first time, enter your MATLAB license information in the dialog box that appears. For details, see [Licensing](https://github.com/mathworks/matlab-proxy/blob/main/MATLAB-Licensing-Info.md). The MATLAB session can take a few minutes to start.
173173

174+
- **Sharing MATLAB across notebooks:** By default, multiple notebooks running on a Jupyter server share the underlying MATLAB process, so executing code in one notebook affects the workspace in others. To use a dedicated MATLAB for your kernel instead, use the magic `%%matlab new_session`. For details, see [Magic Commands for MATLAB Kernel](https://github.com/mathworks/jupyter-matlab-proxy/blob/main/src/jupyter_matlab_kernel/magics/README.md). To learn more about the kernel architecture, see [MATLAB Kernel for Jupyter](https://github.com/mathworks/jupyter-matlab-proxy/blob/main/src/jupyter_matlab_kernel/README.md).
175+
174176
- **MATLAB Kernel:** The MATLAB kernel supports tab completion and rich outputs:
175177
* Inline static plot images
176178
* LaTeX representation for symbolic expressions
@@ -181,7 +183,7 @@ This opens a Jupyter notebook that supports MATLAB.
181183

182184
For a technical overview of the MATLAB kernel, see [MATLAB Kernel for Jupyter](https://github.com/mathworks/jupyter-matlab-proxy/blob/main/src/jupyter_matlab_kernel/README.md).
183185

184-
- **Multiple notebooks:** Multiple notebooks running on a Jupyter server share the underlying MATLAB process, so executing code in one notebook affects the workspace in others. If you work in several notebooks simultaneously, be aware they share a workspace. For details, see [MATLAB Kernel for Jupyter](https://github.com/mathworks/jupyter-matlab-proxy/blob/main/src/jupyter_matlab_kernel/README.md).
186+
185187
- **Local functions:** With MATLAB R2022b and later, you can define a local function at the end of the cell where you want to call it:
186188
<p><img width="350" src="https://github.com/mathworks/jupyter-matlab-proxy/raw/main/img/local_functions.png"></p>
187189

179 KB
Loading

img/kernel-architecture.png

-32.8 KB
Binary file not shown.

src/jupyter_matlab_kernel/README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,23 @@ After installing the MATLAB Integration for Jupyter, your Jupyter environment sh
1212

1313
## Technical Overview
1414

15+
Start a Jupyter notebook to create a MATLAB kernel. When you run MATLAB code in a notebook for the first time, you see a licensing screen to enter your MATLAB license details. If a MATLAB process is not already running, one would be started automatically.
1516

16-
|<p align="center"><img width="600" src="https://github.com/mathworks/jupyter-matlab-proxy/raw/main/img/kernel-architecture.png"></p>|
17-
|--|
18-
|The diagram above illustrates that multiple Jupyter notebooks communicate with a shared MATLAB process, through the Jupyter notebook server.|
17+
<p align="center"><img width="600" src="../../img/kernel-architecture-dedicated.png"></p>
1918

20-
Start a Jupyter notebook to create a MATLAB kernel. When you run MATLAB code in a notebook for the first time, you see a licensing screen to enter your MATLAB license details. If a MATLAB process is not already running, Jupyter will start one.
19+
### Shared MATLAB Workspace (Default Behavior)
2120

22-
Multiple notebooks share the same MATLAB workspace. MATLAB processes commands from multiple notebooks in on a first-in, first-out basis.
21+
By default, multiple notebooks share the same MATLAB workspace. MATLAB processes commands from multiple notebooks on a first-in, first-out basis.
2322

24-
You can use kernel interrupts to stop MATLAB from processing a request. Remember that if cells from multiple notebooks are being run at the same time, the execution request you interrupt may not be from the notebook where you initated the interrupt.
23+
You can use kernel interrupts to stop MATLAB from processing a request. Remember that if cells from multiple notebooks are being run at the same time, the execution request you interrupt may not be from the notebook where you initiated the interrupt.
24+
25+
### Dedicated MATLAB Workspace (Optional Behavior)
26+
27+
You can now create a dedicated MATLAB session for your notebook by using the magic command `%%matlab new_session` in a cell. This starts a separate MATLAB process exclusively for that notebook, providing an isolated workspace that is not shared with other notebooks.
28+
29+
This is useful when you need to avoid conflicts with other notebooks or require an independent execution environment.
30+
31+
Once created, all subsequent MATLAB code in that notebook will execute in the dedicated session. Each dedicated session operates independently with its own workspace and execution queue.
2532

2633

2734
## Limitations
@@ -33,6 +40,6 @@ To request an enhancement or technical support, [create a GitHub issue](https://
3340

3441
----
3542

36-
Copyright 2023-2024 The MathWorks, Inc.
43+
Copyright 2023-2025 The MathWorks, Inc.
3744

3845
----

src/jupyter_matlab_kernel/base_kernel.py

Lines changed: 161 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
MagicExecutionEngine,
2626
get_completion_result_for_magics,
2727
)
28+
from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper
2829
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
2930

3031
from jupyter_matlab_kernel.comms import LabExtensionCommunication
@@ -141,7 +142,22 @@ def __init__(self, *args, **kwargs):
141142
self.magic_engine = MagicExecutionEngine(self.log)
142143

143144
# Communication helper for interaction with backend MATLAB proxy
144-
self.mwi_comm_helper = None
145+
self.mwi_comm_helper: Optional[MWICommHelper] = None
146+
147+
# Used to detect if this Kernel has been assigned a MATLAB-proxy server or not
148+
self.is_matlab_assigned = False
149+
150+
# Flag indicating whether this kernel is using a shared MATLAB instance
151+
self.is_shared_matlab: bool = True
152+
153+
# Keeps track of MATLAB version information for the MATLAB assigned to this Kernel
154+
self.matlab_version = None
155+
156+
# Keeps track of MATLAB root path information for the MATLAB assigned to this Kernel
157+
self.matlab_root_path = None
158+
159+
# Keeps track of the MATLAB licensing mode information for the MATLAB assigned to this Kernel
160+
self.licensing_mode = None
145161

146162
self.labext_comm = LabExtensionCommunication(self)
147163

@@ -165,8 +181,9 @@ async def interrupt_request(self, stream, ident, parent):
165181
"""
166182
self.log.debug("Received interrupt request from Jupyter")
167183
try:
168-
# Send interrupt request to MATLAB
169-
await self.mwi_comm_helper.send_interrupt_request_to_matlab()
184+
if self.is_matlab_assigned and self.mwi_comm_helper:
185+
# Send interrupt request to MATLAB
186+
await self.mwi_comm_helper.send_interrupt_request_to_matlab()
170187

171188
# Set the response to interrupt request.
172189
content = {"status": "ok"}
@@ -184,29 +201,6 @@ async def interrupt_request(self, stream, ident, parent):
184201

185202
self.session.send(stream, "interrupt_reply", content, parent, ident=ident)
186203

187-
def modify_kernel(self, states_to_modify):
188-
"""
189-
Used to modify MATLAB Kernel state
190-
Args:
191-
states_to_modify (dict): A key value pair of all the states to be modified.
192-
193-
"""
194-
self.log.debug(f"Modifying the kernel with {states_to_modify}")
195-
for key, value in states_to_modify.items():
196-
if hasattr(self, key):
197-
self.log.debug(f"set the value of {key} to {value}")
198-
setattr(self, key, value)
199-
200-
def handle_magic_output(self, output, outputs=None):
201-
if output["type"] == "modify_kernel":
202-
self.modify_kernel(output)
203-
else:
204-
self.display_output(output)
205-
if outputs is not None and not self.startup_checks_completed:
206-
# Outputs are cleared after startup_check.
207-
# Storing the magic outputs to display them after startup_check completes.
208-
outputs.append(output)
209-
210204
async def do_execute(
211205
self,
212206
code,
@@ -222,18 +216,19 @@ async def do_execute(
222216
https://jupyter-client.readthedocs.io/en/stable/messaging.html#execute
223217
"""
224218
self.log.debug(f"Received execution request from Jupyter with code:\n{code}")
219+
225220
try:
226-
accumulated_magic_outputs = []
227221
performed_startup_checks = False
228-
229-
for output in self.magic_engine.process_before_cell_execution(
230-
code, self.execution_count
231-
):
232-
self.handle_magic_output(output, accumulated_magic_outputs)
222+
accumulated_magic_outputs = await self._perform_before_cell_execution(code)
233223

234224
skip_cell_execution = self.magic_engine.skip_cell_execution()
235225
self.log.debug(f"Skipping cell execution is set to {skip_cell_execution}")
236226

227+
# Start a shared matlab-proxy (default) if not already started
228+
if not self.is_matlab_assigned and not skip_cell_execution:
229+
await self.start_matlab_proxy_and_comm_helper()
230+
self.is_matlab_assigned = True
231+
237232
# Complete one-time startup checks before sending request to MATLAB.
238233
# Blocking call, returns after MATLAB is started.
239234
if not skip_cell_execution:
@@ -275,9 +270,8 @@ async def do_execute(
275270
)
276271

277272
# Display all the outputs produced during the execution of code.
278-
for idx in range(len(outputs)):
279-
data = outputs[idx]
280-
self.log.debug(f"Displaying output {idx+1}:\n{data}")
273+
for idx, data in enumerate(outputs):
274+
self.log.debug(f"Displaying output {idx + 1}:\n{data}")
281275

282276
# Ignore empty values returned from MATLAB.
283277
if not data:
@@ -286,7 +280,7 @@ async def do_execute(
286280

287281
# Execute post execution of MAGICs
288282
for output in self.magic_engine.process_after_cell_execution():
289-
self.handle_magic_output(output)
283+
await self._handle_magic_output(output)
290284

291285
except Exception as e:
292286
self.log.error(
@@ -418,6 +412,119 @@ async def do_history(
418412

419413
# Helper functions
420414

415+
def _get_kernel_info(self):
416+
return {
417+
"is_shared_matlab": self.is_shared_matlab,
418+
"matlab_version": self.matlab_version,
419+
"matlab_root_path": self.matlab_root_path,
420+
"licensing_mode": self.licensing_mode,
421+
}
422+
423+
def _modify_kernel(self, states_to_modify):
424+
"""
425+
Used to modify MATLAB Kernel state
426+
Args:
427+
states_to_modify (dict): A key value pair of all the states to be modified.
428+
429+
"""
430+
self.log.info(f"Modifying the kernel with {states_to_modify}")
431+
for key, value in states_to_modify.items():
432+
if hasattr(self, key):
433+
self.log.debug(f"set the value of {key} to {value}")
434+
setattr(self, key, value)
435+
else:
436+
self.log.warning(f"Attribute with name: {key} not found in kernel")
437+
438+
async def _handle_magic_output(self, output):
439+
"""
440+
Handle the output from magic commands.
441+
442+
Args:
443+
output (dict): The output from a magic command.
444+
445+
Returns:
446+
dict or None: Returns the output if startup checks are not completed,
447+
otherwise returns None.
448+
449+
This method processes the output from magic commands. It handles kernel
450+
modifications, stores outputs before startup checks are completed, and
451+
displays outputs after startup checks are done.
452+
"""
453+
if output["type"] == "modify_kernel":
454+
self.log.debug("Handling modify_kernel output")
455+
self._modify_kernel(output)
456+
elif output["type"] == "callback":
457+
self.log.debug("Handling callback output")
458+
await self._invoke_callback_function(output.get("callback_function"))
459+
else:
460+
self.display_output(output)
461+
462+
if not self.startup_checks_completed:
463+
# Outputs are cleared after startup_check.
464+
# Storing the magic outputs to display them after startup_check completes.
465+
return output
466+
return None
467+
468+
async def _invoke_callback_function(self, callback_fx):
469+
"""
470+
Handles the invocation of callback function supplied by the magic command. Kernel injects
471+
itself as a parameter.
472+
473+
Args:
474+
callback_fx: Function to be called. Currently only supports calling async or async generator functions.
475+
"""
476+
if callback_fx:
477+
import inspect
478+
479+
if inspect.isasyncgenfunction(callback_fx):
480+
async for result in callback_fx(self):
481+
self.display_output(result)
482+
else:
483+
result = await callback_fx(self)
484+
if result:
485+
self.display_output(result)
486+
self.log.debug(f"Callback function {callback_fx} executed successfully")
487+
return None
488+
489+
async def start_matlab_proxy_and_comm_helper(self):
490+
"""
491+
Start MATLAB proxy and communication helper.
492+
493+
This method is intended to be overridden by subclasses to perform
494+
any necessary setup for matlab-proxy startup. The default implementation
495+
does nothing.
496+
497+
Returns:
498+
None
499+
500+
Raises:
501+
NotImplementedError: Always raised as this method must be implemented by subclasses.
502+
"""
503+
raise NotImplementedError("Subclasses should implement this method")
504+
505+
async def _perform_before_cell_execution(self, code) -> list:
506+
"""
507+
Perform actions before cell execution and handle magic outputs.
508+
509+
This method processes magic commands before cell execution and accumulates
510+
their outputs.
511+
512+
Args:
513+
code (str): The code to be executed.
514+
515+
Returns:
516+
list: A list of accumulated magic outputs.
517+
"""
518+
accumulated_magic_outputs = []
519+
for magic_output in self.magic_engine.process_before_cell_execution(
520+
code, self.execution_count
521+
):
522+
output = await self._handle_magic_output(magic_output)
523+
if output:
524+
accumulated_magic_outputs.append(output)
525+
526+
return accumulated_magic_outputs
527+
421528
def display_output(self, out):
422529
"""
423530
Common function to send execution outputs to Jupyter UI.
@@ -472,11 +579,8 @@ async def perform_startup_checks(
472579
self.log.error(f"Found a startup error: {self.startup_error}")
473580
raise self.startup_error
474581

475-
(
476-
is_matlab_licensed,
477-
matlab_status,
478-
matlab_proxy_has_error,
479-
) = await self.mwi_comm_helper.fetch_matlab_proxy_status()
582+
# Query matlab-proxy for its current status
583+
matlab_proxy_status = await self.mwi_comm_helper.fetch_matlab_proxy_status()
480584

481585
# Display iframe containing matlab-proxy to show login window if MATLAB
482586
# is not licensed using matlab-proxy. The iframe is removed after MATLAB
@@ -486,7 +590,7 @@ async def perform_startup_checks(
486590
# as src for iframe to avoid hardcoding any hostname/domain information. This is done to
487591
# ensure the kernel works in Jupyter deployments. VS Code however does not work the same way
488592
# as other browser based Jupyter clients.
489-
if not is_matlab_licensed:
593+
if not matlab_proxy_status.is_matlab_licensed:
490594
if not jupyter_base_url:
491595
# happens for non-jupyter environments (like VSCode), we expect licensing to
492596
# be completed before hand
@@ -519,22 +623,13 @@ async def perform_startup_checks(
519623
)
520624

521625
# Wait until MATLAB is started before sending requests.
522-
await self.poll_for_matlab_startup(
523-
is_matlab_licensed, matlab_status, matlab_proxy_has_error
524-
)
626+
await self.poll_for_matlab_startup(matlab_proxy_status)
525627

526-
async def poll_for_matlab_startup(
527-
self, is_matlab_licensed, matlab_status, matlab_proxy_has_error
528-
):
529-
"""Wait until MATLAB has started or time has run out"
628+
async def poll_for_matlab_startup(self, matlab_proxy_status):
629+
"""Wait until MATLAB has started or time has run out
530630
531631
Args:
532-
is_matlab_licensed (bool): A flag indicating whether MATLAB is
533-
licensed and eligible to start.
534-
matlab_status (str): A string representing the current status
535-
of the MATLAB startup process.
536-
matlab_proxy_has_error (bool): A flag indicating whether there
537-
is an error in the MATLAB proxy process during startup.
632+
matlab_proxy_status: The status object from matlab-proxy
538633
539634
Raises:
540635
MATLABConnectionError: If an error occurs while attempting to
@@ -544,11 +639,12 @@ async def poll_for_matlab_startup(
544639
self.log.debug("Waiting until MATLAB is started")
545640
timeout = 0
546641
while (
547-
matlab_status != "up"
642+
matlab_proxy_status
643+
and matlab_proxy_status.matlab_status != "up"
548644
and timeout != _MATLAB_STARTUP_TIMEOUT
549-
and not matlab_proxy_has_error
645+
and not matlab_proxy_status.matlab_proxy_has_error
550646
):
551-
if is_matlab_licensed:
647+
if matlab_proxy_status.is_matlab_licensed:
552648
if timeout == 0:
553649
self.log.debug("Licensing completed. Clearing output area")
554650
self.display_output(
@@ -565,11 +661,7 @@ async def poll_for_matlab_startup(
565661
)
566662
timeout += 1
567663
time.sleep(1)
568-
(
569-
is_matlab_licensed,
570-
matlab_status,
571-
matlab_proxy_has_error,
572-
) = await self.mwi_comm_helper.fetch_matlab_proxy_status()
664+
matlab_proxy_status = await self.mwi_comm_helper.fetch_matlab_proxy_status()
573665

574666
# If MATLAB is not available after 15 seconds of licensing information
575667
# being available either through user input or through matlab-proxy cache,
@@ -580,10 +672,15 @@ async def poll_for_matlab_startup(
580672
)
581673
raise MATLABConnectionError
582674

583-
if matlab_proxy_has_error:
675+
if not matlab_proxy_status or matlab_proxy_status.matlab_proxy_has_error:
584676
self.log.error("matlab-proxy encountered error.")
585677
raise MATLABConnectionError
586678

679+
# Update the kernel state with information from matlab proxy server
680+
self.licensing_mode = matlab_proxy_status.licensing_mode
681+
self.matlab_version = matlab_proxy_status.matlab_version
682+
self.matlab_root_path = await self.mwi_comm_helper.fetch_matlab_root_path()
683+
587684
self.log.debug("MATLAB is running, startup checks completed.")
588685

589686
def _extract_kernel_id_from_sys_args(self, args) -> str:

0 commit comments

Comments
 (0)