matlab-proxy 0.22.0__py3-none-any.whl → 0.23.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of matlab-proxy might be problematic. Click here for more details.

Files changed (28) hide show
  1. matlab_proxy/app_state.py +42 -47
  2. matlab_proxy/default_configuration.py +39 -4
  3. matlab_proxy/gui/asset-manifest.json +6 -6
  4. matlab_proxy/gui/index.html +1 -1
  5. matlab_proxy/gui/static/css/{main.6cd0caba.css → main.efa05ff9.css} +2 -2
  6. matlab_proxy/gui/static/css/{main.6cd0caba.css.map → main.efa05ff9.css.map} +1 -1
  7. matlab_proxy/gui/static/js/{main.77e6cbaf.js → main.ce0f5505.js} +3 -3
  8. matlab_proxy/gui/static/js/main.ce0f5505.js.map +1 -0
  9. matlab_proxy/settings.py +1 -1
  10. matlab_proxy/util/__init__.py +1 -1
  11. matlab_proxy/util/mwi/validators.py +17 -16
  12. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/METADATA +2 -2
  13. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/RECORD +27 -27
  14. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/WHEEL +1 -1
  15. matlab_proxy_manager/lib/api.py +90 -49
  16. matlab_proxy_manager/storage/server.py +2 -2
  17. matlab_proxy_manager/utils/helpers.py +38 -21
  18. matlab_proxy_manager/web/app.py +92 -49
  19. matlab_proxy_manager/web/monitor.py +1 -2
  20. matlab_proxy_manager/web/watcher.py +11 -0
  21. tests/unit/test_app.py +1 -0
  22. tests/unit/test_app_state.py +79 -4
  23. tests/unit/util/mwi/test_validators.py +4 -5
  24. matlab_proxy/gui/static/js/main.77e6cbaf.js.map +0 -1
  25. /matlab_proxy/gui/static/js/{main.77e6cbaf.js.LICENSE.txt → main.ce0f5505.js.LICENSE.txt} +0 -0
  26. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/LICENSE.md +0 -0
  27. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/entry_points.txt +0 -0
  28. {matlab_proxy-0.22.0.dist-info → matlab_proxy-0.23.3.dist-info}/top_level.txt +0 -0
@@ -14,17 +14,18 @@ from aiohttp import ClientSession, client_exceptions, web
14
14
  import matlab_proxy.util.mwi.environment_variables as mwi_env
15
15
  import matlab_proxy.util.system as mwi_sys
16
16
  import matlab_proxy_manager.lib.api as mpm_lib
17
- from matlab_proxy.util.event_loop import get_event_loop
18
17
  from matlab_proxy_manager.utils import constants, helpers, logger
19
18
  from matlab_proxy_manager.utils import environment_variables as mpm_env
20
19
  from matlab_proxy_manager.utils.auth import authenticate_access_decorator
21
20
  from matlab_proxy_manager.web import watcher
22
21
  from matlab_proxy_manager.web.monitor import OrphanedProcessMonitor
23
22
 
24
- # We use __all__ to list down all the public-facing APIs exported by this module
25
- __all__ = ["proxy", "SHUTDOWN_EVENT"]
23
+ # List of public-facing APIs exported by this module.
24
+ # This list contains the names of functions or classes that are intended to be
25
+ # used by external code importing this module. Only items listed here will be
26
+ # directly accessible when using "from module import *".
27
+ __all__ = ["proxy"]
26
28
 
27
- SHUTDOWN_EVENT = None
28
29
  log = logger.get(init=True)
29
30
 
30
31
 
@@ -40,6 +41,8 @@ def init_app() -> web.Application:
40
41
  web.Application: The configured aiohttp web application.
