diff --git a/README.md b/README.md
index 1769840d..d4432d6e 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,8 @@ This opens a Jupyter notebook that supports MATLAB.
- **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.
+- **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).
+
- **MATLAB Kernel:** The MATLAB kernel supports tab completion and rich outputs:
* Inline static plot images
* LaTeX representation for symbolic expressions
@@ -181,7 +183,7 @@ This opens a Jupyter notebook that supports MATLAB.
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).
-- **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).
+
- **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:

diff --git a/img/kernel-architecture-dedicated.png b/img/kernel-architecture-dedicated.png
new file mode 100644
index 00000000..cb9a1fcb
Binary files /dev/null and b/img/kernel-architecture-dedicated.png differ
diff --git a/img/kernel-architecture.png b/img/kernel-architecture.png
deleted file mode 100644
index 367fb796..00000000
Binary files a/img/kernel-architecture.png and /dev/null differ
diff --git a/pyproject.toml b/pyproject.toml
index 40813ca2..2b6370d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "jupyter-matlab-proxy"
-version = "0.17.5"
+version = "0.18.0"
description = "MATLAB Integration for Jupyter"
readme = "README.md"
license = { file = "LICENSE.md" }
@@ -43,7 +43,7 @@ dependencies = [
"ipykernel>=6.0.3",
"jupyter-client",
"jupyter-server-proxy>=4.1.0",
- "matlab-proxy>=0.26.0",
+ "matlab-proxy>=0.30.0",
"psutil",
"requests",
]
diff --git a/src/jupyter_matlab_kernel/README.md b/src/jupyter_matlab_kernel/README.md
index aee316bf..55aae4a4 100644
--- a/src/jupyter_matlab_kernel/README.md
+++ b/src/jupyter_matlab_kernel/README.md
@@ -12,16 +12,23 @@ After installing the MATLAB Integration for Jupyter, your Jupyter environment sh
## Technical Overview
+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.
-|
|
-|--|
-|The diagram above illustrates that multiple Jupyter notebooks communicate with a shared MATLAB process, through the Jupyter notebook server.|
+
-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.
+### Shared MATLAB Workspace (Default Behavior)
-Multiple notebooks share the same MATLAB workspace. MATLAB processes commands from multiple notebooks in on a first-in, first-out basis.
+By default, multiple notebooks share the same MATLAB workspace. MATLAB processes commands from multiple notebooks on a first-in, first-out basis.
-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.
+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.
+
+### Dedicated MATLAB Workspace (Optional Behavior)
+
+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.
+
+This is useful when you need to avoid conflicts with other notebooks or require an independent execution environment.
+
+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.
## Limitations
@@ -33,6 +40,6 @@ To request an enhancement or technical support, [create a GitHub issue](https://
----
-Copyright 2023-2024 The MathWorks, Inc.
+Copyright 2023-2025 The MathWorks, Inc.
----
diff --git a/src/jupyter_matlab_kernel/base_kernel.py b/src/jupyter_matlab_kernel/base_kernel.py
index ec0369e0..253a48ed 100644
--- a/src/jupyter_matlab_kernel/base_kernel.py
+++ b/src/jupyter_matlab_kernel/base_kernel.py
@@ -25,6 +25,7 @@
MagicExecutionEngine,
get_completion_result_for_magics,
)
+from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
from jupyter_matlab_kernel.comms import LabExtensionCommunication
@@ -141,7 +142,22 @@ def __init__(self, *args, **kwargs):
self.magic_engine = MagicExecutionEngine(self.log)
# Communication helper for interaction with backend MATLAB proxy
- self.mwi_comm_helper = None
+ self.mwi_comm_helper: Optional[MWICommHelper] = None
+
+ # Used to detect if this Kernel has been assigned a MATLAB-proxy server or not
+ self.is_matlab_assigned = False
+
+ # Flag indicating whether this kernel is using a shared MATLAB instance
+ self.is_shared_matlab: bool = True
+
+ # Keeps track of MATLAB version information for the MATLAB assigned to this Kernel
+ self.matlab_version = None
+
+ # Keeps track of MATLAB root path information for the MATLAB assigned to this Kernel
+ self.matlab_root_path = None
+
+ # Keeps track of the MATLAB licensing mode information for the MATLAB assigned to this Kernel
+ self.licensing_mode = None
self.labext_comm = LabExtensionCommunication(self)
@@ -165,8 +181,9 @@ async def interrupt_request(self, stream, ident, parent):
"""
self.log.debug("Received interrupt request from Jupyter")
try:
- # Send interrupt request to MATLAB
- await self.mwi_comm_helper.send_interrupt_request_to_matlab()
+ if self.is_matlab_assigned and self.mwi_comm_helper:
+ # Send interrupt request to MATLAB
+ await self.mwi_comm_helper.send_interrupt_request_to_matlab()
# Set the response to interrupt request.
content = {"status": "ok"}
@@ -184,29 +201,6 @@ async def interrupt_request(self, stream, ident, parent):
self.session.send(stream, "interrupt_reply", content, parent, ident=ident)
- def modify_kernel(self, states_to_modify):
- """
- Used to modify MATLAB Kernel state
- Args:
- states_to_modify (dict): A key value pair of all the states to be modified.
-
- """
- self.log.debug(f"Modifying the kernel with {states_to_modify}")
- for key, value in states_to_modify.items():
- if hasattr(self, key):
- self.log.debug(f"set the value of {key} to {value}")
- setattr(self, key, value)
-
- def handle_magic_output(self, output, outputs=None):
- if output["type"] == "modify_kernel":
- self.modify_kernel(output)
- else:
- self.display_output(output)
- if outputs is not None and not self.startup_checks_completed:
- # Outputs are cleared after startup_check.
- # Storing the magic outputs to display them after startup_check completes.
- outputs.append(output)
-
async def do_execute(
self,
code,
@@ -222,18 +216,19 @@ async def do_execute(
https://jupyter-client.readthedocs.io/en/stable/messaging.html#execute
"""
self.log.debug(f"Received execution request from Jupyter with code:\n{code}")
+
try:
- accumulated_magic_outputs = []
performed_startup_checks = False
-
- for output in self.magic_engine.process_before_cell_execution(
- code, self.execution_count
- ):
- self.handle_magic_output(output, accumulated_magic_outputs)
+ accumulated_magic_outputs = await self._perform_before_cell_execution(code)
skip_cell_execution = self.magic_engine.skip_cell_execution()
self.log.debug(f"Skipping cell execution is set to {skip_cell_execution}")
+ # Start a shared matlab-proxy (default) if not already started
+ if not self.is_matlab_assigned and not skip_cell_execution:
+ await self.start_matlab_proxy_and_comm_helper()
+ self.is_matlab_assigned = True
+
# Complete one-time startup checks before sending request to MATLAB.
# Blocking call, returns after MATLAB is started.
if not skip_cell_execution:
@@ -275,9 +270,8 @@ async def do_execute(
)
# Display all the outputs produced during the execution of code.
- for idx in range(len(outputs)):
- data = outputs[idx]
- self.log.debug(f"Displaying output {idx+1}:\n{data}")
+ for idx, data in enumerate(outputs):
+ self.log.debug(f"Displaying output {idx + 1}:\n{data}")
# Ignore empty values returned from MATLAB.
if not data:
@@ -286,7 +280,7 @@ async def do_execute(
# Execute post execution of MAGICs
for output in self.magic_engine.process_after_cell_execution():
- self.handle_magic_output(output)
+ await self._handle_magic_output(output)
except Exception as e:
self.log.error(
@@ -418,6 +412,119 @@ async def do_history(
# Helper functions
+ def _get_kernel_info(self):
+ return {
+ "is_shared_matlab": self.is_shared_matlab,
+ "matlab_version": self.matlab_version,
+ "matlab_root_path": self.matlab_root_path,
+ "licensing_mode": self.licensing_mode,
+ }
+
+ def _modify_kernel(self, states_to_modify):
+ """
+ Used to modify MATLAB Kernel state
+ Args:
+ states_to_modify (dict): A key value pair of all the states to be modified.
+
+ """
+ self.log.info(f"Modifying the kernel with {states_to_modify}")
+ for key, value in states_to_modify.items():
+ if hasattr(self, key):
+ self.log.debug(f"set the value of {key} to {value}")
+ setattr(self, key, value)
+ else:
+ self.log.warning(f"Attribute with name: {key} not found in kernel")
+
+ async def _handle_magic_output(self, output):
+ """
+ Handle the output from magic commands.
+
+ Args:
+ output (dict): The output from a magic command.
+
+ Returns:
+ dict or None: Returns the output if startup checks are not completed,
+ otherwise returns None.
+
+ This method processes the output from magic commands. It handles kernel
+ modifications, stores outputs before startup checks are completed, and
+ displays outputs after startup checks are done.
+ """
+ if output["type"] == "modify_kernel":
+ self.log.debug("Handling modify_kernel output")
+ self._modify_kernel(output)
+ elif output["type"] == "callback":
+ self.log.debug("Handling callback output")
+ await self._invoke_callback_function(output.get("callback_function"))
+ else:
+ self.display_output(output)
+
+ if not self.startup_checks_completed:
+ # Outputs are cleared after startup_check.
+ # Storing the magic outputs to display them after startup_check completes.
+ return output
+ return None
+
+ async def _invoke_callback_function(self, callback_fx):
+ """
+ Handles the invocation of callback function supplied by the magic command. Kernel injects
+ itself as a parameter.
+
+ Args:
+ callback_fx: Function to be called. Currently only supports calling async or async generator functions.
+ """
+ if callback_fx:
+ import inspect
+
+ if inspect.isasyncgenfunction(callback_fx):
+ async for result in callback_fx(self):
+ self.display_output(result)
+ else:
+ result = await callback_fx(self)
+ if result:
+ self.display_output(result)
+ self.log.debug(f"Callback function {callback_fx} executed successfully")
+ return None
+
+ async def start_matlab_proxy_and_comm_helper(self):
+ """
+ Start MATLAB proxy and communication helper.
+
+ This method is intended to be overridden by subclasses to perform
+ any necessary setup for matlab-proxy startup. The default implementation
+ does nothing.
+
+ Returns:
+ None
+
+ Raises:
+ NotImplementedError: Always raised as this method must be implemented by subclasses.
+ """
+ raise NotImplementedError("Subclasses should implement this method")
+
+ async def _perform_before_cell_execution(self, code) -> list:
+ """
+ Perform actions before cell execution and handle magic outputs.
+
+ This method processes magic commands before cell execution and accumulates
+ their outputs.
+
+ Args:
+ code (str): The code to be executed.
+
+ Returns:
+ list: A list of accumulated magic outputs.
+ """
+ accumulated_magic_outputs = []
+ for magic_output in self.magic_engine.process_before_cell_execution(
+ code, self.execution_count
+ ):
+ output = await self._handle_magic_output(magic_output)
+ if output:
+ accumulated_magic_outputs.append(output)
+
+ return accumulated_magic_outputs
+
def display_output(self, out):
"""
Common function to send execution outputs to Jupyter UI.
@@ -472,11 +579,8 @@ async def perform_startup_checks(
self.log.error(f"Found a startup error: {self.startup_error}")
raise self.startup_error
- (
- is_matlab_licensed,
- matlab_status,
- matlab_proxy_has_error,
- ) = await self.mwi_comm_helper.fetch_matlab_proxy_status()
+ # Query matlab-proxy for its current status
+ matlab_proxy_status = await self.mwi_comm_helper.fetch_matlab_proxy_status()
# Display iframe containing matlab-proxy to show login window if MATLAB
# is not licensed using matlab-proxy. The iframe is removed after MATLAB
@@ -486,7 +590,7 @@ async def perform_startup_checks(
# as src for iframe to avoid hardcoding any hostname/domain information. This is done to
# ensure the kernel works in Jupyter deployments. VS Code however does not work the same way
# as other browser based Jupyter clients.
- if not is_matlab_licensed:
+ if not matlab_proxy_status.is_matlab_licensed:
if not jupyter_base_url:
# happens for non-jupyter environments (like VSCode), we expect licensing to
# be completed before hand
@@ -519,22 +623,13 @@ async def perform_startup_checks(
)
# Wait until MATLAB is started before sending requests.
- await self.poll_for_matlab_startup(
- is_matlab_licensed, matlab_status, matlab_proxy_has_error
- )
+ await self.poll_for_matlab_startup(matlab_proxy_status)
- async def poll_for_matlab_startup(
- self, is_matlab_licensed, matlab_status, matlab_proxy_has_error
- ):
- """Wait until MATLAB has started or time has run out"
+ async def poll_for_matlab_startup(self, matlab_proxy_status):
+ """Wait until MATLAB has started or time has run out
Args:
- is_matlab_licensed (bool): A flag indicating whether MATLAB is
- licensed and eligible to start.
- matlab_status (str): A string representing the current status
- of the MATLAB startup process.
- matlab_proxy_has_error (bool): A flag indicating whether there
- is an error in the MATLAB proxy process during startup.
+ matlab_proxy_status: The status object from matlab-proxy
Raises:
MATLABConnectionError: If an error occurs while attempting to
@@ -544,11 +639,12 @@ async def poll_for_matlab_startup(
self.log.debug("Waiting until MATLAB is started")
timeout = 0
while (
- matlab_status != "up"
+ matlab_proxy_status
+ and matlab_proxy_status.matlab_status != "up"
and timeout != _MATLAB_STARTUP_TIMEOUT
- and not matlab_proxy_has_error
+ and not matlab_proxy_status.matlab_proxy_has_error
):
- if is_matlab_licensed:
+ if matlab_proxy_status.is_matlab_licensed:
if timeout == 0:
self.log.debug("Licensing completed. Clearing output area")
self.display_output(
@@ -565,11 +661,7 @@ async def poll_for_matlab_startup(
)
timeout += 1
time.sleep(1)
- (
- is_matlab_licensed,
- matlab_status,
- matlab_proxy_has_error,
- ) = await self.mwi_comm_helper.fetch_matlab_proxy_status()
+ matlab_proxy_status = await self.mwi_comm_helper.fetch_matlab_proxy_status()
# If MATLAB is not available after 15 seconds of licensing information
# being available either through user input or through matlab-proxy cache,
@@ -580,10 +672,15 @@ async def poll_for_matlab_startup(
)
raise MATLABConnectionError
- if matlab_proxy_has_error:
+ if not matlab_proxy_status or matlab_proxy_status.matlab_proxy_has_error:
self.log.error("matlab-proxy encountered error.")
raise MATLABConnectionError
+ # Update the kernel state with information from matlab proxy server
+ self.licensing_mode = matlab_proxy_status.licensing_mode
+ self.matlab_version = matlab_proxy_status.matlab_version
+ self.matlab_root_path = await self.mwi_comm_helper.fetch_matlab_root_path()
+
self.log.debug("MATLAB is running, startup checks completed.")
def _extract_kernel_id_from_sys_args(self, args) -> str:
diff --git a/src/jupyter_matlab_kernel/jsp_kernel.py b/src/jupyter_matlab_kernel/jsp_kernel.py
index dd0c4793..8efbc73a 100644
--- a/src/jupyter_matlab_kernel/jsp_kernel.py
+++ b/src/jupyter_matlab_kernel/jsp_kernel.py
@@ -186,19 +186,24 @@ def __init__(self, *args, **kwargs):
async def do_shutdown(self, restart):
self.log.debug("Received shutdown request from Jupyter")
- try:
- await self.mwi_comm_helper.send_shutdown_request_to_matlab()
- await self.mwi_comm_helper.disconnect()
- except (
- MATLABConnectionError,
- aiohttp.client_exceptions.ClientResponseError,
- ) as e:
- self.log.error(
- f"Exception occurred while sending shutdown request to MATLAB:\n{e}"
- )
+ if self.is_matlab_assigned:
+ try:
+ await self.mwi_comm_helper.send_shutdown_request_to_matlab()
+ await self.mwi_comm_helper.disconnect()
+ except (
+ MATLABConnectionError,
+ aiohttp.client_exceptions.ClientResponseError,
+ ) as e:
+ self.log.error(
+ f"Exception occurred while sending shutdown request to MATLAB:\n{e}"
+ )
return super().do_shutdown(restart)
async def perform_startup_checks(self):
"""Overriding base function to provide a different iframe source"""
await super().perform_startup_checks(self.jupyter_base_url, "matlab")
+
+ async def start_matlab_proxy_and_comm_helper(self):
+ """Default implementation assumes that matlab is assigned"""
+ self.is_matlab_assigned = True
diff --git a/src/jupyter_matlab_kernel/magic_execution_engine.py b/src/jupyter_matlab_kernel/magic_execution_engine.py
index 58811a2f..9169aadf 100644
--- a/src/jupyter_matlab_kernel/magic_execution_engine.py
+++ b/src/jupyter_matlab_kernel/magic_execution_engine.py
@@ -181,7 +181,7 @@ def magic_executor(magics_for_execution, magic_execution_function):
for output_from_method in magic_method():
if output_from_method:
if "type" in output_from_method:
- yield output_from_method
+ yield (output_from_method)
else:
raise MagicError(
f"Invalid result returned by a Magic command. Contact Magic Author to fix. \n Error: {output_from_method}\n Does not contain a key called type."
diff --git a/src/jupyter_matlab_kernel/magics/README.md b/src/jupyter_matlab_kernel/magics/README.md
index 117bfe43..038f8f8d 100644
--- a/src/jupyter_matlab_kernel/magics/README.md
+++ b/src/jupyter_matlab_kernel/magics/README.md
@@ -15,6 +15,8 @@ This table lists the predefined magic commands you can use:
|---|---|---|---|---|
|`?` and `help`| Display documentation of given magic command.|Name of magic command.||`%%lsmagic?` or `%%help lsmagic`|
|`lsmagic`|List predefined magic commands.|||`%%lsmagic`|
+|`matlab new_session`|Starts a new MATLAB dedicated to the kernel instead of being shared across kernels.
Note: To change from a shared MATLAB to a dedicated MATLAB after you have already run MATLAB code in a notebook, you must first restart the kernel.|||`%%matlab new_session`|
+|`matlab info`|Print a summary of the MATLAB session currently being used for the kernel. The summary includes the MATLAB version, root path, licensing mode, and whether the MATLAB is shared or dedicated to a kernel. |||`%%matlab info`|
|`time`|Display time taken to execute a cell.|||`%%time`|
|`file`|Save contents of cell as a file in the notebook folder. You can use this command to define and save new functions. For details, see the section below on how to [Create New Functions Using the %%file Magic Command](#create-new-functions-using-the-the-file-magic-command)|Name of saved file.|The file magic command will save the contents of the cell, but not execute them in MATLAB.|`%%file myfile.m`|
@@ -63,6 +65,6 @@ Note: to use your function in MATLAB, remember to add the Jupyter notebook folde
---
-Copyright 2024 The MathWorks, Inc.
+Copyright 2024-2025 The MathWorks, Inc.
---
diff --git a/src/jupyter_matlab_kernel/magics/base/matlab_magic.py b/src/jupyter_matlab_kernel/magics/base/matlab_magic.py
index 51031089..8015399f 100644
--- a/src/jupyter_matlab_kernel/magics/base/matlab_magic.py
+++ b/src/jupyter_matlab_kernel/magics/base/matlab_magic.py
@@ -1,4 +1,4 @@
-# Copyright 2024 The MathWorks, Inc.
+# Copyright 2024-2025 The MathWorks, Inc.
from jupyter_matlab_kernel import mwi_logger
@@ -83,6 +83,11 @@ def before_cell_execute(self):
"murl": new_url,
"headers": new_headers,
}
+ 4. To invoke the callback function:
+ {
+ "type": "callback",
+ "callback_function": callback_function_name,
+ }
default: Empty dict ({}).
"""
yield {}
diff --git a/src/jupyter_matlab_kernel/magics/matlab.py b/src/jupyter_matlab_kernel/magics/matlab.py
new file mode 100644
index 00000000..5a207cbf
--- /dev/null
+++ b/src/jupyter_matlab_kernel/magics/matlab.py
@@ -0,0 +1,219 @@
+# Copyright 2025 The MathWorks, Inc.
+
+from jupyter_matlab_kernel.magics.base.matlab_magic import MATLABMagic
+from jupyter_matlab_kernel.mpm_kernel import MATLABKernelUsingMPM
+from jupyter_matlab_kernel.mwi_exceptions import MagicError
+
+# Module constants
+LICENSING_MODES = {
+ "mhlm": "Online Licensing",
+ "nlm": "Network License Manager",
+ "existing_license": "Existing License",
+}
+CMD_NEW_SESSION = "new_session"
+CMD_INFO = "info"
+EXISTING_NEW_SESSION_ERROR = "This kernel is already using a dedicated MATLAB.\n"
+DEDICATED_SESSION_CONFIRMATION_MSG = (
+ "A dedicated MATLAB session has been started for this kernel.\n"
+)
+
+
+async def handle_new_matlab_session(kernel: MATLABKernelUsingMPM):
+ """
+ Handles the creation of a new dedicated MATLAB session for the kernel.
+ Args:
+ kernel: The kernel instance.
+
+ Yields:
+ dict: Result dictionary containing execution status and confirmation message.
+ """
+ # Validations
+ if kernel.is_matlab_assigned:
+ if not kernel.is_shared_matlab:
+ # No-op if already in an isolated MATLAB session
+ yield {
+ "type": "execute_result",
+ "mimetype": ["text/plain", "text/html"],
+ "value": [
+ EXISTING_NEW_SESSION_ERROR,
+ f"{EXISTING_NEW_SESSION_ERROR}",
+ ],
+ }
+ return
+
+ else:
+ # Shared MATLAB session is already assigned
+ kernel.log.warning(
+ "Cannot start a new MATLAB session while an existing session is active."
+ )
+ raise MagicError(
+ "This notebook is currently linked to Default MATLAB session."
+ "To proceed, restart the kernel and run this magic command before any other MATLAB commands."
+ )
+ # Starting new dedicated MATLAB session
+ try:
+ kernel.is_shared_matlab = False
+ await kernel.start_matlab_proxy_and_comm_helper()
+ kernel.is_matlab_assigned = True
+
+ # Raises MATLABConnectionError if matlab-proxy failed to start in previous step
+ await kernel.perform_startup_checks()
+ kernel.startup_checks_completed = True
+ kernel.display_output({"type": "clear_output", "content": {"wait": False}})
+ except Exception as ex:
+ _reset_kernel_state(kernel)
+
+ # Try and cleanup the matlab-proxy process if it was started
+ await kernel.cleanup_matlab_proxy()
+
+ # Raising here so that matlab magic output can display the error
+ raise MagicError(str(ex)) from ex
+
+ yield {
+ "type": "execute_result",
+ "mimetype": ["text/plain", "text/html"],
+ "value": [
+ DEDICATED_SESSION_CONFIRMATION_MSG,
+ f"{DEDICATED_SESSION_CONFIRMATION_MSG}",
+ ],
+ }
+
+
+def _reset_kernel_state(kernel: MATLABKernelUsingMPM):
+ """
+ Resets the kernel to its initial state for MATLAB session management.
+
+ Args:
+ kernel (MATLABKernelUsingMPM): The MATLAB kernel instance whose state
+ needs to be reset.
+ """
+ kernel.is_shared_matlab = True
+ kernel.is_matlab_assigned = False
+ kernel.startup_checks_completed = False
+
+
+async def get_kernel_info(kernel):
+ """
+ Provides information about the current MATLAB kernel state related to MATLAB.
+
+ :param kernel: kernel object containing MATLAB information
+ """
+ output = _format_info(kernel._get_kernel_info())
+ yield {
+ "type": "execute_result",
+ "mimetype": ["text/plain", "text/html"],
+ "value": [
+ output,
+ f"{output}",
+ ],
+ }
+
+
+def _format_info(info) -> str:
+ """
+ Formats MATLAB information into a formatted string.
+
+ Args:
+ info: Dictionary containing MATLAB information.
+
+ Returns:
+ str: Formatted string with MATLAB information.
+ """
+ info_text = f'MATLAB Version: {info.get("matlab_version")}\n'
+ info_text += f'MATLAB Root Path: {info.get("matlab_root_path")}\n'
+ info_text += f'Licensing Mode: {LICENSING_MODES.get(info.get("licensing_mode"), "Unknown")}\n'
+ info_text += f'MATLAB Shared With Other Notebooks: {info.get("is_shared_matlab")}\n'
+ return info_text
+
+
+class matlab(MATLABMagic):
+ info_about_magic = f"""
+ Starts a new MATLAB that is dedicated to the current kernel, instead of being shared across kernels.
+
+ Usage: %%matlab {CMD_NEW_SESSION} or %%matlab {CMD_INFO}
+
+ Note: To change from a shared MATLAB to a dedicated MATLAB after you have already run MATLAB code in a notebook, you must first restart the kernel.
+ """
+ skip_matlab_execution = False
+
+ def before_cell_execute(self):
+ """
+ Processes the MATLAB magic command before cell execution.
+
+ This method validates the parameters passed to the MATLAB magic command,
+ and yields appropriate callbacks based on the command type.
+
+ Raises:
+ MagicError: If the number of parameters is not exactly one or if an unknown argument is provided.
+
+ Yields:
+ dict: A dictionary containing callback information for the kernel to process.
+ The dictionary must contain a key called "type". Kernel injects itself into the callback function while
+ making the call. This ensures Kernel object is available to magic instance.
+
+ Examples: To start a new matlab session or to display information about assigned MATLAB:
+ {
+ "type": "callback",
+ "callback_function": "function local to this module to be called from kernel",
+ }
+ """
+ if len(self.parameters) != 1:
+ raise MagicError(
+ f"matlab magic expects only one argument. Received: {self.parameters}. Choose one of: {[arg for arg in self.get_supported_arguments()]}"
+ )
+
+ command = self.parameters[0]
+ # Handles "new_session" argument
+ if command == CMD_NEW_SESSION:
+ yield {
+ "type": "callback",
+ "callback_function": handle_new_matlab_session,
+ }
+
+ # Handles "info" argument
+ elif command == CMD_INFO:
+ yield {
+ "type": "callback",
+ "callback_function": get_kernel_info,
+ }
+
+ # Handles unknown arguments
+ else:
+ raise MagicError(
+ f"Unknown argument {command}. Choose one of: {[arg for arg in self.get_supported_arguments()]}"
+ )
+
+ def do_complete(self, parameters, parameter_pos, cursor_pos):
+ """
+ Provides autocompletion for the matlab magic command.
+
+ Args:
+ parameters (list): The parameters passed to the magic command
+ parameter_pos (int): The position of the parameter being completed
+ cursor_pos (int): The cursor position within the parameter
+
+ Returns:
+ list: A list of possible completions
+ """
+ matches = []
+ if parameter_pos == 1:
+ # Show all the arguments under matlab magic
+ if cursor_pos == 0:
+ matches = self.get_supported_arguments()
+ # For partial input, match arguments that start with the current input
+ else:
+ matches = [
+ s
+ for s in self.get_supported_arguments()
+ if s.startswith(parameters[0][:cursor_pos])
+ ]
+ return matches
+
+ def get_supported_arguments(self) -> list:
+ """
+ Returns a list of supported arguments for the MATLAB magic command.
+
+ Returns:
+ list: A list of supported arguments
+ """
+ return [CMD_NEW_SESSION, CMD_INFO]
diff --git a/src/jupyter_matlab_kernel/mpm_kernel.py b/src/jupyter_matlab_kernel/mpm_kernel.py
index 1e2a9604..e52140a8 100644
--- a/src/jupyter_matlab_kernel/mpm_kernel.py
+++ b/src/jupyter_matlab_kernel/mpm_kernel.py
@@ -18,9 +18,6 @@ class MATLABKernelUsingMPM(base.BaseMATLABKernel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # Used to detect if this Kernel has been assigned a MATLAB-proxy server or not
- self.is_matlab_assigned = False
-
# Serves as the auth token to secure communication between Jupyter Server and MATLAB proxy manager
self.mpm_auth_token = None
@@ -35,41 +32,9 @@ def __init__(self, *args, **kwargs):
# ipykernel Interface API
# https://ipython.readthedocs.io/en/stable/development/wrapperkernels.html
- async def do_execute(
- self,
- code,
- silent,
- store_history=True,
- user_expressions=None,
- allow_stdin=False,
- *,
- cell_id=None,
- ):
- """
- Used by ipykernel infrastructure for execution. For more info, look at
- https://jupyter-client.readthedocs.io/en/stable/messaging.html#execute
- """
- self.log.debug(f"Received execution request from Jupyter with code:\n{code}")
-
- # Starts the matlab proxy process if this kernel hasn't yet been assigned a
- # matlab proxy and sets the attributes on kernel to talk to the correct backend.
- if not self.is_matlab_assigned:
- self.log.debug("Starting matlab-proxy")
- await self._start_matlab_proxy_and_comm_helper()
- self.is_matlab_assigned = True
-
- return await super().do_execute(
- code=code,
- silent=silent,
- store_history=store_history,
- user_expressions=user_expressions,
- allow_stdin=allow_stdin,
- cell_id=cell_id,
- )
-
async def do_shutdown(self, restart):
self.log.debug("Received shutdown request from Jupyter")
- if self.is_matlab_assigned:
+ if self.is_matlab_assigned and self.mwi_comm_helper:
try:
# Cleans up internal live editor state, client session
await self.mwi_comm_helper.send_shutdown_request_to_matlab()
@@ -80,25 +45,28 @@ async def do_shutdown(self, restart):
f"Exception occurred while sending shutdown request to MATLAB:\n{e}"
)
except Exception as e:
- self.log.debug("Exception during shutdown", e)
+ self.log.debug("Exception during shutdown: %s", e)
finally:
- # Shuts down matlab assigned to this Kernel (based on satisfying certain criteria)
- await mpm_lib.shutdown(
- self.parent_pid, self.kernel_id, self.mpm_auth_token
- )
- self.is_matlab_assigned = False
+ await self.cleanup_matlab_proxy()
return super().do_shutdown(restart)
+ # Helper functions
+
+ async def cleanup_matlab_proxy(self):
+ # Shuts down matlab-proxy and MATLAB assigned to this Kernel.
+ # matlab-proxy process is cleaned up when this Kernel process is the
+ # only reference to the assigned matlab-proxy instance
+ await mpm_lib.shutdown(self.parent_pid, self.kernel_id, self.mpm_auth_token)
+ self.is_matlab_assigned = False
+
async def perform_startup_checks(self):
"""Overriding base function to provide a different iframe source"""
await super().perform_startup_checks(
self.jupyter_base_url, f"{self.matlab_proxy_base_url}/"
)
- # Helper functions
-
- async def _start_matlab_proxy_and_comm_helper(self) -> None:
+ async def start_matlab_proxy_and_comm_helper(self) -> None:
"""
Starts the MATLAB proxy using the proxy manager and fetches its status.
"""
@@ -137,7 +105,7 @@ async def _initialize_matlab_proxy_with_mpm(self, _logger: Logger):
response = await mpm_lib.start_matlab_proxy_for_kernel(
caller_id=self.kernel_id,
parent_id=self.parent_pid,
- is_shared_matlab=True,
+ is_shared_matlab=self.is_shared_matlab,
base_url_prefix=self.jupyter_base_url,
)
err = response.get("errors")
diff --git a/src/jupyter_matlab_kernel/mwi_comm_helpers.py b/src/jupyter_matlab_kernel/mwi_comm_helpers.py
index 95df8105..f3f54281 100644
--- a/src/jupyter_matlab_kernel/mwi_comm_helpers.py
+++ b/src/jupyter_matlab_kernel/mwi_comm_helpers.py
@@ -4,6 +4,8 @@
import http
import json
import pathlib
+from dataclasses import dataclass
+from typing import Optional
import aiohttp
from matlab_proxy.util.mwi.embedded_connector.helpers import (
@@ -27,6 +29,27 @@ def check_licensing_status(data):
return licensing_status
+@dataclass
+class MATLABStatus:
+ """Represents the status of MATLAB and its licensing information.
+
+ Attributes:
+ is_matlab_licensed (bool): Indicates whether MATLAB is properly licensed.
+ matlab_status (str): Current status of the MATLAB instance.
+ matlab_proxy_has_error (bool): Whether the MATLAB proxy has encountered an error. Defaults to False.
+ licensing_mode (str): The type of licensing being used. Defaults to an empty string.
+ matlab_version (str): Version of the MATLAB instance. Defaults to an empty string.
+ matlab_root_path (str): Root installation path of MATLAB. Defaults to an empty string.
+ """
+
+ is_matlab_licensed: bool
+ matlab_status: str
+ matlab_proxy_has_error: bool = False
+ licensing_mode: str = ""
+ matlab_version: str = ""
+ matlab_root_path: str = ""
+
+
class MWICommHelper:
def __init__(
self, kernel_id, url, shell_loop, control_loop, headers=None, logger=_logger
@@ -88,35 +111,84 @@ async def disconnect(self):
if self._http_control_client:
await self._http_control_client.close()
- async def fetch_matlab_proxy_status(self):
+ async def fetch_matlab_root_path(self) -> Optional[str]:
+ """
+ Fetches the MATLAB root path from the matlab-proxy server.
+
+ Sends an HTTP GET request to the /get_env_config endpoint of matlab-proxy
+ to retrieve the filesystem path to the MATLAB installation.
+
+ Returns:
+ Optional[str]: The filesystem path to the MATLAB installation root directory,
+ or None if the path could not be retrieved.
+ """
+ self.logger.debug("Fetching MATLAB root path from matlab-proxy")
+ resp = await self._http_shell_client.get(self.url + "/get_env_config")
+ self.logger.debug(
+ f"Received status code for matlab-proxy get-env-config request: {resp.status}"
+ )
+
+ if resp.status == http.HTTPStatus.OK:
+ data = await resp.json()
+ self.logger.debug(f"get-env-config data:\n{data}")
+ matlab_data = data.get("matlab") or {}
+ return matlab_data.get("rootPath", None)
+
+ self.logger.warning(
+ "Error occurred during retrieving environment config for matlab-proxy"
+ )
+ return None
+
+ async def fetch_matlab_proxy_status(self) -> Optional[MATLABStatus]:
"""
- Sends HTTP request to /get_status endpoint of matlab-proxy and returns
- license and MATLAB status.
+ Fetches the current status of the MATLAB proxy server.
+
+ Sends an HTTP GET request to the /get_status endpoint of matlab-proxy
+ to retrieve information about MATLAB licensing, runtime status, and any
+ errors that may have occurred.
Returns:
- Tuple (bool, string):
- is_matlab_licensed (bool): True if matlab-proxy has license information, else False.
- matlab_status (string): Status of MATLAB. Values could be "up", "down" and "starting"
- matlab_proxy_has_error (bool): True if matlab-proxy faced any issues and unable to
- start MATLAB
+ Optional[MATLABStatus]: A MATLABStatus object containing:
+ - is_matlab_licensed (bool): True if MATLAB has valid license
+ information, False otherwise.
+ - matlab_status (str): Current MATLAB state. Possible values:
+ "up" (running), "down" (stopped).
+ - matlab_proxy_has_error (bool): True if matlab-proxy encountered
+ errors preventing MATLAB startup, False otherwise.
+ - licensing_mode (str): The type of licensing being used
+ (e.g., "mhlm", "nlm", "existing_license").
+ - matlab_version (str): The version string of the MATLAB installation
+ (e.g., "R2024a").
+
Raises:
- HTTPError: Occurs when connection to matlab-proxy cannot be established.
+ HTTPError: If the HTTP request fails or matlab-proxy returns a
+ non-200 status code, indicating connection issues or
+ server errors.
+
+ Example:
+ >>> status = await comm_helper.fetch_matlab_proxy_status()
+ >>> if status and status.matlab_status == "up":
+ ... print(f"MATLAB {status.matlab_version} is running")
"""
self.logger.debug("Fetching matlab-proxy status")
resp = await self._http_shell_client.get(self.url + "/get_status")
self.logger.debug(f"Received status code: {resp.status}")
if resp.status == http.HTTPStatus.OK:
data = await resp.json()
- self.logger.debug(f"Response:\n{data}")
- is_matlab_licensed = check_licensing_status(data)
-
- matlab_status = data["matlab"]["status"]
- matlab_proxy_has_error = data["error"] is not None
- return is_matlab_licensed, matlab_status, matlab_proxy_has_error
+ self.logger.debug(f"matlab-proxy status:\n{data}")
+ matlab_data = data.get("matlab") or {}
+ return MATLABStatus(
+ is_matlab_licensed=check_licensing_status(data),
+ matlab_status=matlab_data.get("status", ""),
+ matlab_proxy_has_error=data.get("error") is not None,
+ licensing_mode=(data.get("licensing") or {}).get("type", ""),
+ matlab_version=matlab_data.get("version", ""),
+ )
else:
self.logger.error("Error occurred during communication with matlab-proxy")
resp.raise_for_status()
+ return None
async def send_execution_request_to_matlab(self, code):
"""
@@ -281,7 +353,7 @@ async def _send_feval_request_to_matlab(self, http_client, fname, nargout, *args
)
else:
self.logger.error(
- f'Error during execution of FEval request in MATLAB:\n{feval_response["messageFaults"][0]["message"]}'
+ f"Error during execution of FEval request in MATLAB:\n{feval_response['messageFaults'][0]['message']}"
)
error_message = "Failed to execute. Please try again."
raise Exception(error_message)
diff --git a/src/jupyter_matlab_kernel/mwi_exceptions.py b/src/jupyter_matlab_kernel/mwi_exceptions.py
index 58671158..da45bc46 100644
--- a/src/jupyter_matlab_kernel/mwi_exceptions.py
+++ b/src/jupyter_matlab_kernel/mwi_exceptions.py
@@ -1,4 +1,4 @@
-# Copyright 2024 The MathWorks, Inc.
+# Copyright 2024-2025 The MathWorks, Inc.
# Custom Exceptions used in MATLAB Kernel
@@ -38,5 +38,5 @@ class MATLABConnectionError(Exception):
def __init__(self, message=None):
if message is None:
- message = 'Error connecting to MATLAB. Check the status of MATLAB by clicking the "Open MATLAB" button. Retry after ensuring MATLAB is running successfully'
+ message = 'Error connecting to MATLAB. Check the status of MATLAB by clicking the "Open MATLAB" button. Retry after ensuring MATLAB is running successfully.'
super().__init__(message)
diff --git a/src/jupyter_matlab_labextension/src/plugins/matlabToolbarButton.ts b/src/jupyter_matlab_labextension/src/plugins/matlabToolbarButton.ts
index abefac94..d0cc0dfd 100644
--- a/src/jupyter_matlab_labextension/src/plugins/matlabToolbarButton.ts
+++ b/src/jupyter_matlab_labextension/src/plugins/matlabToolbarButton.ts
@@ -12,18 +12,77 @@ import { PageConfig } from '@jupyterlab/coreutils';
import { DocumentRegistry } from '@jupyterlab/docregistry';
import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook';
-import { IDisposable } from '@lumino/disposable';
+import { DisposableDelegate } from '@lumino/disposable';
import { matlabIcon } from '../icons';
+function createMATLABToolbarButton (targetUrl: string): ToolbarButton {
+ return new ToolbarButton({
+ className: 'openMATLABButton matlab-toolbar-button-spaced',
+ icon: matlabIcon,
+ label: 'Open MATLAB',
+ tooltip: 'Open MATLAB',
+ onClick: (): void => {
+ window.open(targetUrl, '_blank');
+ }
+ });
+}
+
/** Wait until the kernel has loaded, then check if it is a MATLAB kernel. */
-export const insertButton = async (
- panel: NotebookPanel,
- matlabToolbarButton: ToolbarButton
-): Promise => {
- await panel.sessionContext.ready;
- if (panel.sessionContext.kernelDisplayName === 'MATLAB Kernel') {
- panel.toolbar.insertItem(10, 'matlabToolbarButton', matlabToolbarButton);
+export const insertButton = async (panel: NotebookPanel): Promise => {
+ try {
+ await panel.sessionContext.ready;
+ let targetUrl = '';
+ let matlabToolbarButton: ToolbarButton | null = null;
+
+ // Function to update the target URL based on kernel ID
+ const updateTargetUrl = (): void => {
+ // Check if the kernel is a MATLAB Kernel
+ if (panel.sessionContext.kernelDisplayName === 'MATLAB Kernel') {
+ let kernelId = '';
+
+ // Check that session and kernel exist and then retrieve kernel ID
+ if (panel.sessionContext.session && panel.sessionContext.session.kernel) {
+ kernelId = panel.sessionContext.session.kernel.id;
+ }
+
+ if (kernelId !== '') {
+ targetUrl = PageConfig.getBaseUrl() + 'matlab/' + kernelId + '/';
+
+ // Create the button if it doesn't exist yet
+ if (!matlabToolbarButton) {
+ matlabToolbarButton = createMATLABToolbarButton(targetUrl);
+ panel.toolbar.insertItem(10, 'matlabToolbarButton', matlabToolbarButton);
+ } else {
+ // Update the button's onClick handler
+ matlabToolbarButton.onClick = () => {
+ window.open(targetUrl, '_blank');
+ };
+ }
+ }
+ }
+ };
+
+ // Create Open MATLAB toolbar button
+ updateTargetUrl();
+
+ // Listen for kernel changes
+ panel.sessionContext.kernelChanged.connect(() => {
+ updateTargetUrl();
+ });
+
+ // Create a disposable that will clean up the listener
+ return new DisposableDelegate(() => {
+ if (matlabToolbarButton) {
+ matlabToolbarButton.dispose();
+ }
+ panel.sessionContext.kernelChanged.disconnect(() => {
+ updateTargetUrl();
+ });
+ });
+ } catch (error) {
+ console.error('Failed to insert MATLAB toolbar button: ', error);
+ return new DisposableDelegate(() => {});
}
};
@@ -32,21 +91,13 @@ implements DocumentRegistry.IWidgetExtension {
createNew (
panel: NotebookPanel,
context: DocumentRegistry.IContext
- ): IDisposable {
+ ): DisposableDelegate {
/** Create the toolbar button to open MATLAB in a browser. */
- const matlabToolbarButton = new ToolbarButton({
- className: 'openMATLABButton',
- icon: matlabIcon,
- label: 'Open MATLAB',
- tooltip: 'Open MATLAB',
- onClick: (): void => {
- const baseUrl = PageConfig.getBaseUrl();
- // "_blank" is the option to open in a new browser tab
- window.open(baseUrl + 'matlab', '_blank');
- }
+ insertButton(panel).catch(error => {
+ console.error('Error inserting MATLAB toolbar button:', error);
});
- insertButton(panel, matlabToolbarButton);
- return matlabToolbarButton;
+ // Return a dummy disposable immediately
+ return new DisposableDelegate(() => {});
}
}
diff --git a/src/jupyter_matlab_labextension/src/tests/matlabToolbarButton.test.ts b/src/jupyter_matlab_labextension/src/tests/matlabToolbarButton.test.ts
index 97fff9c9..c2167ac3 100644
--- a/src/jupyter_matlab_labextension/src/tests/matlabToolbarButton.test.ts
+++ b/src/jupyter_matlab_labextension/src/tests/matlabToolbarButton.test.ts
@@ -9,6 +9,7 @@ import {
import { NotebookPanel, INotebookModel } from '@jupyterlab/notebook';
import { JupyterFrontEnd } from '@jupyterlab/application';
import { DocumentRegistry } from '@jupyterlab/docregistry';
+import { Signal } from '@lumino/signaling';
jest.mock('../icons', () => ({
matlabIcon: {
@@ -17,9 +18,6 @@ jest.mock('../icons', () => ({
}
}));
-// Get the mocked matlabIcon
-const { matlabIcon } = jest.requireMock('../icons');
-
// Mock JupyterLab dependencies
jest.mock('@jupyterlab/apputils', () => ({
ToolbarButton: jest.fn().mockImplementation((options: any) => ({
@@ -38,36 +36,34 @@ jest.mock('@jupyterlab/coreutils', () => ({
const originalWindowOpen = window.open;
window.open = jest.fn();
-// Mock for NotebookPanel
-const createMockNotebookPanel = (kernelDisplayName = 'MATLAB Kernel') => ({
- sessionContext: {
- ready: Promise.resolve(),
- kernelDisplayName,
- session: null,
- initialize: jest.fn(),
- isReady: true,
- isTerminating: false,
- // Add other required methods as
- dispose: jest.fn()
- },
- toolbar: {
- insertItem: jest.fn(),
- names: []
- // addItem: jest.fn(),
- // insertAfter: jest.fn(),
- // insertBefore: jest.fn()
- }
-});
-
-// Mock for ToolbarButton
-const createMockToolbarButton = () => ({
- className: 'openMATLABButton',
- icon: matlabIcon,
- label: 'Open MATLAB',
- tooltip: 'Open MATLAB',
- onClick: expect.any(Function),
- dispose: jest.fn()
-});
+// Mock for NotebookPanel with kernel change signal
+const createMockNotebookPanel = (kernelDisplayName = 'MATLAB Kernel', kernelId = '12345') => {
+ const kernelChangedSignal = new Signal({});
+
+ return {
+ sessionContext: {
+ ready: Promise.resolve(),
+ kernelDisplayName,
+ session: kernelId
+ ? {
+ kernel: {
+ id: kernelId
+ }
+ }
+ : null,
+ kernelChanged: kernelChangedSignal,
+ initialize: jest.fn(),
+ isReady: true,
+ isTerminating: false,
+ // Add other required methods as
+ dispose: jest.fn()
+ },
+ toolbar: {
+ insertItem: jest.fn(),
+ names: []
+ }
+ };
+};
// Mock for JupyterFrontEnd
const createMockJupyterFrontEnd = () => ({
@@ -91,34 +87,84 @@ describe('matlab_browser_button', () => {
});
describe('insertButton', () => {
- test('should insert button when kernel is MATLAB Kernel', async () => {
+ test('should insert button when kernel is MATLAB Kernel with valid kernel ID', async () => {
// Arrange
- const panel = createMockNotebookPanel('MATLAB Kernel');
- const button = createMockToolbarButton();
+ const panel = createMockNotebookPanel('MATLAB Kernel', 'test-kernel-123');
// Act
- await insertButton(panel as unknown as NotebookPanel, button as any);
+ await insertButton(panel as unknown as NotebookPanel);
// Assert
expect(panel.toolbar!.insertItem).toHaveBeenCalledWith(
10,
'matlabToolbarButton',
- button
+ expect.objectContaining({
+ className: 'openMATLABButton matlab-toolbar-button-spaced',
+ label: 'Open MATLAB'
+ })
);
});
test('should not insert button when kernel is not MATLAB Kernel', async () => {
// Arrange
const panel = createMockNotebookPanel('Python 3');
- const button = createMockToolbarButton();
// Act
- await insertButton(panel as unknown as NotebookPanel, button as any);
+ await insertButton(panel as unknown as NotebookPanel);
// Assert
expect(panel.toolbar!.insertItem).not.toHaveBeenCalled();
});
+ test('should not insert button when kernel ID is empty', async () => {
+ const panel = createMockNotebookPanel('MATLAB Kernel', '');
+
+ await insertButton(panel as unknown as NotebookPanel);
+
+ expect(panel.toolbar.insertItem).not.toHaveBeenCalled();
+ });
+
+ test('should not insert button when session is null', async () => {
+ const panel = createMockNotebookPanel('MATLAB Kernel');
+ panel.sessionContext.session = null;
+
+ await insertButton(panel as unknown as NotebookPanel);
+
+ expect(panel.toolbar.insertItem).not.toHaveBeenCalled();
+ });
+
+ test('should not insert button when kernel is null', async () => {
+ const panel = createMockNotebookPanel('MATLAB Kernel');
+ panel.sessionContext.session = { kernel: null as any };
+
+ await insertButton(panel as unknown as NotebookPanel);
+
+ expect(panel.toolbar.insertItem).not.toHaveBeenCalled();
+ });
+
+ test('should construct correct target URL with kernel ID', async () => {
+ const ToolbarButtonMock = jest.requireMock('@jupyterlab/apputils').ToolbarButton;
+ let capturedOnClick: () => void = () => {};
+
+ ToolbarButtonMock.mockImplementationOnce((options: any) => {
+ capturedOnClick = options.onClick;
+ return {
+ ...options,
+ dispose: jest.fn()
+ };
+ });
+
+ const panel = createMockNotebookPanel('MATLAB Kernel', 'kernel-abc-123');
+ await insertButton(panel as unknown as NotebookPanel);
+
+ capturedOnClick();
+
+ expect(window.open).toHaveBeenCalledWith(
+ 'http://localhost:8888/matlab/kernel-abc-123/',
+ '_blank'
+ );
+ });
+
test('should wait for session context to be ready before checking kernel', async () => {
// Arrange
const readyPromise = new Promise((resolve) =>
@@ -127,16 +173,21 @@ describe('matlab_browser_button', () => {
const panel = {
sessionContext: {
ready: readyPromise,
- kernelDisplayName: 'MATLAB Kernel'
+ kernelDisplayName: 'MATLAB Kernel',
+ session: {
+ kernel: {
+ id: 'test-kernel'
+ }
+ },
+ kernelChanged: new Signal({})
},
toolbar: {
insertItem: jest.fn()
}
};
- const button = createMockToolbarButton();
// Act
- const insertPromise = insertButton(panel as any, button as any);
+ const insertPromise = insertButton(panel as any);
// Assert - insertItem should not be called before ready resolves
expect(panel.toolbar.insertItem).not.toHaveBeenCalled();
@@ -145,11 +196,50 @@ describe('matlab_browser_button', () => {
await insertPromise;
// Now insertItem should have been called
- expect(panel.toolbar.insertItem).toHaveBeenCalledWith(
- 10,
- 'matlabToolbarButton',
- button
+ expect(panel.toolbar.insertItem).toHaveBeenCalled();
+ });
+
+ test('should update button onClick when kernel changes', async () => {
+ const ToolbarButtonMock = jest.requireMock('@jupyterlab/apputils').ToolbarButton;
+ const mockButton = {
+ onClick: jest.fn(),
+ dispose: jest.fn()
+ };
+
+ ToolbarButtonMock.mockReturnValue(mockButton);
+
+ const panel = createMockNotebookPanel('MATLAB Kernel', 'kernel-1');
+ await insertButton(panel as unknown as NotebookPanel);
+
+ // Simulate kernel change
+ panel.sessionContext.session = {
+ kernel: { id: 'kernel-2' }
+ };
+ panel.sessionContext.kernelChanged.emit({});
+
+ // Wait for async operations
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockButton.onClick).toBeDefined();
+ });
+
+ test('should handle errors gracefully', async () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ const panel = {
+ sessionContext: {
+ ready: Promise.reject(new Error('Session failed'))
+ }
+ };
+
+ const result = await insertButton(panel as any);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to insert MATLAB toolbar button: ',
+ expect.any(Error)
);
+ expect(result.dispose).toBeDefined();
+
+ consoleErrorSpy.mockRestore();
});
});
@@ -160,25 +250,10 @@ describe('matlab_browser_button', () => {
beforeEach(() => {
extension = new MatlabToolbarButtonExtension();
- panel = createMockNotebookPanel();
+ panel = createMockNotebookPanel('MATLAB Kernel', 'test-kernel');
context = {};
});
- test('should create a toolbar button with correct properties', () => {
- // Act
- const result = extension.createNew(panel, context);
-
- // Assert
- expect(result).toEqual(
- expect.objectContaining({
- className: 'openMATLABButton',
- icon: matlabIcon,
- label: 'Open MATLAB',
- tooltip: 'Open MATLAB'
- })
- );
- });
-
test('should return a disposable object', () => {
// Act
const result = extension.createNew(panel, context);
@@ -188,35 +263,20 @@ describe('matlab_browser_button', () => {
expect(typeof result.dispose).toBe('function');
});
- test('button onClick should open MATLAB in a new tab', () => {
- // Arrange
- const ToolbarButtonMock = jest.requireMock(
- '@jupyterlab/apputils'
- ).ToolbarButton;
- let capturedOnClick: () => void = () => {}; // Initialize with empty function
-
- // Capture the onClick handler when ToolbarButton is constructed
- ToolbarButtonMock.mockImplementationOnce((options: any) => {
- capturedOnClick = options.onClick;
- return {
- ...options,
- dispose: jest.fn()
- };
- });
+ test('should call insertButton when createNew is invoked', () => {
+ const matlabButtonModule = require('../plugins/matlabToolbarButton');
+ const spy = jest
+ .spyOn(matlabButtonModule, 'insertButton')
+ .mockResolvedValue({ dispose: jest.fn() });
- // Act
extension.createNew(
- panel as unknown as NotebookPanel,
- context as unknown as DocumentRegistry.IContext
+ panel as unknown as NotebookPanel,
+ context as unknown as DocumentRegistry.IContext
);
- // Manually call the onClick handler
- capturedOnClick();
- // Assert
- expect(window.open).toHaveBeenCalledWith(
- 'http://localhost:8888/matlab',
- '_blank'
- );
+ expect(spy).toHaveBeenCalledWith(panel);
+
+ spy.mockRestore();
});
test('should call insertButton with panel and button', () => {
@@ -228,13 +288,13 @@ describe('matlab_browser_button', () => {
.mockImplementation(() => Promise.resolve());
// Act
- const button = extension.createNew(
- panel as unknown as NotebookPanel,
- context as unknown as DocumentRegistry.IContext
+ extension.createNew(
+ panel as unknown as NotebookPanel,
+ context as unknown as DocumentRegistry.IContext
);
// Assert
- expect(spy).toHaveBeenCalledWith(panel, button);
+ expect(spy).toHaveBeenCalledWith(panel);
// Cleanup
spy.mockRestore();
diff --git a/src/jupyter_matlab_labextension/style/base.css b/src/jupyter_matlab_labextension/style/base.css
index e11f4577..de8fe6aa 100644
--- a/src/jupyter_matlab_labextension/style/base.css
+++ b/src/jupyter_matlab_labextension/style/base.css
@@ -1,5 +1,11 @@
+/* # Copyright 2024-2025 The MathWorks, Inc. */
/*
See the JupyterLab Developer Guide for useful CSS Patterns:
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
*/
+
+/* Adds spacing between toolbar icon and label */
+.matlab-toolbar-button-spaced .jp-ToolbarButtonComponent-label {
+ margin-left: 4px;
+}
\ No newline at end of file
diff --git a/tests/integration/utils/integration_test_utils.py b/tests/integration/utils/integration_test_utils.py
index 40a1be08..9f37daf2 100644
--- a/tests/integration/utils/integration_test_utils.py
+++ b/tests/integration/utils/integration_test_utils.py
@@ -1,4 +1,4 @@
-# Copyright 2023-2024 The MathWorks, Inc.
+# Copyright 2023-2025 The MathWorks, Inc.
# Utility functions for integration testing of jupyter-matlab-proxy
import asyncio
@@ -24,7 +24,7 @@ def perform_basic_checks():
# Check if MATLAB is in the system path
assert matlab_path is not None, "MATLAB is not in system path"
- # Check if MATLAB verison is >= R2020b
+ # Check if MATLAB version is >= R2020b
assert (
matlab_proxy.settings.get_matlab_version(matlab_path) >= "R2020b"
), "MATLAB version should be R2020b or later"
@@ -83,35 +83,33 @@ async def wait_matlab_proxy_ready(matlab_proxy_url):
from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper
- is_matlab_licensed = False
- matlab_status = "down"
start_time = time.time()
loop = asyncio.get_event_loop()
- matlab_proxy = MWICommHelper("", matlab_proxy_url, loop, loop, {})
- await matlab_proxy.connect()
+ comm_helper = MWICommHelper("", matlab_proxy_url, loop, loop, {})
+ await comm_helper.connect()
+ matlab_proxy_status = await comm_helper.fetch_matlab_proxy_status()
# Poll for matlab-proxy to be up
- while matlab_status in ["down", "starting"] and (
- time.time() - start_time < MATLAB_STARTUP_TIMEOUT
+ while (
+ matlab_proxy_status
+ and matlab_proxy_status.matlab_status in ["down", "starting"]
+ and (time.time() - start_time < MATLAB_STARTUP_TIMEOUT)
+ and not matlab_proxy_status.matlab_proxy_has_error
):
time.sleep(1)
try:
- (
- is_matlab_licensed,
- matlab_status,
- _,
- ) = await matlab_proxy.fetch_matlab_proxy_status()
+ matlab_proxy_status = await comm_helper.fetch_matlab_proxy_status()
except Exception:
# The network connection can be flaky while the
# matlab-proxy server is booting. There can also be some
# intermediate connection errors
pass
- assert is_matlab_licensed is True, "MATLAB is not licensed"
+ assert matlab_proxy_status.is_matlab_licensed is True, "MATLAB is not licensed"
assert (
- matlab_status == "up"
- ), f"matlab-proxy process did not start successfully\nMATLAB Status is '{matlab_status}'"
- await matlab_proxy.disconnect()
+ matlab_proxy_status.matlab_status == "up"
+ ), f"matlab-proxy process did not start successfully\nMATLAB Status is '{matlab_proxy_status.matlab_status}'"
+ await comm_helper.disconnect()
def get_random_free_port() -> str:
@@ -184,13 +182,14 @@ def license_matlab_proxy(matlab_proxy_url):
status_info,
"Verify if Licensing is successful. This might fail if incorrect credentials are provided",
).to_be_visible(timeout=60000)
- except:
+ except Exception as e:
# Grab screenshots
log_dir = "./"
file_name = "licensing-screenshot-failed.png"
file_path = os.path.join(log_dir, file_name)
os.makedirs(log_dir, exist_ok=True)
page.screenshot(path=file_path)
+ print("Exception: %s", str(e))
finally:
browser.close()
diff --git a/tests/unit/jupyter_matlab_kernel/magics/test_matlab.py b/tests/unit/jupyter_matlab_kernel/magics/test_matlab.py
new file mode 100644
index 00000000..e40efa1c
--- /dev/null
+++ b/tests/unit/jupyter_matlab_kernel/magics/test_matlab.py
@@ -0,0 +1,202 @@
+# Copyright 2025 The MathWorks, Inc.
+
+import pytest
+
+from jupyter_matlab_kernel.magics.help import help
+from jupyter_matlab_kernel.magics.matlab import (
+ CMD_INFO,
+ CMD_NEW_SESSION,
+ DEDICATED_SESSION_CONFIRMATION_MSG,
+ EXISTING_NEW_SESSION_ERROR,
+ get_kernel_info,
+ handle_new_matlab_session,
+ matlab,
+)
+from jupyter_matlab_kernel.mwi_exceptions import MagicError
+
+
+def test_help_magic():
+ magic_object = help([matlab.__name__])
+ before_cell_executor = magic_object.before_cell_execute()
+ output = next(before_cell_executor)
+ expected_output = matlab.info_about_magic
+ assert expected_output in output["value"][0]
+
+
+@pytest.mark.parametrize(
+ "parameters",
+ [
+ pytest.param([], id="atleast one argument is required"),
+ pytest.param(
+ ["invalid"],
+ id="Invalid argument should throw exception",
+ ),
+ pytest.param(
+ [CMD_INFO, CMD_NEW_SESSION],
+ id="more than one parameter should throw exception",
+ ),
+ ],
+)
+def test_matlab_magic_exceptions(parameters):
+ magic_object = matlab(parameters)
+ before_cell_executor = magic_object.before_cell_execute()
+ with pytest.raises(MagicError):
+ next(before_cell_executor)
+
+
+@pytest.mark.parametrize(
+ "parameters, parameter_pos, cursor_pos, expected_output",
+ [
+ pytest.param(
+ ["n"],
+ 1,
+ 1,
+ {"new_session"},
+ id="n as parameter with parameter and cursor position as 1",
+ ),
+ pytest.param(
+ [""],
+ 1,
+ 1,
+ {"new_session", "info"},
+ id="no parameter with parameter and cursor position as 1",
+ ),
+ pytest.param(
+ ["in"],
+ 1,
+ 2,
+ {"info"},
+ id="i as parameter with parameter position as 1 and cursor position as 2",
+ ),
+ pytest.param(
+ ["i"],
+ 2,
+ 1,
+ set([]),
+ id="t as parameter with parameter position as 2 and cursor position as 1",
+ ),
+ pytest.param(
+ ["magic"],
+ 1,
+ 4,
+ set([]),
+ id="magic as parameter with parameter position as 1 and cursor position as 4",
+ ),
+ ],
+)
+def test_do_complete_in_matlab_magic(
+ parameters, parameter_pos, cursor_pos, expected_output
+):
+ magic_object = matlab()
+ output = magic_object.do_complete(parameters, parameter_pos, cursor_pos)
+ assert expected_output.issubset(set(output))
+
+
+async def test_new_session_in_matlab_magic_while_already_in_new_session(mocker):
+ """
+ Test that an appropriate message is displayed when trying to create a new session while already in a new session.
+
+ This test verifies that when a %%matlab magic command with new_session option is executed
+ while MATLAB is already assigned to the kernel in a new session, an appropriate error message
+ is displayed indicating that the notebook is already linked to a new MATLAB session.
+ """
+ mock_kernel = mocker.MagicMock()
+ mock_kernel.is_matlab_assigned = True
+ mock_kernel.is_shared_matlab = False
+ output = []
+ async for result in handle_new_matlab_session(mock_kernel):
+ output.append(result)
+
+ assert output is not None
+ assert EXISTING_NEW_SESSION_ERROR in output[0]["value"][0]
+
+
+async def test_new_session_in_matlab_magic_while_already_in_shared_session(mocker):
+ """
+ Test that an exception is raised when trying to switch from shared session to new session.
+
+ This test verifies that when a %%matlab magic command with new_session option is executed
+ while MATLAB is already assigned to the kernel, an appropriate exception is raised
+ with a message indicating that the notebook is already linked to a MATLAB session.
+ """
+ mock_kernel = mocker.MagicMock()
+ mock_kernel.is_matlab_assigned = True
+ mock_kernel.is_shared_matlab = True
+ with pytest.raises(Exception) as excinfo:
+ async for _ in handle_new_matlab_session(mock_kernel):
+ pass
+
+ assert "linked to Default MATLAB session" in str(excinfo.value)
+
+
+async def test_handle_new_matlab_session_success(mocker):
+ """
+ Test that MATLAB proxy is started correctly when using MATLAB magic command.
+
+ This test verifies that when a %%matlab magic command with new_session option is executed,
+ the kernel properly starts the MATLAB proxy, assigns MATLAB to the kernel
+ (is_matlab_assigned=True), and sets the shared MATLAB flag to False.
+ """
+ mock_kernel = mocker.AsyncMock()
+ mock_kernel.is_matlab_assigned = False
+ output = []
+ async for result in handle_new_matlab_session(mock_kernel):
+ output.append(result)
+
+ assert output is not None
+ mock_kernel.start_matlab_proxy_and_comm_helper.assert_called_once()
+ mock_kernel.perform_startup_checks.assert_called_once()
+ mock_kernel.cleanup_matlab_proxy.assert_not_called()
+ assert mock_kernel.is_matlab_assigned is True
+ assert mock_kernel.is_shared_matlab is False
+ assert DEDICATED_SESSION_CONFIRMATION_MSG in output[0]["value"][0]
+
+
+async def test_handle_new_matlab_session_raises_exception(mocker):
+ """
+ Test that exceptions during MATLAB magic command execution are handled properly.
+
+ This test verifies that when an exception occurs during the startup of the MATLAB proxy
+ (triggered by a %%matlab magic command), the kernel properly handles the error and
+ maintains the expected state (is_matlab_assigned=False, is_shared_matlab=True).
+ """
+ mock_kernel = mocker.AsyncMock()
+ mock_kernel.is_matlab_assigned = False
+ output = []
+ with pytest.raises(Exception):
+ async for result in handle_new_matlab_session(mock_kernel):
+ output.append(result)
+
+ assert output is not None
+ mock_kernel.start_matlab_proxy_and_comm_helper.assert_called_once()
+ mock_kernel.perform_startup_checks.side_effect = Exception(
+ "MATLAB Connection Error"
+ )
+ mock_kernel.cleanup_matlab_proxy.assert_called_once()
+ assert mock_kernel.is_matlab_assigned is False
+ assert mock_kernel.is_shared_matlab is True
+
+
+@pytest.mark.parametrize(
+ "shared_matlab_status, expected_output",
+ [
+ (True, "MATLAB Shared With Other Notebooks: True"),
+ (False, "MATLAB Shared With Other Notebooks: False"),
+ ],
+ ids=["Shared MATLAB", "Non-shared MATLAB"],
+)
+async def test_get_kernel_info_in_matlab_magic(
+ shared_matlab_status, expected_output, mocker
+):
+ mock_kernel = mocker.MagicMock()
+ mock_kernel._get_kernel_info.return_value = {
+ "is_shared_matlab": shared_matlab_status,
+ "matlab_version": "R2025b",
+ "matlab_root_path": "/path/to/matlab",
+ "licensing_mode": "existing_license",
+ }
+ output = []
+ async for result in get_kernel_info(mock_kernel):
+ output.append(result)
+ assert output is not None
+ assert expected_output in output[0]["value"][0]
diff --git a/tests/unit/jupyter_matlab_kernel/test_kernel.py b/tests/unit/jupyter_matlab_kernel/test_kernel.py
index 18ef551b..d0591ad6 100644
--- a/tests/unit/jupyter_matlab_kernel/test_kernel.py
+++ b/tests/unit/jupyter_matlab_kernel/test_kernel.py
@@ -6,10 +6,8 @@
from jupyter_server import serverapp
from mocks.mock_jupyter_server import MockJupyterServerFixture
-from jupyter_matlab_kernel.jsp_kernel import (
- MATLABKernelUsingJSP,
- start_matlab_proxy,
-)
+from jupyter_matlab_kernel.jsp_kernel import start_matlab_proxy
+from jupyter_matlab_kernel.mpm_kernel import MATLABKernelUsingMPM
from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError
@@ -98,19 +96,24 @@ async def test_matlab_not_licensed_non_jupyter(mocker):
exception (MATLABConnectionError) is raised when performing startup checks.
"""
# Mock the kernel's jupyter_base_url attribute to simulate a non-Jupyter environment
- kernel = mocker.MagicMock(spec=MATLABKernelUsingJSP)
+ kernel = mocker.MagicMock(spec=MATLABKernelUsingMPM)
kernel.jupyter_base_url = None
kernel.startup_error = None
+ kernel.matlab_proxy_base_url = "/test"
+
+ matlab_status_mock = mocker.Mock()
+ matlab_status_mock.is_matlab_licensed = False
+ matlab_status_mock.matlab_status = "down"
+ matlab_status_mock.matlab_proxy_has_error = False
+
kernel.mwi_comm_helper = mocker.Mock()
kernel.mwi_comm_helper.fetch_matlab_proxy_status = mocker.AsyncMock(
- return_value=(False, "down", False)
+ return_value=matlab_status_mock
)
# Mock the perform_startup_checks method to actually call the implementation
- async def mock_perform_startup_checks(*args, **kwargs):
- return await MATLABKernelUsingJSP.perform_startup_checks(
- kernel, *args, **kwargs
- )
+ async def mock_perform_startup_checks():
+ return await MATLABKernelUsingMPM.perform_startup_checks(kernel)
kernel.perform_startup_checks.side_effect = mock_perform_startup_checks
diff --git a/tests/unit/jupyter_matlab_kernel/test_mpm_kernel.py b/tests/unit/jupyter_matlab_kernel/test_mpm_kernel.py
index 1bf01d1b..f797d376 100644
--- a/tests/unit/jupyter_matlab_kernel/test_mpm_kernel.py
+++ b/tests/unit/jupyter_matlab_kernel/test_mpm_kernel.py
@@ -26,7 +26,6 @@ def mpm_kernel_instance(mocker) -> MATLABKernelUsingMPM:
return MATLABKernelUsingMPM()
-@pytest.mark.asyncio
async def test_initialize_matlab_proxy_with_mpm_success(mocker, mpm_kernel_instance):
mpm_lib_start_matlab_proxy_response = {
"absolute_url": "dummyURL",
@@ -166,3 +165,27 @@ async def test_do_shutdown_exception(mocker, mpm_kernel_instance):
mpm_kernel_instance.mpm_auth_token,
)
assert not mpm_kernel_instance.is_matlab_assigned
+
+
+async def test_matlab_proxy_assignment_on_executing_matlab_command(
+ mocker, mpm_kernel_instance
+):
+ """
+ Test that MATLAB proxy is assigned when executing a MATLAB command.
+
+ This test verifies that when a regular MATLAB command is executed while MATLAB
+ is not yet assigned, the kernel properly starts the MATLAB proxy and assigns
+ MATLAB to the kernel (is_matlab_assigned=True).
+ """
+ code = "why"
+ mpm_kernel_instance.is_matlab_assigned = False
+ # Patch kernel instance to mock start_matlab_proxy_and_comm_helper method
+ mock_start_matlab_proxy = mocker.patch.object(
+ mpm_kernel_instance,
+ "start_matlab_proxy_and_comm_helper",
+ autospec=True,
+ )
+
+ await mpm_kernel_instance.do_execute(code, silent=True)
+ mock_start_matlab_proxy.assert_called_once()
+ assert mpm_kernel_instance.is_matlab_assigned is True
diff --git a/tests/unit/jupyter_matlab_kernel/test_mwi_comm_helpers.py b/tests/unit/jupyter_matlab_kernel/test_mwi_comm_helpers.py
index 4b7ee6f4..786dbd47 100644
--- a/tests/unit/jupyter_matlab_kernel/test_mwi_comm_helpers.py
+++ b/tests/unit/jupyter_matlab_kernel/test_mwi_comm_helpers.py
@@ -23,7 +23,7 @@
@pytest.fixture
-async def matlab_proxy_fixture():
+async def comm_helper_fixture():
url = "http://localhost"
headers = {}
kernel_id = ""
@@ -36,7 +36,7 @@ async def matlab_proxy_fixture():
# Testing fetch_matlab_proxy_status
async def test_fetch_matlab_proxy_status_unauth_request(
- monkeypatch, matlab_proxy_fixture
+ monkeypatch, comm_helper_fixture
):
"""
This test checks that fetch_matlab_proxy_status throws an exception
@@ -48,7 +48,7 @@ async def mock_get(*args, **kwargs):
monkeypatch.setattr(aiohttp.ClientSession, "get", mock_get)
with pytest.raises(aiohttp.client_exceptions.ClientError) as exceptionInfo:
- await matlab_proxy_fixture.fetch_matlab_proxy_status()
+ await comm_helper_fixture.fetch_matlab_proxy_status()
assert MockUnauthorisedRequestResponse().exception_msg in str(exceptionInfo.value)
@@ -63,7 +63,7 @@ async def mock_get(*args, **kwargs):
],
)
async def test_fetch_matlab_proxy_status(
- input_lic_type, expected_license_status, monkeypatch, matlab_proxy_fixture
+ input_lic_type, expected_license_status, monkeypatch, comm_helper_fixture
):
"""
This test checks that fetch_matlab_proxy_status returns the correct
@@ -77,17 +77,35 @@ async def mock_get(*args, **kwargs):
monkeypatch.setattr(aiohttp.ClientSession, "get", mock_get)
- (
- is_matlab_licensed,
- matlab_status,
- matlab_proxy_has_error,
- ) = await matlab_proxy_fixture.fetch_matlab_proxy_status()
- assert is_matlab_licensed == expected_license_status
- assert matlab_status == "up"
- assert matlab_proxy_has_error is False
+ matlab_proxy_status = await comm_helper_fixture.fetch_matlab_proxy_status()
+ assert matlab_proxy_status.is_matlab_licensed == expected_license_status
+ assert matlab_proxy_status.matlab_status == "up"
+ assert matlab_proxy_status.matlab_proxy_has_error is False
-async def test_interrupt_request_bad_request(monkeypatch, matlab_proxy_fixture):
+async def test_fetch_matlab_root_path(mocker, comm_helper_fixture):
+ """
+ This test checks that fetch_matlab_root_path returns the correct matlab root path.
+ """
+ mock_response = mocker.AsyncMock()
+ mock_response.status = http.HTTPStatus.OK
+ mock_response.json = mocker.AsyncMock(
+ return_value={
+ "matlab": {
+ "rootPath": "test_root_path",
+ "version": "test_version",
+ }
+ },
+ )
+ mocker.patch(
+ "aiohttp.ClientSession.get", new=mocker.AsyncMock(return_value=mock_response)
+ )
+
+ matlab_root_path = await comm_helper_fixture.fetch_matlab_root_path()
+ assert matlab_root_path == "test_root_path"
+
+
+async def test_interrupt_request_bad_request(monkeypatch, comm_helper_fixture):
"""
This test checks that send_interrupt_request_to_matlab raises
an exception if the response to the HTTP post is not valid.
@@ -101,12 +119,12 @@ async def mock_post(*args, **kwargs):
monkeypatch.setattr(aiohttp.ClientSession, "post", mock_post)
with pytest.raises(aiohttp.client_exceptions.ClientError) as exceptionInfo:
- await matlab_proxy_fixture.send_interrupt_request_to_matlab()
+ await comm_helper_fixture.send_interrupt_request_to_matlab()
assert mock_exception_message in str(exceptionInfo.value)
# Testing send_execution_request_to_matlab
-async def test_execution_request_bad_request(monkeypatch, matlab_proxy_fixture):
+async def test_execution_request_bad_request(monkeypatch, comm_helper_fixture):
"""
This test checks that send_execution_request_to_matlab throws an exception
if the response to the HTTP request is invalid.
@@ -120,12 +138,12 @@ async def mock_post(*args, **kwargs):
code = "placeholder for code"
with pytest.raises(aiohttp.client_exceptions.ClientError) as exceptionInfo:
- await matlab_proxy_fixture.send_execution_request_to_matlab(code)
+ await comm_helper_fixture.send_execution_request_to_matlab(code)
assert mock_exception_message in str(exceptionInfo.value)
async def test_execution_request_invalid_feval_response(
- monkeypatch, matlab_proxy_fixture
+ monkeypatch, comm_helper_fixture
):
"""
This test checks that send_execution_request_to_matlab raises an exception
@@ -150,11 +168,11 @@ async def mock_post(*args, **kwargs):
code = "placeholder for code"
with pytest.raises(MATLABConnectionError) as exceptionInfo:
- await matlab_proxy_fixture.send_execution_request_to_matlab(code)
+ await comm_helper_fixture.send_execution_request_to_matlab(code)
assert str(exceptionInfo.value) == str(MATLABConnectionError())
-async def test_execution_interrupt(monkeypatch, matlab_proxy_fixture):
+async def test_execution_interrupt(monkeypatch, comm_helper_fixture):
"""
This test checks that send_execution_request_to_matlab raises an exception
if the matlab command appears to have been interupted.
@@ -190,11 +208,11 @@ async def mock_post(*args, **kwargs):
code = "placeholder for code"
with pytest.raises(Exception) as exceptionInfo:
- await matlab_proxy_fixture.send_execution_request_to_matlab(code)
+ await comm_helper_fixture.send_execution_request_to_matlab(code)
assert "Operation may have interrupted by user" in str(exceptionInfo.value)
-async def test_execution_success(monkeypatch, matlab_proxy_fixture):
+async def test_execution_success(monkeypatch, comm_helper_fixture):
"""
This test checks that send_execution_request_to_matlab returns the correct information
from a valid response from MATLAB.
@@ -225,7 +243,7 @@ async def mock_post(*args, **kwargs):
code = "placeholder for code"
try:
- outputs = await matlab_proxy_fixture.send_execution_request_to_matlab(code)
+ outputs = await comm_helper_fixture.send_execution_request_to_matlab(code)
except Exception:
pytest.fail("Unexpected failured in execution request")
@@ -233,7 +251,7 @@ async def mock_post(*args, **kwargs):
# Testing send_eval_request_to_matlab
-async def test_send_eval_request_to_matlab_success(monkeypatch, matlab_proxy_fixture):
+async def test_send_eval_request_to_matlab_success(monkeypatch, comm_helper_fixture):
"""Test that send_eval_request_to_matlab returns eval response correctly."""
# Arrange
@@ -245,7 +263,7 @@ async def mock_post(*args, **kwargs):
mcode = "x = 1 + 1"
# Act
- result = await matlab_proxy_fixture.send_eval_request_to_matlab(mcode)
+ result = await comm_helper_fixture.send_eval_request_to_matlab(mcode)
# Assert
# Verify the eval response is returned as-is
@@ -253,9 +271,7 @@ async def mock_post(*args, **kwargs):
assert result == expected_response
-async def test_send_eval_request_to_matlab_with_error(
- monkeypatch, matlab_proxy_fixture
-):
+async def test_send_eval_request_to_matlab_with_error(monkeypatch, comm_helper_fixture):
"""Test that send_eval_request_to_matlab returns error response correctly."""
# Arrange
@@ -271,7 +287,7 @@ async def mock_post(*args, **kwargs):
mcode = "invalid_syntax"
# Act
- result = await matlab_proxy_fixture.send_eval_request_to_matlab(mcode)
+ result = await comm_helper_fixture.send_eval_request_to_matlab(mcode)
# Assert
# Verify the error response is returned as-is
@@ -284,7 +300,7 @@ async def mock_post(*args, **kwargs):
async def test_send_eval_request_to_matlab_bad_request(
- monkeypatch, matlab_proxy_fixture
+ monkeypatch, comm_helper_fixture
):
"""Test that send_eval_request_to_matlab raises exception for bad HTTP request."""
# Arrange
@@ -299,14 +315,14 @@ async def mock_post(*args, **kwargs):
# Act
with pytest.raises(aiohttp.client_exceptions.ClientError) as exceptionInfo:
- await matlab_proxy_fixture.send_eval_request_to_matlab(mcode)
+ await comm_helper_fixture.send_eval_request_to_matlab(mcode)
# Assert
assert mock_exception_message in str(exceptionInfo.value)
async def test_send_eval_request_to_matlab_missing_eval_response(
- monkeypatch, matlab_proxy_fixture
+ monkeypatch, comm_helper_fixture
):
"""Test that send_eval_request_to_matlab raises MATLABConnectionError for missing EvalResponse."""
@@ -318,11 +334,11 @@ async def mock_post(*args, **kwargs):
mcode = "x = 1 + 1"
with pytest.raises(MATLABConnectionError):
- await matlab_proxy_fixture.send_eval_request_to_matlab(mcode)
+ await comm_helper_fixture.send_eval_request_to_matlab(mcode)
# Testing _read_eval_response_from_file
-async def test_read_eval_response_from_file_success_with_file(matlab_proxy_fixture):
+async def test_read_eval_response_from_file_success_with_file(comm_helper_fixture):
"""Test _read_eval_response_from_file with successful response and file."""
# Arrange
# Create a temporary file with test data
@@ -341,7 +357,7 @@ async def test_read_eval_response_from_file_success_with_file(matlab_proxy_fixtu
}
# Act
- result = await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ result = await comm_helper_fixture._read_eval_response_from_file(eval_response)
# Assert
# Verify the result
@@ -356,7 +372,7 @@ async def test_read_eval_response_from_file_success_with_file(matlab_proxy_fixtu
os.remove(temp_file_path)
-async def test_read_eval_response_from_file_success_without_file(matlab_proxy_fixture):
+async def test_read_eval_response_from_file_success_without_file(comm_helper_fixture):
"""Test _read_eval_response_from_file with successful response but no file."""
# Arrange
eval_response = {
@@ -366,7 +382,7 @@ async def test_read_eval_response_from_file_success_without_file(matlab_proxy_fi
}
# Act
- result = await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ result = await comm_helper_fixture._read_eval_response_from_file(eval_response)
# Assert
# Verify empty result returns empty list
@@ -374,7 +390,7 @@ async def test_read_eval_response_from_file_success_without_file(matlab_proxy_fi
async def test_read_eval_response_from_file_error_with_message_faults(
- matlab_proxy_fixture,
+ comm_helper_fixture,
):
"""Test _read_eval_response_from_file with error response containing message faults."""
# Arrange
@@ -389,11 +405,11 @@ async def test_read_eval_response_from_file_error_with_message_faults(
Exception,
match="Failed to execute. Operation may have been interrupted by user.",
):
- await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ await comm_helper_fixture._read_eval_response_from_file(eval_response)
async def test_read_eval_response_from_file_error_without_message_faults(
- matlab_proxy_fixture,
+ comm_helper_fixture,
):
"""Test _read_eval_response_from_file with error response without message faults."""
@@ -404,11 +420,11 @@ async def test_read_eval_response_from_file_error_without_message_faults(
}
with pytest.raises(Exception, match="Custom error message"):
- await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ await comm_helper_fixture._read_eval_response_from_file(eval_response)
async def test_read_eval_response_from_file_handles_file_deletion_error(
- matlab_proxy_fixture, monkeypatch
+ comm_helper_fixture, monkeypatch
):
"""Test _read_eval_response_from_file handles file deletion errors gracefully."""
@@ -438,7 +454,7 @@ def mock_remove(path):
}
# Should not raise exception even if file deletion fails
- result = await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ result = await comm_helper_fixture._read_eval_response_from_file(eval_response)
# Verify the result is still correct
assert result == test_data
@@ -450,7 +466,7 @@ def mock_remove(path):
async def test_read_eval_response_from_file_with_empty_file_content(
- matlab_proxy_fixture,
+ comm_helper_fixture,
):
"""Test _read_eval_response_from_file with empty file content."""
@@ -466,7 +482,7 @@ async def test_read_eval_response_from_file_with_empty_file_content(
"messageFaults": [],
}
- result = await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ result = await comm_helper_fixture._read_eval_response_from_file(eval_response)
# Verify empty content returns empty list
assert result == []
@@ -481,7 +497,7 @@ async def test_read_eval_response_from_file_with_empty_file_content(
async def test_read_eval_response_from_file_with_whitespace_only_content(
- matlab_proxy_fixture,
+ comm_helper_fixture,
):
"""Test _read_eval_response_from_file with whitespace-only file content."""
@@ -497,7 +513,7 @@ async def test_read_eval_response_from_file_with_whitespace_only_content(
"messageFaults": [],
}
- result = await matlab_proxy_fixture._read_eval_response_from_file(eval_response)
+ result = await comm_helper_fixture._read_eval_response_from_file(eval_response)
# Verify whitespace-only content returns empty list
assert result == []