|
| 1 | +# Copyright 2024 The MathWorks, Inc. |
| 2 | + |
| 3 | +"""This module contains derived class implementation of MATLABKernel that uses |
| 4 | +Jupyter Server to manage interactions with matlab-proxy & MATLAB. |
| 5 | +""" |
| 6 | + |
| 7 | +import asyncio |
| 8 | +import http |
| 9 | +import os |
| 10 | + |
| 11 | +# Import Dependencies |
| 12 | +import aiohttp |
| 13 | +import aiohttp.client_exceptions |
| 14 | +import requests |
| 15 | + |
| 16 | +from jupyter_matlab_kernel import base_kernel as base |
| 17 | +from jupyter_matlab_kernel import mwi_logger, test_utils |
| 18 | +from jupyter_matlab_kernel.mwi_comm_helpers import MWICommHelper |
| 19 | +from jupyter_matlab_kernel.mwi_exceptions import MATLABConnectionError |
| 20 | + |
| 21 | +_logger = mwi_logger.get() |
| 22 | + |
| 23 | + |
| 24 | +def _start_matlab_proxy_using_jupyter(url, headers, logger=_logger): |
| 25 | + """ |
| 26 | + Start matlab-proxy using jupyter server which started the current kernel |
| 27 | + process by sending HTTP request to the endpoint registered through |
| 28 | + jupyter-matlab-proxy. |
| 29 | +
|
| 30 | + Args: |
| 31 | + url (string): URL to send HTTP request |
| 32 | + headers (dict): HTTP headers required for the request |
| 33 | +
|
| 34 | + Returns: |
| 35 | + bool: True if jupyter server has successfully started matlab-proxy else False. |
| 36 | + """ |
| 37 | + # This is content that is present in the matlab-proxy index.html page which |
| 38 | + # can be used to validate a proper response. |
| 39 | + matlab_proxy_index_page_identifier = "MWI_MATLAB_PROXY_IDENTIFIER" |
| 40 | + |
| 41 | + logger.debug( |
| 42 | + f"Sending request to jupyter to start matlab-proxy at {url} with headers: {headers}" |
| 43 | + ) |
| 44 | + # send request to the matlab-proxy endpoint to make sure it is available. |
| 45 | + # If matlab-proxy is not started, jupyter-server starts it at this point. |
| 46 | + resp = requests.get(url, headers=headers, verify=False) |
| 47 | + logger.debug("Received status code: %s", resp.status_code) |
| 48 | + |
| 49 | + return ( |
| 50 | + resp.status_code == http.HTTPStatus.OK |
| 51 | + and matlab_proxy_index_page_identifier in resp.text |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +def start_matlab_proxy(logger=_logger): |
| 56 | + """ |
| 57 | + Start matlab-proxy registered with the jupyter server which started the |
| 58 | + current kernel process. |
| 59 | +
|
| 60 | + Raises: |
| 61 | + MATLABConnectionError: Occurs when kernel is not started by jupyter server. |
| 62 | +
|
| 63 | + Returns: |
| 64 | + Tuple (string, string, dict): |
| 65 | + url (string): Complete URL to send HTTP requests to matlab-proxy |
| 66 | + base_url (string): Complete base url for matlab-proxy provided by jupyter server |
| 67 | + headers (dict): HTTP headers required while sending HTTP requests to matlab-proxy |
| 68 | + """ |
| 69 | + |
| 70 | + # If jupyter testing is enabled, then a standalone matlab-proxy server would be |
| 71 | + # launched by the tests and kernel would expect the configurations of this matlab-proxy |
| 72 | + # server which is provided through environment variables to 'start_matlab_proxy_for_testing' |
| 73 | + if test_utils.is_jupyter_testing_enabled(): |
| 74 | + return test_utils.start_matlab_proxy_for_testing(logger) |
| 75 | + |
| 76 | + nb_server_list = [] |
| 77 | + |
| 78 | + # The matlab-proxy server, if running, could have been started by either |
| 79 | + # "jupyter_server" or "notebook" package. |
| 80 | + try: |
| 81 | + from jupyter_server import serverapp |
| 82 | + |
| 83 | + nb_server_list += list(serverapp.list_running_servers()) |
| 84 | + |
| 85 | + from notebook import notebookapp |
| 86 | + |
| 87 | + nb_server_list += list(notebookapp.list_running_servers()) |
| 88 | + except ImportError: |
| 89 | + pass |
| 90 | + |
| 91 | + # Use parent process id of the kernel to filter Jupyter Server from the list. |
| 92 | + jupyter_server_pid = base._get_parent_pid() |
| 93 | + logger.debug(f"Resolved jupyter server pid: {jupyter_server_pid}") |
| 94 | + |
| 95 | + nb_server = dict() |
| 96 | + found_nb_server = False |
| 97 | + for server in nb_server_list: |
| 98 | + if server["pid"] == jupyter_server_pid: |
| 99 | + logger.debug("Jupyter server associated with this MATLAB Kernel found.") |
| 100 | + found_nb_server = True |
| 101 | + nb_server = server |
| 102 | + # Stop iterating over the server list |
| 103 | + break |
| 104 | + |
| 105 | + # Error out if the server is not found! |
| 106 | + if not found_nb_server: |
| 107 | + logger.error("Jupyter server associated with this MATLABKernel not found.") |
| 108 | + raise MATLABConnectionError( |
| 109 | + """ |
| 110 | + Error: MATLAB Kernel for Jupyter was unable to find the notebook server from which it was spawned!\n |
| 111 | + Resolution: Please relaunch kernel from JupyterLab or Classic Jupyter Notebook. |
| 112 | + """ |
| 113 | + ) |
| 114 | + |
| 115 | + # Verify that Password is disabled |
| 116 | + if nb_server["password"] is True: |
| 117 | + logger.error("Jupyter server uses password for authentication.") |
| 118 | + # TODO: To support passwords, we either need to acquire it from Jupyter or ask the user? |
| 119 | + raise MATLABConnectionError( |
| 120 | + """ |
| 121 | + Error: MATLAB Kernel could not communicate with MATLAB.\n |
| 122 | + Reason: There is a password set to access the Jupyter server.\n |
| 123 | + Resolution: Delete the cached Notebook password file, and restart the kernel.\n |
| 124 | + See https://jupyter-notebook.readthedocs.io/en/stable/public_server.html#securing-a-notebook-server for more information. |
| 125 | + """ |
| 126 | + ) |
| 127 | + |
| 128 | + # Using nb_server["url"] to construct matlab-proxy URL as it handles the following cases |
| 129 | + # 1. For normal usage of Jupyter, the URL returned by nb_server uses localhost |
| 130 | + # 2. For explicitly specified IP with Jupyter, the URL returned by nb_server |
| 131 | + # a. uses FQDN hostname when specified IP is 0.0.0.0 |
| 132 | + # b. uses specified IP for all other cases |
| 133 | + matlab_proxy_url = "{jupyter_server_url}matlab".format( |
| 134 | + jupyter_server_url=nb_server["url"] |
| 135 | + ) |
| 136 | + |
| 137 | + available_tokens = { |
| 138 | + "jupyter_server": nb_server.get("token"), |
| 139 | + "jupyterhub": os.getenv("JUPYTERHUB_API_TOKEN"), |
| 140 | + "default": None, |
| 141 | + } |
| 142 | + |
| 143 | + for token in available_tokens.values(): |
| 144 | + if token: |
| 145 | + headers = {"Authorization": f"token {token}"} |
| 146 | + else: |
| 147 | + headers = None |
| 148 | + |
| 149 | + if _start_matlab_proxy_using_jupyter(matlab_proxy_url, headers, logger): |
| 150 | + logger.debug( |
| 151 | + f"Started matlab-proxy using jupyter at {matlab_proxy_url} with headers: {headers}" |
| 152 | + ) |
| 153 | + return matlab_proxy_url, nb_server["base_url"], headers |
| 154 | + |
| 155 | + logger.error( |
| 156 | + "MATLABKernel could not communicate with matlab-proxy through Jupyter server" |
| 157 | + ) |
| 158 | + logger.error(f"Jupyter server:\n{nb_server}") |
| 159 | + raise MATLABConnectionError( |
| 160 | + """ |
| 161 | + Error: MATLAB Kernel could not communicate with MATLAB. |
| 162 | + Reason: Possibly due to invalid jupyter security tokens. |
| 163 | + """ |
| 164 | + ) |
| 165 | + |
| 166 | + |
| 167 | +class MATLABKernelUsingJSP(base.BaseMATLABKernel): |
| 168 | + def __init__(self, *args, **kwargs): |
| 169 | + super().__init__(*args, **kwargs) |
| 170 | + |
| 171 | + try: |
| 172 | + # Start matlab-proxy using the jupyter-matlab-proxy registered endpoint. |
| 173 | + murl, self.jupyter_base_url, headers = start_matlab_proxy(self.log) |
| 174 | + |
| 175 | + # Using asyncio.get_event_loop for shell_loop as io_loop variable is |
| 176 | + # not yet initialized because start() is called after the __init__ |
| 177 | + # is completed. |
| 178 | + shell_loop = asyncio.get_event_loop() |
| 179 | + control_loop = self.control_thread.io_loop.asyncio_loop |
| 180 | + self.mwi_comm_helper = MWICommHelper( |
| 181 | + self.kernel_id, murl, shell_loop, control_loop, headers, self.log |
| 182 | + ) |
| 183 | + shell_loop.run_until_complete(self.mwi_comm_helper.connect()) |
| 184 | + except MATLABConnectionError as err: |
| 185 | + self.startup_error = err |
| 186 | + |
| 187 | + async def do_shutdown(self, restart): |
| 188 | + self.log.debug("Received shutdown request from Jupyter") |
| 189 | + try: |
| 190 | + await self.mwi_comm_helper.send_shutdown_request_to_matlab() |
| 191 | + await self.mwi_comm_helper.disconnect() |
| 192 | + except ( |
| 193 | + MATLABConnectionError, |
| 194 | + aiohttp.client_exceptions.ClientResponseError, |
| 195 | + ) as e: |
| 196 | + self.log.error( |
| 197 | + f"Exception occurred while sending shutdown request to MATLAB:\n{e}" |
| 198 | + ) |
| 199 | + |
| 200 | + return super().do_shutdown(restart) |
| 201 | + |
| 202 | + async def perform_startup_checks(self): |
| 203 | + """Overriding base function to provide a different iframe source""" |
| 204 | + iframe_src: str = f'{self.jupyter_base_url + "matlab"}' |
| 205 | + await super().perform_startup_checks(iframe_src) |
0 commit comments