41
42
  """
42
43
  app = web.Application()
44
+ # Async event is utilized to signal app termination from this and other modules
45
+ app["shutdown_event"] = asyncio.Event()
43
46
 
44
47
  # Create and get the proxy manager data directory
45
48
  try:
@@ -53,7 +56,7 @@ def init_app() -> web.Application:
53
56
 
54
57
  async def start_idle_monitor(app):
55
58
  """Start the idle timeout monitor."""
56
- asyncio.create_task(monitor.start())
59
+ app["monitor_task"] = asyncio.create_task(monitor.start())
57
60
 
58
61
  async def create_client_session(app):
59
62
  """Create an aiohttp client session."""
@@ -65,10 +68,34 @@ def init_app() -> web.Application:
65
68
  """Cleanup the aiohttp client session."""
66
69
  await app["session"].close()
67
70
 
71
+ async def cleanup_monitor(app):
72
+ """Cancel the idle timeout monitor task."""
73
+ if "monitor_task" in app:
74
+ app["monitor_task"].cancel()
75
+ try:
76
+ await app["monitor_task"]
77
+ except asyncio.CancelledError:
78
+ pass
79
+
80
+ async def cleanup_watcher(app):
81
+ """Cleanup the filesystem watcher."""
82
+ if "observer" in app:
83
+ loop = asyncio.get_running_loop()
84
+ await loop.run_in_executor(None, watcher.stop_watcher, app)
85
+
86
+ if "watcher_future" in app:
87
+ app["watcher_future"].cancel()
88
+ try:
89
+ await app["watcher_future"]
90
+ except asyncio.CancelledError:
91
+ pass
92
+
68
93
  app.on_startup.append(start_idle_monitor)
69
94
  app.on_startup.append(create_client_session)
70
95
  app.on_cleanup.append(helpers.delete_dangling_servers)
71
96
  app.on_cleanup.append(cleanup_client_session)
97
+ app.on_cleanup.append(cleanup_monitor)
98
+ app.on_cleanup.append(cleanup_watcher)
72
99
 
73
100
  app.router.add_route("*", "/{tail:.*}", proxy)
74
101
 
@@ -86,11 +113,6 @@ async def start_app(env_vars: namedtuple):
86
113
  Raises:
87
114
  Exception: If any error occurs during the application startup or runtime.
88
115
  """
89
- # Async events are utilized to signal app termination from other modules,
90
- # necessitating the use of a global variable. To avoid potential issues with variable attachment
91
- # to other event loops in Python versions prior to 3.10, the variable is initialized locally
92
- # rather than globally.
93
- global SHUTDOWN_EVENT
94
116
  app = init_app()
95
117
 
96
118
  app["port"] = env_vars.mpm_port
@@ -110,47 +132,51 @@ async def start_app(env_vars: namedtuple):
110
132
  log.debug("Proxy manager started at http://127.0.0.1:%d", site._port)
111
133
 
112
134
  # Get the default event loop
113
- loop = get_event_loop()
135
+ loop = asyncio.get_running_loop()
114
136
 
115
- # Run the observer in a separate thread
116
- loop.run_in_executor(None, watcher.start_watcher, app)
137
+ # Run the observer in a separate thread and store the future
138
+ app["watcher_future"] = loop.run_in_executor(None, watcher.start_watcher, app)
117
139
 
118
140
  # Register signal handler for graceful shutdown
119
- _register_signal_handler(loop)
120
-
121
- SHUTDOWN_EVENT = asyncio.Event()
141
+ _register_signal_handler(loop, app)
122
142
 
123
143
  # Wait for receiving shutdown_event (set by interrupts or by monitoring process)
124
- await SHUTDOWN_EVENT.wait()
144
+ await app.get("shutdown_event").wait()
125
145
 
126
146
  # After receiving the shutdown signal, perform cleanup by stopping the web server
127
147
  await runner.cleanup()
128
148
 
129
149
 
130
- def _register_signal_handler(loop):
150
+ def _register_signal_handler(loop, app):
131
151
  """
132
- Registers signal handlers for supported termination signals to allow for graceful shutdown
133
- of the application.
152
+ Registers signal handlers for graceful shutdown of the application.
153
+
154
+ This function sets up handlers for supported termination signals to allow
155
+ the application to shut down gracefully. It uses different methods for
156
+ POSIX and non-POSIX systems to add the signal handlers.
134
157
 
135
158
  Args:
136
159
  loop (asyncio.AbstractEventLoop): The event loop to which the signal handlers
137
- should be added.
160
+ should be added.
161
+ app (aiohttp.web.Application): The web application instance.
138
162
  """
