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 == []