139
163
  signals = mwi_sys.get_supported_termination_signals()
140
164
  for sig_name in signals:
141
165
  if mwi_sys.is_posix():
142
- loop.add_signal_handler(sig_name, catch_signals)
166
+ loop.add_signal_handler(sig_name, lambda: _catch_signals(app))
143
167
  else:
144
168
  # loop.add_signal_handler() is not yet supported in Windows.
145
169
  # Using the 'signal' package instead.
146
- signal.signal(sig_name, catch_signals)
170
+ # signal module expects a handler function that takes two arguments:
171
+ # the signal number and the current stack frame
172
+ signal.signal(sig_name, lambda s, f: _catch_signals(app))
147
173
 
148
174
 
149
- def catch_signals(*args):
175
+ def _catch_signals(app):
150
176
  """Handle termination signals for graceful shutdown."""
151
177
  # Poll for parent process to clean up to avoid race conditions in cleanup of matlab proxies
152
178
  helpers.poll_for_server_deletion()
153
- SHUTDOWN_EVENT.set()
179
+ app.get("shutdown_event").set()
154
180
 
155
181
 
156
182
  async def _start_default_proxy(app):
@@ -162,7 +188,7 @@ async def _start_default_proxy(app):
162
188
  """
163
189
  server_process = await mpm_lib.start_matlab_proxy_for_jsp(
164
190
  parent_id=app.get("parent_pid"),
165
- is_isolated_matlab=False,
191
+ is_shared_matlab=True,
166
192
  mpm_auth_token=app.get("auth_token"),
167
193
  )
168
194
  if not server_process:
@@ -206,7 +232,7 @@ async def proxy(req):
206
232
 
207
233
  # Set content length in case of modification
208
234
  req_headers["Content-Length"] = str(len(req_body))
209
- req_headers["x-forwarded-proto"] = "http"
235
+ req_headers["X-Forwarded-Proto"] = "http"
210
236
  req_path = req.rel_url
211
237
 
212
238
  # Redirect block to move /*/matlab to /*/matlab/default/
@@ -230,7 +256,7 @@ async def proxy(req):
230
256
  if not ctx:
231
257
  log.debug("MPM Context header not found in the request")
232
258
  return _render_error_page(
233
- "Required header (MWI-MPM-CONTEXT) not found in the request"
259
+ f"Required header: ${constants.HEADER_MWI_MPM_CONTEXT} not found in the request"
234
260
  )
235
261
 
236
262
  client_key = f"{ctx}_{ident}"
@@ -246,9 +272,11 @@ async def proxy(req):
246
272
  and req_headers.get(upgrade, "").lower() == "websocket"
247
273
  and req.method == "GET"
248
274
  ):
249
- return await _handle_websocket_request(req, proxy_url)
275
+ return await _forward_websocket_request(req, proxy_url)
250
276
  try:
251
- return await _handle_http_request(req, req_body, proxy_url, backend_server)
277
+ return await _forward_http_request(
278
+ req, req_body, proxy_url, _collate_headers(req_headers, backend_server)
279
+ )
252
280
  except web.HTTPFound:
253
281
  log.debug("Redirection to path with /default")
254
282
  raise
@@ -266,7 +294,23 @@ async def proxy(req):
266
294
  raise web.HTTPNotFound() from err
267
295
 
268
296
 
269
- async def _handle_websocket_request(
297
+ # Helper private functions
298
+
299
+
300
+ def _collate_headers(req_headers: dict, backend_server: dict) -> dict:
301
+ """Combines request headers with backend server (matlab-proxy) headers.
302
+
303
+ Args:
304
+ req_headers (dict): The headers from the incoming request.
305
+ backend_server (dict): The backend server configuration.
306
+
307
+ Returns:
308
+ dict: A new dictionary containing all headers from both sources.
309
+ """
310
+ return {**req_headers, **backend_server.get("headers")}
311
+
312
+
313
+ async def _forward_websocket_request(
270
314
  req: web.Request, proxy_url: str
271
315
  ) -> web.WebSocketResponse:
272
316
  """Handles a websocket request to the backend matlab proxy server
@@ -352,11 +396,11 @@ async def _handle_websocket_request(
352
396
  raise aiohttp.WebSocketError(code=code, message=message)
353
397
 
354
398
 
355
- # Helper private functions
356
-
357
-
358
- async def _handle_http_request(
359
- req: web.Request, req_body: Optional[bytes], proxy_url: str, backend_server: dict
399
+ async def _forward_http_request(
400
+ req: web.Request,
401
+ req_body: Optional[bytes],
402
+ proxy_url: str,
403
+ headers: dict,
360
404
  ) -> web.Response:
361
405
  """
362
406
  Forwards an incoming HTTP request to a specified backend server.
@@ -370,7 +414,7 @@ async def _handle_http_request(
370
414
  proxy_url,
371
415
  allow_redirects=True,
372
416
  data=req_body,
373
- headers=backend_server.get("headers"),
417
+ headers=headers,
374
418
  ) as res:
375
419
  headers = res.headers.copy()
376
420
  body = await res.read()
@@ -410,16 +454,6 @@ def _render_error_page(error_msg: str) -> web.Response:
410
454
  return web.HTTPServiceUnavailable(text=f"Error: {error_msg}")
411
455
 
412
456
 
413
- def main() -> None:
414
- """
415
- The main entry point of the application. Starts the app and run until the shutdown
416
- signal to terminate the app is received.
417
- """
418
- env_vars: namedtuple = _fetch_and_validate_required_env_vars()
419
- loop: asyncio.AbstractEventLoop | asyncio.ProactorEventLoop = get_event_loop()
420
- loop.run_until_complete(start_app(env_vars))
421
-
422
-
423
457
  def _fetch_and_validate_required_env_vars() -> namedtuple:
424
458
  EnvVars = namedtuple("EnvVars", ["mpm_port", "mpm_auth_token", "mpm_parent_pid"])
425
459
 
@@ -428,7 +462,7 @@ def _fetch_and_validate_required_env_vars() -> namedtuple:
428
462
  ctx = os.getenv(mpm_env.get_env_name_mwi_mpm_parent_pid())
429
463
 
430
464
  if not ctx or not port or not mpm_auth_token:
431
- print("Error: One or more required environment variables are missing.")
465
+ log.error("Error: One or more required environment variables are missing.")
432
466
  sys.exit(1)
433
467
 
434
468
  try:
@@ -437,10 +471,19 @@ def _fetch_and_validate_required_env_vars() -> namedtuple:
437
471
  mpm_port=mwi_mpm_port, mpm_auth_token=mpm_auth_token, mpm_parent_pid=ctx
438
472
  )
439
473
  except ValueError as ve:
440
- print("Error: Invalid type for port: ", ve)
474
+ log.error("Error: Invalid type for port: %s", ve)
441
475
  sys.exit(1)
442
476
 
443
477
 
478
+ def main() -> None:
479
+ """
480
+ The main entry point of the application. Starts the app and run until the shutdown
481
+ signal to terminate the app is received.
482
+ """
483
+ env_vars: namedtuple = _fetch_and_validate_required_env_vars()
484
+ asyncio.run(start_app(env_vars))
485
+
486
+
444
487
  if __name__ == "__main__":
445
488
  # This ensures that the app is not created when the module is imported and
446
489
  # is only started when the script is run directly or via executable invocation
@@ -36,10 +36,9 @@ class OrphanedProcessMonitor:
36
36
  """
37
37
  Triggers the shutdown process by setting the shutdown event.
38
38
  """
39
- from matlab_proxy_manager.web.app import SHUTDOWN_EVENT
40
39
 
41
40
  try:
42
41
  # Set the shutdown async event to signal app shutdown to the app runner
43
- SHUTDOWN_EVENT.set()
42
+ self.app.get("shutdown_event").set()
44
43
  except Exception as ex:
45
44
  log.debug("Unable to set proxy manager shutdown event, err: %s", ex)
@@ -51,4 +51,15 @@ def start_watcher(app: web.Application):
51
51
  observer = Observer()
52
52
  observer.schedule(event_handler, path_to_watch, recursive=True)
53
53
  observer.start()
54
+ app["observer"] = observer
54
55
  return observer
56
+
57
+
58
+ def stop_watcher(app):
59
+ """
60
+ Stop the file system watcher associated with the application.
61
+ This function stops and joins the observer thread if it exists in the application.
62
+ """
63
+ if "observer" in app:
64
+ app["observer"].stop()
65
+ app["observer"].join()
tests/unit/test_app.py CHANGED
@@ -317,6 +317,7 @@ async def test_get_env_config(test_server):
317
317
  "doc_url": "foo",
318
318
  "extension_name": "bar",
319
319
  "extension_name_short_description": "foobar",
320
+ "should_show_shutdown_button": True,
320
321
  "isConcurrencyEnabled": "foobar",
321
322
  "idleTimeoutDuration": 100,
322
323
  }
@@ -364,9 +364,23 @@ def test_are_required_processes_ready(
364
364
  assert actual == expected
365
365
 
366
366
 
367
- @pytest.mark.parametrize("platform", [("linux"), ("windows"), ("mac")])
368
- async def test_track_embedded_connector(mocker_os_patching_fixture, app_state_fixture):
369
- """Test to check track_embedded_connector task
367
+ # The test: test_track_embedded_connector has been split into:
368
+ # 1) test_track_embedded_connector_posix: Test to check if stop_matlab is called on posix systems.
369
+ # 2) test_track_embedded_connector : Test to check if stop_matlab is not called in windows.
370
+
371
+ # In windows, errors are shown as UI windows and calling stop_matlab() if MATLAB had not started in
372
+ # PROCESS_TIMEOUT seconds would remove the window thereby leaving the user without knowing why MATLAB
373
+ # failed to start.
374
+
375
+
376
+ @pytest.mark.parametrize("platform", [("linux"), ("mac")])
377
+ async def test_track_embedded_connector_posix(
378
+ mocker_os_patching_fixture, app_state_fixture
379
+ ):
380
+ """Test to check track_embedded_connector task for posix platforms.
381
+
382
+ Checks if stop_matlab() has been called when the embedded connector doesn't respond
383
+ even after PROCESS_TIMEOUT seconds of starting MATLAB.
370
384
 
371
385
  Args:
372
386
  mocker_os_patching_fixture (mocker): Custom pytest fixture for mocking
@@ -374,7 +388,16 @@ async def test_track_embedded_connector(mocker_os_patching_fixture, app_state_fi
374
388
  """
375
389
 
376
390
  # Arrange
377
- # patching embedded_connector_start_time to EPOCH+1 seconds and state to be "down"
391
+ # Patching embedded_connector_start_time to EPOCH+1 seconds and state to be "down".
392
+
393
+ # For this test, the embedded_connector_start_time can be patched to ant value 600(default PROCESS_TIMEOUT) seconds
394
+ # before the current time.
395
+
396
+ # To always ensure that the time difference between the embedded_connector_start_time
397
+ # and the current time is greater than PROCESS_TIMEOUT, the embedded_connector_start_time is patched to
398
+ # EPOCH + 1 seconds so that the time_diff = current_time - embedded_connector_start_time is greater
399
+ # than PROCESS_TIMEOUT always evaluates to True.
400
+
378
401
  mocker_os_patching_fixture.patch.object(
379
402
  app_state_fixture, "embedded_connector_start_time", new=float(1.0)
380
403
  )
@@ -392,6 +415,58 @@ async def test_track_embedded_connector(mocker_os_patching_fixture, app_state_fi
392
415
  spy.assert_called_once()
393
416
 
394
417
 
418
+ @pytest.mark.parametrize("platform", [("windows")])
419
+ async def test_track_embedded_connector(mocker_os_patching_fixture, app_state_fixture):
420
+ """Test to check track_embedded_connector task on windows.
421
+
422
+ In windows, since errors are shown in native UI windows , calling stop_matlab() would remove them,
423
+ thereby not knowing the error with which MATLAB failed to start.
424
+
425
+ Hence, this test checks that stop_matlab() is not called.
426
+
427
+ Args:
428
+ mocker_os_patching_fixture (mocker): Custom pytest fixture for mocking
429
+ app_state_fixture (AppState): Object of AppState class with defaults set
430
+ """
431
+ # Arrange
432
+ # Patching embedded_connector_start_time to EPOCH+1 seconds and state to be "down".
433
+
434
+ # For this test, the embedded_connector_start_time can be patched to any value 600(default PROCESS_TIMEOUT) seconds
435
+ # before the current time.
436
+
437
+ # To always ensure that the time difference between the embedded_connector_start_time
438
+ # and the current time is greater than PROCESS_TIMEOUT, the embedded_connector_start_time is patched to
439
+ # EPOCH + 1 seconds so that the time_diff = current_time - embedded_connector_start_time is greater
440
+ # than PROCESS_TIMEOUT always evaluates to True.
441
+
442
+ mocker_os_patching_fixture.patch.object(
443
+ app_state_fixture, "embedded_connector_start_time", new=float(1.0)
444
+ )
445
+ mocker_os_patching_fixture.patch.object(
446
+ app_state_fixture, "embedded_connector_state", return_value="down"
447
+ )
448
+
449
+ spy = mocker_os_patching_fixture.spy(app_state_fixture, "stop_matlab")
450
+
451
+ # Act
452
+
453
+ # Unlike the posix test (test_track_embedded_connector_posix) where the task track_embedded_connector_state()
454
+ # would exit automatically after stopping MATLAB, in windows, the task will never exit(until the user checks the error
455
+ # manually and clicks on "Stop MATLAB").
456
+
457
+ # So, the task is manually stopped by raising a timeout error(set to 3 seconds). This is a generous amount of
458
+ # time for the error to be set as a MatlabError in CI systems.
459
+ with pytest.raises(asyncio.TimeoutError):
460
+ await asyncio.wait_for(
461
+ app_state_fixture._AppState__track_embedded_connector_state(),
462
+ timeout=3, # timeout of 3 seconds to account for CI systems. This is to wait for the error to be set as MatlabError.
463
+ )
464
+
465
+ # Assert
466
+ spy.assert_not_called() # In windows, MATLAB process should not be stopped so that the UI error window is not closed.
467
+ assert isinstance(app_state_fixture.error, MatlabError)
468
+
469
+
395
470
  @pytest.mark.parametrize(
396
471
  "env_var_name, filter_prefix, is_filtered",
397
472
  [("MWI_AUTH_TOKEN", "MWI_", None), ("MWIFOO_AUTH_TOKEN", "MWI_", "foo")],
@@ -1,7 +1,6 @@
1
- # Copyright 2020-2023 The MathWorks, Inc.
1
+ # Copyright 2020-2024 The MathWorks, Inc.
2
2
 
3
- """Tests for functions in matlab_proxy/util/mwi_validators.py
4
- """
3
+ """Tests for functions in matlab_proxy/util/mwi_validators.py"""
5
4
 
6
5
  import os
7
6
  import random
@@ -185,8 +184,8 @@ def test_validate_env_config_true():
185
184
  def test_validate_env_config_false():
186
185
  """Passing a non existent config should raise FatalError exception"""
187
186
 
188
- with pytest.raises(FatalError) as e:
189
- config = validators.validate_env_config(str(random.randint(10, 100)))
187
+ with pytest.raises(FatalError):
188
+ validators.validate_env_config(str(random.randint(10, 100)))
190
189
 
191
190
 
192
191
  def test_get_configs():