From d9ce99dbad264fae27df96ebce380837e2404d61 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 28 Oct 2025 15:02:46 -0500 Subject: [PATCH 01/20] Add shell interaction code --- .../core/embedding/ipython_shell.py | 149 ++++++++++++++++++ src/ansys/mechanical/core/embedding/shell.py | 61 +++++++ 2 files changed, 210 insertions(+) create mode 100644 src/ansys/mechanical/core/embedding/ipython_shell.py create mode 100644 src/ansys/mechanical/core/embedding/shell.py diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py new file mode 100644 index 000000000..d52d882c0 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -0,0 +1,149 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Interactive IPython shell for embedding.""" + +import queue +import time +import threading + +from ansys.mechanical.core import LOG + +try: + import pythoncom + HAS_WIN32 = True +except ImportError as e: + HAS_WIN32 = False + +try: + from IPython.core.interactiveshell import InteractiveShell + HAS_IPYTHON = True +except ImportError as e: + HAS_IPYTHON = False + +CODE_QUEUE = queue.Queue() +RESULT_QUEUE = queue.Queue() +SHUTDOWN_EVENT = threading.Event() +APP_INIT_EVENT = threading.Event() +EXEC_THREAD = None +ORIGINAL_RUN_CELL = None +SHELL_HOOK: callable = None +EXECUTION_THREAD_ID: int = None + +def _execution_thread_main(): + global APP_INIT_EVENT + global CODE_QUEUE + global EXECUTION_THREAD_ID + global ORIGINAL_RUN_CELL + global RESULT_QUEUE + global SHUTDOWN_EVENT + global SHELL_HOOK + pythoncom.CoInitialize() + shell = InteractiveShell.instance() + EXECUTION_THREAD_ID = threading.get_ident() + while not SHUTDOWN_EVENT.is_set(): + try: + code = CODE_QUEUE.get_nowait() + except queue.Empty: + # Spin with short sleep + if SHELL_HOOK is None: + time.sleep(.05) + else: + try: + SHELL_HOOK() + except Exception as e: + LOG.error(f"shell hook raised {e}. Uninstalling it.") + SHELL_HOOK = None + continue + if code is None: + break + LOG.info(f"execution thread {threading.get_ident()}") + result = ORIGINAL_RUN_CELL(shell, code, store_history=True) + RESULT_QUEUE.put(result) + +def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell_futures=True): + global CODE_QUEUE + global RESULT_QUEUE + global SHUTDOWN_EVENT + CODE_QUEUE.put(raw_cell) + while not SHUTDOWN_EVENT.is_set(): + try: + return RESULT_QUEUE.get(timeout=0.1) + except queue.Empty: + continue + raise RuntimeError("Execution thread shut down before result was returned.") + +def _cleanup(): + global CODE_QUEUE + global SHUTDOWN_EVENT + global EXEC_THREAD + print("Shutting down execution thread") + SHUTDOWN_EVENT.set() + CODE_QUEUE.put(None) # Unblock the thread + EXEC_THREAD.join(timeout=1) + +def _can_post_ipython_blocks(): + if not HAS_WIN32: + raise Exception("`post_ipython_blocks` requires pywin32") + if not HAS_IPYTHON: + raise Exception("`post_ipython_blocks` requires ipython") + +def in_ipython(): + if not HAS_IPYTHON: + return False + from IPython import get_ipython + return get_ipython() is not None + + +# Capture the original run_cell before patching +def post_ipython_blocks(): + _can_post_ipython_blocks() + global EXEC_THREAD + global ORIGINAL_RUN_CELL + LOG.info(f"original main thread {threading.get_ident()}") + ORIGINAL_RUN_CELL = InteractiveShell.run_cell + EXEC_THREAD = threading.Thread(target=_execution_thread_main, daemon=True) + EXEC_THREAD.start() + + import atexit + atexit.register(_cleanup) + # Patch IPython to delegate to your thread + InteractiveShell.run_cell = _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) + + LOG.info("IPython now runs all cells in your dedicated thread.") + +def install_shell_hook(hook): + global SHELL_HOOK + SHELL_HOOK = hook + + +def try_post_ipython_blocks(): + if in_ipython(): + post_ipython_blocks() + + +def is_in_interactive_thread(): + global EXECUTION_THREAD_ID + if EXECUTION_THREAD_ID is None: + return False + return EXECUTION_THREAD_ID == threading.get_ident() + diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py new file mode 100644 index 000000000..f7736afd1 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -0,0 +1,61 @@ +# Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Shell interaction for PyMechanical embedding.""" + +import sys +import warnings + +def _start_nonblocking_ui_no_ipython(app) -> None: + warnings.warn("""Starting the UI without blocking. + The UI will be stuck while python is executing, except during + non-blocking sleeps. To use a non-blocking sleep, run + `import ansys.mechanical.core.embedding.utils` + `ansys.mechanical.core.embedding.utils.sleep(ms)` + or, use app.wait_with_dialog() to open a dialog box that + will block the python interpreter but keep the UI + responsive.""") + def tracer(frame, event, arg): + if event == "line": + from ansys.mechanical.core.embedding.utils import drain + drain() + return tracer + + sys.settrace(tracer) + app.ExtAPI.Application.StartUI(False) + +def _start_nonblocking_ui_ipython(app) -> None: + """""" + from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook + from ansys.mechanical.core.embedding.utils import sleep + install_shell_hook(lambda: sleep(50)) + app.ExtAPI.Application.StartUI(False) + +def start_nonblocking_ui(app) -> None: + """Start the ui without blocking""" + from ansys.mechanical.core.embedding.ipython_shell import in_ipython, is_in_interactive_thread + if not in_ipython(): + _start_nonblocking_ui_no_ipython(app) + return + if not is_in_interactive_thread(): + raise Exception("Cannot start nonblocking UI from IPython when not in interactive thread.") + _start_nonblocking_ui_ipython(app) \ No newline at end of file From 8f1acc7cad4f7c1539e79a346677ab74d666a8ea Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 28 Oct 2025 15:03:07 -0500 Subject: [PATCH 02/20] Add drain to test helper --- src/ansys/mechanical/core/embedding/utils.py | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/utils.py b/src/ansys/mechanical/core/embedding/utils.py index fd873786a..b9b92aa92 100644 --- a/src/ansys/mechanical/core/embedding/utils.py +++ b/src/ansys/mechanical/core/embedding/utils.py @@ -25,18 +25,29 @@ import ctypes import os +TEST_HELPER = None + +def _get_test_helper(): + global TEST_HELPER + import clr + + clr.AddReference("Ans.Common.WB1ManagedUtils") + import Ansys + + TEST_HELPER = Ansys.Common.WB1ManagedUtils.TestHelper() + return TEST_HELPER def sleep(ms: int) -> None: """Non-blocking sleep for `ms` milliseconds. Mechanical should still work during the sleep. """ - import clr - - clr.AddReference("Ans.Common.WB1ManagedUtils") - import Ansys + _get_test_helper().Wait(ms) - Ansys.Common.WB1ManagedUtils.TestHelper().Wait(ms) +def drain() -> None: + """Block the current thread while all the UI messages + are processed.""" + _get_test_helper().Drain() def load_library_windows(library: str) -> int: # pragma: no cover From fd573b773dfc01d0cef165f44caee471085bfb8d Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 28 Oct 2025 15:03:43 -0500 Subject: [PATCH 03/20] Implement shell interaction and UI mode --- src/ansys/mechanical/core/embedding/app.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 9aab96752..efd85d760 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -56,6 +56,9 @@ except ImportError: HAS_ANSYS_GRAPHICS = False +from ansys.mechanical.core.embedding.ipython_shell import try_post_ipython_blocks +try_post_ipython_blocks() + def _get_default_addin_configuration() -> AddinConfiguration: configuration = AddinConfiguration() @@ -275,6 +278,12 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): runtime.initialize(self._version, pep8_aliases=pep8_alias) self._app = _start_application(configuration, self._version, db_file, additional_args) + + if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI") == "1": + from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook + from ansys.mechanical.core.embedding.utils import sleep + install_shell_hook(lambda: sleep(50)) + connect_warnings(self) self._poster = None @@ -321,6 +330,12 @@ def _dispose(self): self._app.Dispose() self._disposed = True + def wait_with_dialog(self): + if self.version< 261: + raise Exception("wait_with_dialog is only supported with Mechanical 261") + """Wait with dialog open.""" + self._app.BlockingModalDialog("Wait with dialog", "PyMechanical") + def open(self, db_file, remove_lock=False): """Open the db file. From 7356422977b64e5e7d38faf53d44094bad24d055 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 29 Oct 2025 12:56:43 -0500 Subject: [PATCH 04/20] style --- src/ansys/mechanical/core/embedding/app.py | 4 ++- .../core/embedding/ipython_shell.py | 25 +++++++++++++------ src/ansys/mechanical/core/embedding/shell.py | 9 ++++++- src/ansys/mechanical/core/embedding/utils.py | 6 ++++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index efd85d760..b6a4632fe 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -57,6 +57,7 @@ HAS_ANSYS_GRAPHICS = False from ansys.mechanical.core.embedding.ipython_shell import try_post_ipython_blocks + try_post_ipython_blocks() @@ -282,6 +283,7 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI") == "1": from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook from ansys.mechanical.core.embedding.utils import sleep + install_shell_hook(lambda: sleep(50)) connect_warnings(self) @@ -331,7 +333,7 @@ def _dispose(self): self._disposed = True def wait_with_dialog(self): - if self.version< 261: + if self.version < 261: raise Exception("wait_with_dialog is only supported with Mechanical 261") """Wait with dialog open.""" self._app.BlockingModalDialog("Wait with dialog", "PyMechanical") diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index d52d882c0..a8ad1cd13 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates. # SPDX-License-Identifier: MIT # # @@ -23,21 +23,23 @@ """Interactive IPython shell for embedding.""" import queue -import time import threading +import time from ansys.mechanical.core import LOG try: import pythoncom + HAS_WIN32 = True -except ImportError as e: +except ImportError: HAS_WIN32 = False try: from IPython.core.interactiveshell import InteractiveShell + HAS_IPYTHON = True -except ImportError as e: +except ImportError: HAS_IPYTHON = False CODE_QUEUE = queue.Queue() @@ -49,6 +51,7 @@ SHELL_HOOK: callable = None EXECUTION_THREAD_ID: int = None + def _execution_thread_main(): global APP_INIT_EVENT global CODE_QUEUE @@ -66,7 +69,7 @@ def _execution_thread_main(): except queue.Empty: # Spin with short sleep if SHELL_HOOK is None: - time.sleep(.05) + time.sleep(0.05) else: try: SHELL_HOOK() @@ -80,6 +83,7 @@ def _execution_thread_main(): result = ORIGINAL_RUN_CELL(shell, code, store_history=True) RESULT_QUEUE.put(result) + def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell_futures=True): global CODE_QUEUE global RESULT_QUEUE @@ -92,6 +96,7 @@ def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell continue raise RuntimeError("Execution thread shut down before result was returned.") + def _cleanup(): global CODE_QUEUE global SHUTDOWN_EVENT @@ -101,16 +106,19 @@ def _cleanup(): CODE_QUEUE.put(None) # Unblock the thread EXEC_THREAD.join(timeout=1) + def _can_post_ipython_blocks(): if not HAS_WIN32: raise Exception("`post_ipython_blocks` requires pywin32") if not HAS_IPYTHON: raise Exception("`post_ipython_blocks` requires ipython") + def in_ipython(): if not HAS_IPYTHON: return False from IPython import get_ipython + return get_ipython() is not None @@ -125,12 +133,16 @@ def post_ipython_blocks(): EXEC_THREAD.start() import atexit + atexit.register(_cleanup) # Patch IPython to delegate to your thread - InteractiveShell.run_cell = _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) + InteractiveShell.run_cell = _run_cell_in_thread.__get__( + InteractiveShell.instance(), InteractiveShell + ) LOG.info("IPython now runs all cells in your dedicated thread.") + def install_shell_hook(hook): global SHELL_HOOK SHELL_HOOK = hook @@ -146,4 +158,3 @@ def is_in_interactive_thread(): if EXECUTION_THREAD_ID is None: return False return EXECUTION_THREAD_ID == threading.get_ident() - diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index f7736afd1..d51494637 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -25,6 +25,7 @@ import sys import warnings + def _start_nonblocking_ui_no_ipython(app) -> None: warnings.warn("""Starting the UI without blocking. The UI will be stuck while python is executing, except during @@ -34,28 +35,34 @@ def _start_nonblocking_ui_no_ipython(app) -> None: or, use app.wait_with_dialog() to open a dialog box that will block the python interpreter but keep the UI responsive.""") + def tracer(frame, event, arg): if event == "line": from ansys.mechanical.core.embedding.utils import drain + drain() return tracer sys.settrace(tracer) app.ExtAPI.Application.StartUI(False) + def _start_nonblocking_ui_ipython(app) -> None: """""" from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook from ansys.mechanical.core.embedding.utils import sleep + install_shell_hook(lambda: sleep(50)) app.ExtAPI.Application.StartUI(False) + def start_nonblocking_ui(app) -> None: """Start the ui without blocking""" from ansys.mechanical.core.embedding.ipython_shell import in_ipython, is_in_interactive_thread + if not in_ipython(): _start_nonblocking_ui_no_ipython(app) return if not is_in_interactive_thread(): raise Exception("Cannot start nonblocking UI from IPython when not in interactive thread.") - _start_nonblocking_ui_ipython(app) \ No newline at end of file + _start_nonblocking_ui_ipython(app) diff --git a/src/ansys/mechanical/core/embedding/utils.py b/src/ansys/mechanical/core/embedding/utils.py index b9b92aa92..bdf8f0d75 100644 --- a/src/ansys/mechanical/core/embedding/utils.py +++ b/src/ansys/mechanical/core/embedding/utils.py @@ -27,6 +27,7 @@ TEST_HELPER = None + def _get_test_helper(): global TEST_HELPER import clr @@ -37,6 +38,7 @@ def _get_test_helper(): TEST_HELPER = Ansys.Common.WB1ManagedUtils.TestHelper() return TEST_HELPER + def sleep(ms: int) -> None: """Non-blocking sleep for `ms` milliseconds. @@ -44,9 +46,11 @@ def sleep(ms: int) -> None: """ _get_test_helper().Wait(ms) + def drain() -> None: """Block the current thread while all the UI messages - are processed.""" + are processed. + """ _get_test_helper().Drain() From 28ae05e31658103eab2e89a97df601ea3bb3a33f Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 4 Nov 2025 13:55:39 -0600 Subject: [PATCH 05/20] Update ipython shell --- src/ansys/mechanical/core/embedding/app.py | 53 +++++++-- .../core/embedding/ipython_shell.py | 111 ++++++++++++------ src/ansys/mechanical/core/embedding/shell.py | 65 +++++++--- 3 files changed, 163 insertions(+), 66 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index b6a4632fe..b18b5a0c0 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -28,6 +28,7 @@ import os from pathlib import Path import typing +import warnings from ansys.mechanical.core import LOG from ansys.mechanical.core.embedding import initializer, runtime @@ -40,6 +41,7 @@ disconnect_warnings, ) from ansys.mechanical.core.embedding.poster import Poster +import ansys.mechanical.core.embedding.shell as shell from ansys.mechanical.core.embedding.ui import launch_ui from ansys.mechanical.core.feature_flags import get_command_line_arguments @@ -56,10 +58,7 @@ except ImportError: HAS_ANSYS_GRAPHICS = False -from ansys.mechanical.core.embedding.ipython_shell import try_post_ipython_blocks - -try_post_ipython_blocks() - +shell.initialize_ipython_shell() def _get_default_addin_configuration() -> AddinConfiguration: configuration = AddinConfiguration() @@ -277,14 +276,16 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): else: additional_args = "" + self._interactive_mode = kwargs.get("interactive", False) + if self._version < 261 and self.interactive: + raise Exception("Interactive mode is only supported starting with version 261") + + self._prepare_interactive_mode() runtime.initialize(self._version, pep8_aliases=pep8_alias) - self._app = _start_application(configuration, self._version, db_file, additional_args) - if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI") == "1": - from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook - from ansys.mechanical.core.embedding.utils import sleep + self._app = _start_application(configuration, self._version, db_file, additional_args) - install_shell_hook(lambda: sleep(50)) + self._handle_interactive_shell() connect_warnings(self) self._poster = None @@ -329,15 +330,46 @@ def _dispose(self): return self._unsubscribe() disconnect_warnings(self) + self._end_interactive_shell() self._app.Dispose() self._disposed = True + def _prepare_interactive_mode(self): + if not self.interactive: + return + if self._version < 261: + raise Exception("Interactive mode is only supported starting with version 261") + if os.name != "nt": + if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_LINUX_UI", False) == False: + raise Exception("Interactive mode is only supported on windows") + os.environ["ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI"] = "1" + + def _handle_interactive_shell(self): + if not self.interactive: + return + shell.start_interactive_shell(self) + + def _end_interactive_shell(self): + if not self.interactive: + return + shell.end_interactive_shell() + def wait_with_dialog(self): + """Block python while an interactive pop-up is displayed. + + While that pop-up is displayed, Mechanical's main thread will + not be blocked.""" + if not self.interactive: + raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: raise Exception("wait_with_dialog is only supported with Mechanical 261") """Wait with dialog open.""" self._app.BlockingModalDialog("Wait with dialog", "PyMechanical") + @property + def interactive(self) -> bool: + return self._interactive_mode + def open(self, db_file, remove_lock=False): """Open the db file. @@ -507,6 +539,9 @@ def plot(self, obj=None) -> None: >>> app.open("path/to/file.mechdat") >>> app.plot() """ + if self.interactive: + raise Exception("Plotting is not allowed in interactive mode") + _plotter = self.plotter(obj) if _plotter is None: diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index a8ad1cd13..8c3c86e3f 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -20,7 +20,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Interactive IPython shell for embedding.""" +"""Module for scheduling background work in IPython shells. + +Interactive Python, or IPython, offers an enhanced Python shell. This module +schedules all the work of the IPython shell on a background thread, allowing +the Main thread to be used exclusively for the shell frontend. As a result, +user-defined functions can be executed during idle time between blocks. +""" import queue import threading @@ -28,13 +34,6 @@ from ansys.mechanical.core import LOG -try: - import pythoncom - - HAS_WIN32 = True -except ImportError: - HAS_WIN32 = False - try: from IPython.core.interactiveshell import InteractiveShell @@ -48,9 +47,58 @@ APP_INIT_EVENT = threading.Event() EXEC_THREAD = None ORIGINAL_RUN_CELL = None -SHELL_HOOK: callable = None EXECUTION_THREAD_ID: int = None +DEFAULT_IDLE_HOOK = lambda: time.sleep(0.05) + +class ShellHooks: + """IPython shell lifetime hooks.""" + + def __init__(self): + self._idle_hook: callable = DEFAULT_IDLE_HOOK + self._start_hook: callable = None + self._end_hook: callable = None + + @property + def idle_hook(self) -> callable: + """Function to call between IPython block executions.""" + return self._idle_hook + + @idle_hook.setter + def idle_hook(self, value: callable) -> None: + self._idle_hook = value + + @property + def start_hook(self) -> callable: + """Function to call at the start of the block thread.""" + return self._start_hook + + @start_hook.setter + def start_hook(self, value: callable) -> None: + self._start_hook = value + + @property + def end_hook(self) -> callable: + """Function to call when the shell is exited.""" + return self._end_hook + + @end_hook.setter + def end_hook(self, value: callable) -> None: + self._end_hook = value + + def start(self): + if self.start_hook is not None: + self._start_hook() + + def idle(self): + if self.idle_hook is not None: + self._idle_hook() + + def end(self): + if self.end_hook is not None: + self._end_hook() + +SHELL_HOOKS = ShellHooks() def _execution_thread_main(): global APP_INIT_EVENT @@ -59,29 +107,29 @@ def _execution_thread_main(): global ORIGINAL_RUN_CELL global RESULT_QUEUE global SHUTDOWN_EVENT - global SHELL_HOOK - pythoncom.CoInitialize() + global SHELL_HOOKS + + SHELL_HOOKS.start() shell = InteractiveShell.instance() EXECUTION_THREAD_ID = threading.get_ident() while not SHUTDOWN_EVENT.is_set(): try: code = CODE_QUEUE.get_nowait() except queue.Empty: - # Spin with short sleep - if SHELL_HOOK is None: - time.sleep(0.05) - else: - try: - SHELL_HOOK() - except Exception as e: - LOG.error(f"shell hook raised {e}. Uninstalling it.") - SHELL_HOOK = None + # call the idle hook + try: + SHELL_HOOKS.idle() + except Exception as e: + LOG.error(f"shell hook raised {e}. Uninstalling it.") + SHELL_HOOKS.idle_hook = DEFAULT_IDLE_HOOK continue if code is None: + SHELL_HOOKS.end() break LOG.info(f"execution thread {threading.get_ident()}") result = ORIGINAL_RUN_CELL(shell, code, store_history=True) RESULT_QUEUE.put(result) + SHELL_HOOKS.end() def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell_futures=True): @@ -101,15 +149,13 @@ def _cleanup(): global CODE_QUEUE global SHUTDOWN_EVENT global EXEC_THREAD - print("Shutting down execution thread") + LOG.info("Shutting down execution thread") SHUTDOWN_EVENT.set() CODE_QUEUE.put(None) # Unblock the thread EXEC_THREAD.join(timeout=1) def _can_post_ipython_blocks(): - if not HAS_WIN32: - raise Exception("`post_ipython_blocks` requires pywin32") if not HAS_IPYTHON: raise Exception("`post_ipython_blocks` requires ipython") @@ -139,22 +185,9 @@ def post_ipython_blocks(): InteractiveShell.run_cell = _run_cell_in_thread.__get__( InteractiveShell.instance(), InteractiveShell ) - LOG.info("IPython now runs all cells in your dedicated thread.") -def install_shell_hook(hook): - global SHELL_HOOK - SHELL_HOOK = hook - - -def try_post_ipython_blocks(): - if in_ipython(): - post_ipython_blocks() - - -def is_in_interactive_thread(): - global EXECUTION_THREAD_ID - if EXECUTION_THREAD_ID is None: - return False - return EXECUTION_THREAD_ID == threading.get_ident() +def get_shell_hooks(): + global SHELL_HOOKS + return SHELL_HOOKS diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index d51494637..6e21c19bc 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -22,12 +22,23 @@ """Shell interaction for PyMechanical embedding.""" +import os import sys import warnings +import ansys.mechanical.core.embedding.ipython_shell as ipython_shell +import ansys.mechanical.core.embedding.utils as embedding_utils -def _start_nonblocking_ui_no_ipython(app) -> None: - warnings.warn("""Starting the UI without blocking. +try: + import pythoncom + + HAS_WIN32 = True +except ImportError: + HAS_WIN32 = False + +def _install_drain_tracer() -> None: + """Optional.""" + warnings.warn("""Mechanical UI without blocking python. The UI will be stuck while python is executing, except during non-blocking sleeps. To use a non-blocking sleep, run `import ansys.mechanical.core.embedding.utils` @@ -44,25 +55,43 @@ def tracer(frame, event, arg): return tracer sys.settrace(tracer) - app.ExtAPI.Application.StartUI(False) - -def _start_nonblocking_ui_ipython(app) -> None: - """""" - from ansys.mechanical.core.embedding.ipython_shell import install_shell_hook - from ansys.mechanical.core.embedding.utils import sleep +def _uninstall_drain_tracer() -> None: + sys.settrace(None) - install_shell_hook(lambda: sleep(50)) - app.ExtAPI.Application.StartUI(False) +def _use_drain_tracer(): + return os.environ.get("ANSYS_MECHANICAL_EMBEDDING_UI_DRAIN_TRACER") == "1" +def start_interactive_shell(app): + if ipython_shell.in_ipython(): + if not HAS_WIN32: + warnings.warn("""Interactive PyMechanical requires pywin32""") + return + ipython_shell.get_shell_hooks().idle_hook = lambda: embedding_utils.sleep(50) + ipython_shell.get_shell_hooks().end_hook = lambda: app._dispose() + else: + if _use_drain_tracer(): + _install_drain_tracer() + else: + warnings.warn("""Interactive PyMechanical is used without IPython. + The main thread will be held by python, and therefore + the mechanical UI will not be responsive""") -def start_nonblocking_ui(app) -> None: - """Start the ui without blocking""" - from ansys.mechanical.core.embedding.ipython_shell import in_ipython, is_in_interactive_thread +def end_interactive_shell(): + if ipython_shell.in_ipython(): + if not HAS_WIN32: + return + ipython_shell.get_shell_hooks().idle_hook = None + else: + if _use_drain_tracer(): + _uninstall_drain_tracer() - if not in_ipython(): - _start_nonblocking_ui_no_ipython(app) +def initialize_ipython_shell(): + if not ipython_shell.in_ipython(): return - if not is_in_interactive_thread(): - raise Exception("Cannot start nonblocking UI from IPython when not in interactive thread.") - _start_nonblocking_ui_ipython(app) + if not HAS_WIN32: + return + + ipython_shell.get_shell_hooks().start_hook = lambda: pythoncom.CoInitialize() + ipython_shell.post_ipython_blocks() + #ipython_shell.get_shell_hooks().end_hook = lambda: print("DSFKSJDFSFJK") From 60145bc03d11fbe831cdc7d0aec0a558da774b75 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 4 Nov 2025 15:34:57 -0600 Subject: [PATCH 06/20] improve lifetime handling --- src/ansys/mechanical/core/embedding/app.py | 23 +++--- .../core/embedding/ipython_shell.py | 75 +++++++++++-------- src/ansys/mechanical/core/embedding/shell.py | 35 ++++++--- 3 files changed, 79 insertions(+), 54 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index b18b5a0c0..ed7c8415e 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -69,10 +69,13 @@ def _get_default_addin_configuration() -> AddinConfiguration: """List of instances.""" -def _dispose_embedded_app(instances): # pragma: nocover +def _atexit_embedded_app(instances): # pragma: nocover if len(instances) > 0: instance = instances[0] - instance._dispose() + if instance._started_interactive_shell: + shell.end_interactive_shell() + else: + instance._dispose() def _cleanup_private_appdata(profile: UniqueUserProfile): @@ -285,15 +288,15 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): self._app = _start_application(configuration, self._version, db_file, additional_args) + self._disposed = False + atexit.register(_atexit_embedded_app, INSTANCES) + INSTANCES.append(self) + self._handle_interactive_shell() connect_warnings(self) self._poster = None - self._disposed = False - atexit.register(_dispose_embedded_app, INSTANCES) - INSTANCES.append(self) - # Clean up the private appdata directory on exit if private_appdata is True if private_appdata: atexit.register(_cleanup_private_appdata, profile) @@ -330,7 +333,6 @@ def _dispose(self): return self._unsubscribe() disconnect_warnings(self) - self._end_interactive_shell() self._app.Dispose() self._disposed = True @@ -345,14 +347,11 @@ def _prepare_interactive_mode(self): os.environ["ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI"] = "1" def _handle_interactive_shell(self): + self._started_interactive_shell = False if not self.interactive: return shell.start_interactive_shell(self) - - def _end_interactive_shell(self): - if not self.interactive: - return - shell.end_interactive_shell() + self._started_interactive_shell = True def wait_with_dialog(self): """Block python while an interactive pop-up is displayed. diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index 8c3c86e3f..f97477b3f 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -36,15 +36,17 @@ try: from IPython.core.interactiveshell import InteractiveShell + from IPython import get_ipython + FROM_IPYTHON = get_ipython() is not None HAS_IPYTHON = True except ImportError: + FROM_IPYTHON = False HAS_IPYTHON = False CODE_QUEUE = queue.Queue() RESULT_QUEUE = queue.Queue() SHUTDOWN_EVENT = threading.Event() -APP_INIT_EVENT = threading.Event() EXEC_THREAD = None ORIGINAL_RUN_CELL = None EXECUTION_THREAD_ID: int = None @@ -89,6 +91,7 @@ def end_hook(self, value: callable) -> None: def start(self): if self.start_hook is not None: self._start_hook() + self._start_hook = None def idle(self): if self.idle_hook is not None: @@ -97,15 +100,38 @@ def idle(self): def end(self): if self.end_hook is not None: self._end_hook() + self._end_hook = None SHELL_HOOKS = ShellHooks() -def _execution_thread_main(): - global APP_INIT_EVENT +def _exec_from_queue(shell) -> bool: + """Worker function for ipython execution. + + Return whether to break out of loop + """ global CODE_QUEUE - global EXECUTION_THREAD_ID global ORIGINAL_RUN_CELL global RESULT_QUEUE + global SHELL_HOOKS + try: + code = CODE_QUEUE.get_nowait() + except queue.Empty: + # call the idle hook + try: + SHELL_HOOKS.idle() + except Exception as e: + LOG.error(f"shell hook raised {e}. Uninstalling it.") + SHELL_HOOKS.idle_hook = DEFAULT_IDLE_HOOK + return False + if code is None: + return True + LOG.info(f"execution thread {threading.get_ident()}") + result = ORIGINAL_RUN_CELL(shell, code, store_history=True) + RESULT_QUEUE.put(result) + return False + +def _execution_thread_main(): + global EXECUTION_THREAD_ID global SHUTDOWN_EVENT global SHELL_HOOKS @@ -113,23 +139,12 @@ def _execution_thread_main(): shell = InteractiveShell.instance() EXECUTION_THREAD_ID = threading.get_ident() while not SHUTDOWN_EVENT.is_set(): - try: - code = CODE_QUEUE.get_nowait() - except queue.Empty: - # call the idle hook - try: - SHELL_HOOKS.idle() - except Exception as e: - LOG.error(f"shell hook raised {e}. Uninstalling it.") - SHELL_HOOKS.idle_hook = DEFAULT_IDLE_HOOK - continue - if code is None: - SHELL_HOOKS.end() + if _exec_from_queue(shell): break - LOG.info(f"execution thread {threading.get_ident()}") - result = ORIGINAL_RUN_CELL(shell, code, store_history=True) - RESULT_QUEUE.put(result) - SHELL_HOOKS.end() + + # one more execution in case the shutdown event was set and the exit code was not processed? + if SHELL_HOOKS.end_hook is not None: + _exec_from_queue(shell) def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell_futures=True): @@ -145,13 +160,17 @@ def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell raise RuntimeError("Execution thread shut down before result was returned.") -def _cleanup(): +def cleanup(): + """Cleanup the ipython shell. + + Must be called before the application exits. + May be called from an atexit handler.""" global CODE_QUEUE global SHUTDOWN_EVENT global EXEC_THREAD LOG.info("Shutting down execution thread") - SHUTDOWN_EVENT.set() CODE_QUEUE.put(None) # Unblock the thread + SHUTDOWN_EVENT.set() EXEC_THREAD.join(timeout=1) @@ -161,15 +180,13 @@ def _can_post_ipython_blocks(): def in_ipython(): - if not HAS_IPYTHON: - return False - from IPython import get_ipython - - return get_ipython() is not None + """Return whether Python is running from IPython""" + return FROM_IPYTHON # Capture the original run_cell before patching def post_ipython_blocks(): + """Initiate the IPython worker thread for block execution.""" _can_post_ipython_blocks() global EXEC_THREAD global ORIGINAL_RUN_CELL @@ -178,9 +195,6 @@ def post_ipython_blocks(): EXEC_THREAD = threading.Thread(target=_execution_thread_main, daemon=True) EXEC_THREAD.start() - import atexit - - atexit.register(_cleanup) # Patch IPython to delegate to your thread InteractiveShell.run_cell = _run_cell_in_thread.__get__( InteractiveShell.instance(), InteractiveShell @@ -189,5 +203,6 @@ def post_ipython_blocks(): def get_shell_hooks(): + """Get the shell hooks object.""" global SHELL_HOOKS return SHELL_HOOKS diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index 6e21c19bc..3a6fc9650 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -62,13 +62,26 @@ def _uninstall_drain_tracer() -> None: def _use_drain_tracer(): return os.environ.get("ANSYS_MECHANICAL_EMBEDDING_UI_DRAIN_TRACER") == "1" -def start_interactive_shell(app): - if ipython_shell.in_ipython(): - if not HAS_WIN32: +def _using_interactive_ipython(warn: bool): + if not ipython_shell.in_ipython(): + return False + + if not HAS_WIN32: + if warn: warnings.warn("""Interactive PyMechanical requires pywin32""") - return + return False + + if os.environ.get("PYMECHANICAL_NO_INTERCTIVE_IPYTHON") == "1": + return False + return True + +def start_interactive_shell(app): + """Start the interactive IPython shell.""" + if _using_interactive_ipython(True): ipython_shell.get_shell_hooks().idle_hook = lambda: embedding_utils.sleep(50) - ipython_shell.get_shell_hooks().end_hook = lambda: app._dispose() + def _end_hook(): + app._dispose() + ipython_shell.get_shell_hooks().end_hook = _end_hook else: if _use_drain_tracer(): _install_drain_tracer() @@ -78,20 +91,18 @@ def start_interactive_shell(app): the mechanical UI will not be responsive""") def end_interactive_shell(): - if ipython_shell.in_ipython(): - if not HAS_WIN32: - return + """End the interactive IPython shell.""" + if _using_interactive_ipython(False): ipython_shell.get_shell_hooks().idle_hook = None + ipython_shell.cleanup() else: if _use_drain_tracer(): _uninstall_drain_tracer() def initialize_ipython_shell(): - if not ipython_shell.in_ipython(): - return - if not HAS_WIN32: + """Initialize the interactive IPython shell.""" + if not _using_interactive_ipython(False): return ipython_shell.get_shell_hooks().start_hook = lambda: pythoncom.CoInitialize() ipython_shell.post_ipython_blocks() - #ipython_shell.get_shell_hooks().end_hook = lambda: print("DSFKSJDFSFJK") From b8306c393f06f298b2bab03642c64ac3695edfd2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:28:38 +0000 Subject: [PATCH 07/20] chore: auto fixes from pre-commit hooks --- src/ansys/mechanical/core/embedding/app.py | 5 +++-- src/ansys/mechanical/core/embedding/ipython_shell.py | 11 ++++++++--- src/ansys/mechanical/core/embedding/shell.py | 9 +++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index ed7c8415e..1d181bcda 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -28,7 +28,6 @@ import os from pathlib import Path import typing -import warnings from ansys.mechanical.core import LOG from ansys.mechanical.core.embedding import initializer, runtime @@ -60,6 +59,7 @@ shell.initialize_ipython_shell() + def _get_default_addin_configuration() -> AddinConfiguration: configuration = AddinConfiguration() return configuration @@ -357,7 +357,8 @@ def wait_with_dialog(self): """Block python while an interactive pop-up is displayed. While that pop-up is displayed, Mechanical's main thread will - not be blocked.""" + not be blocked. + """ if not self.interactive: raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index f97477b3f..9ae2ce9fc 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -35,10 +35,10 @@ from ansys.mechanical.core import LOG try: - from IPython.core.interactiveshell import InteractiveShell from IPython import get_ipython + from IPython.core.interactiveshell import InteractiveShell - FROM_IPYTHON = get_ipython() is not None + FROM_IPYTHON = get_ipython() is not None HAS_IPYTHON = True except ImportError: FROM_IPYTHON = False @@ -53,6 +53,7 @@ DEFAULT_IDLE_HOOK = lambda: time.sleep(0.05) + class ShellHooks: """IPython shell lifetime hooks.""" @@ -102,8 +103,10 @@ def end(self): self._end_hook() self._end_hook = None + SHELL_HOOKS = ShellHooks() + def _exec_from_queue(shell) -> bool: """Worker function for ipython execution. @@ -130,6 +133,7 @@ def _exec_from_queue(shell) -> bool: RESULT_QUEUE.put(result) return False + def _execution_thread_main(): global EXECUTION_THREAD_ID global SHUTDOWN_EVENT @@ -164,7 +168,8 @@ def cleanup(): """Cleanup the ipython shell. Must be called before the application exits. - May be called from an atexit handler.""" + May be called from an atexit handler. + """ global CODE_QUEUE global SHUTDOWN_EVENT global EXEC_THREAD diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index 3a6fc9650..ea4018296 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -36,6 +36,7 @@ except ImportError: HAS_WIN32 = False + def _install_drain_tracer() -> None: """Optional.""" warnings.warn("""Mechanical UI without blocking python. @@ -56,12 +57,15 @@ def tracer(frame, event, arg): sys.settrace(tracer) + def _uninstall_drain_tracer() -> None: sys.settrace(None) + def _use_drain_tracer(): return os.environ.get("ANSYS_MECHANICAL_EMBEDDING_UI_DRAIN_TRACER") == "1" + def _using_interactive_ipython(warn: bool): if not ipython_shell.in_ipython(): return False @@ -75,12 +79,15 @@ def _using_interactive_ipython(warn: bool): return False return True + def start_interactive_shell(app): """Start the interactive IPython shell.""" if _using_interactive_ipython(True): ipython_shell.get_shell_hooks().idle_hook = lambda: embedding_utils.sleep(50) + def _end_hook(): app._dispose() + ipython_shell.get_shell_hooks().end_hook = _end_hook else: if _use_drain_tracer(): @@ -90,6 +97,7 @@ def _end_hook(): The main thread will be held by python, and therefore the mechanical UI will not be responsive""") + def end_interactive_shell(): """End the interactive IPython shell.""" if _using_interactive_ipython(False): @@ -99,6 +107,7 @@ def end_interactive_shell(): if _use_drain_tracer(): _uninstall_drain_tracer() + def initialize_ipython_shell(): """Initialize the interactive IPython shell.""" if not _using_interactive_ipython(False): From 67a9638157972716291ade4592472940b81269e2 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:57:12 +0000 Subject: [PATCH 08/20] chore: adding changelog file 1390.added.md [dependabot-skip] --- doc/changelog.d/1390.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/1390.added.md diff --git a/doc/changelog.d/1390.added.md b/doc/changelog.d/1390.added.md new file mode 100644 index 000000000..161591c98 --- /dev/null +++ b/doc/changelog.d/1390.added.md @@ -0,0 +1 @@ +Embedding with UI From 033a9e9a17090be4b20ce544c618717a02dabe74 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Tue, 11 Nov 2025 07:59:08 -0600 Subject: [PATCH 09/20] style --- src/ansys/mechanical/core/embedding/app.py | 8 ++++--- .../core/embedding/ipython_shell.py | 23 +++++++++++++++---- src/ansys/mechanical/core/embedding/shell.py | 10 +++++++- src/ansys/mechanical/core/embedding/utils.py | 5 ++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index ed7c8415e..5b7cecba1 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -28,7 +28,6 @@ import os from pathlib import Path import typing -import warnings from ansys.mechanical.core import LOG from ansys.mechanical.core.embedding import initializer, runtime @@ -60,6 +59,7 @@ shell.initialize_ipython_shell() + def _get_default_addin_configuration() -> AddinConfiguration: configuration = AddinConfiguration() return configuration @@ -342,7 +342,7 @@ def _prepare_interactive_mode(self): if self._version < 261: raise Exception("Interactive mode is only supported starting with version 261") if os.name != "nt": - if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_LINUX_UI", False) == False: + if os.environ.get("ANSYS_MECHANICAL_EMBEDDING_LINUX_UI", False) is False: raise Exception("Interactive mode is only supported on windows") os.environ["ANSYS_MECHANICAL_EMBEDDING_START_WITH_UI"] = "1" @@ -357,7 +357,8 @@ def wait_with_dialog(self): """Block python while an interactive pop-up is displayed. While that pop-up is displayed, Mechanical's main thread will - not be blocked.""" + not be blocked. + """ if not self.interactive: raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: @@ -367,6 +368,7 @@ def wait_with_dialog(self): @property def interactive(self) -> bool: + """Get whether the application is running in interactive mode.""" return self._interactive_mode def open(self, db_file, remove_lock=False): diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index f97477b3f..a0db0dccf 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -35,10 +35,10 @@ from ansys.mechanical.core import LOG try: - from IPython.core.interactiveshell import InteractiveShell from IPython import get_ipython + from IPython.core.interactiveshell import InteractiveShell - FROM_IPYTHON = get_ipython() is not None + FROM_IPYTHON = get_ipython() is not None HAS_IPYTHON = True except ImportError: FROM_IPYTHON = False @@ -51,7 +51,13 @@ ORIGINAL_RUN_CELL = None EXECUTION_THREAD_ID: int = None -DEFAULT_IDLE_HOOK = lambda: time.sleep(0.05) + +def _idle_sleep(): + time.sleep(0.01) + + +DEFAULT_IDLE_HOOK = _idle_sleep + class ShellHooks: """IPython shell lifetime hooks.""" @@ -89,21 +95,26 @@ def end_hook(self, value: callable) -> None: self._end_hook = value def start(self): + """Signal handler when the shell starts.""" if self.start_hook is not None: self._start_hook() self._start_hook = None def idle(self): + """Signal handler when the shell is idle.""" if self.idle_hook is not None: self._idle_hook() def end(self): + """Signal handler when the shell ends.""" if self.end_hook is not None: self._end_hook() self._end_hook = None + SHELL_HOOKS = ShellHooks() + def _exec_from_queue(shell) -> bool: """Worker function for ipython execution. @@ -130,6 +141,7 @@ def _exec_from_queue(shell) -> bool: RESULT_QUEUE.put(result) return False + def _execution_thread_main(): global EXECUTION_THREAD_ID global SHUTDOWN_EVENT @@ -164,7 +176,8 @@ def cleanup(): """Cleanup the ipython shell. Must be called before the application exits. - May be called from an atexit handler.""" + May be called from an atexit handler. + """ global CODE_QUEUE global SHUTDOWN_EVENT global EXEC_THREAD @@ -180,7 +193,7 @@ def _can_post_ipython_blocks(): def in_ipython(): - """Return whether Python is running from IPython""" + """Return whether Python is running from IPython.""" return FROM_IPYTHON diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index 3a6fc9650..3d61f4d7d 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -36,8 +36,8 @@ except ImportError: HAS_WIN32 = False + def _install_drain_tracer() -> None: - """Optional.""" warnings.warn("""Mechanical UI without blocking python. The UI will be stuck while python is executing, except during non-blocking sleeps. To use a non-blocking sleep, run @@ -56,12 +56,15 @@ def tracer(frame, event, arg): sys.settrace(tracer) + def _uninstall_drain_tracer() -> None: sys.settrace(None) + def _use_drain_tracer(): return os.environ.get("ANSYS_MECHANICAL_EMBEDDING_UI_DRAIN_TRACER") == "1" + def _using_interactive_ipython(warn: bool): if not ipython_shell.in_ipython(): return False @@ -75,12 +78,15 @@ def _using_interactive_ipython(warn: bool): return False return True + def start_interactive_shell(app): """Start the interactive IPython shell.""" if _using_interactive_ipython(True): ipython_shell.get_shell_hooks().idle_hook = lambda: embedding_utils.sleep(50) + def _end_hook(): app._dispose() + ipython_shell.get_shell_hooks().end_hook = _end_hook else: if _use_drain_tracer(): @@ -90,6 +96,7 @@ def _end_hook(): The main thread will be held by python, and therefore the mechanical UI will not be responsive""") + def end_interactive_shell(): """End the interactive IPython shell.""" if _using_interactive_ipython(False): @@ -99,6 +106,7 @@ def end_interactive_shell(): if _use_drain_tracer(): _uninstall_drain_tracer() + def initialize_ipython_shell(): """Initialize the interactive IPython shell.""" if not _using_interactive_ipython(False): diff --git a/src/ansys/mechanical/core/embedding/utils.py b/src/ansys/mechanical/core/embedding/utils.py index bdf8f0d75..6f11093fe 100644 --- a/src/ansys/mechanical/core/embedding/utils.py +++ b/src/ansys/mechanical/core/embedding/utils.py @@ -48,8 +48,9 @@ def sleep(ms: int) -> None: def drain() -> None: - """Block the current thread while all the UI messages - are processed. + """Execute all pending work on the main thread. + + Blocks until all the UI messages and other scheduled work complete. """ _get_test_helper().Drain() From c36a256b937dfa78f100aa78580187cf3dc80718 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:59:33 +0000 Subject: [PATCH 10/20] chore: auto fixes from pre-commit hooks --- src/ansys/mechanical/core/embedding/ipython_shell.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index 8ae0461f0..a0db0dccf 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -59,7 +59,6 @@ def _idle_sleep(): DEFAULT_IDLE_HOOK = _idle_sleep - class ShellHooks: """IPython shell lifetime hooks.""" From 805b42f1dffa4b1f9b116e0025aa9bd0b0a1115d Mon Sep 17 00:00:00 2001 From: kmcadams Date: Tue, 11 Nov 2025 14:17:48 -0500 Subject: [PATCH 11/20] change self.interactive to self._intereactive_mode --- src/ansys/mechanical/core/embedding/app.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 5b7cecba1..32ff6732b 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -280,7 +280,7 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): additional_args = "" self._interactive_mode = kwargs.get("interactive", False) - if self._version < 261 and self.interactive: + if self._version < 261 and self._interactive_mode: raise Exception("Interactive mode is only supported starting with version 261") self._prepare_interactive_mode() @@ -337,7 +337,7 @@ def _dispose(self): self._disposed = True def _prepare_interactive_mode(self): - if not self.interactive: + if not self._interactive_mode: return if self._version < 261: raise Exception("Interactive mode is only supported starting with version 261") @@ -348,7 +348,7 @@ def _prepare_interactive_mode(self): def _handle_interactive_shell(self): self._started_interactive_shell = False - if not self.interactive: + if not self._interactive_mode: return shell.start_interactive_shell(self) self._started_interactive_shell = True @@ -359,7 +359,7 @@ def wait_with_dialog(self): While that pop-up is displayed, Mechanical's main thread will not be blocked. """ - if not self.interactive: + if not self._interactive_mode: raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: raise Exception("wait_with_dialog is only supported with Mechanical 261") @@ -540,7 +540,7 @@ def plot(self, obj=None) -> None: >>> app.open("path/to/file.mechdat") >>> app.plot() """ - if self.interactive: + if self._interactive_mode: raise Exception("Plotting is not allowed in interactive mode") _plotter = self.plotter(obj) From 20d629e769d12802d6983a8283f9299225d97c74 Mon Sep 17 00:00:00 2001 From: kmcadams Date: Tue, 11 Nov 2025 16:23:55 -0500 Subject: [PATCH 12/20] move interactive mode before building_gallery --- src/ansys/mechanical/core/embedding/app.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 32ff6732b..ce822d725 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -232,6 +232,9 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): # Get the globals dictionary from kwargs globals = kwargs.get("globals") + # Get the interactive mode from kwargs + self._interactive_mode = kwargs.get("interactive", False) + # Set messages to None before BUILDING_GALLERY check self._messages = None @@ -279,8 +282,7 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): else: additional_args = "" - self._interactive_mode = kwargs.get("interactive", False) - if self._version < 261 and self._interactive_mode: + if self._version < 261 and self.interactive: raise Exception("Interactive mode is only supported starting with version 261") self._prepare_interactive_mode() @@ -337,7 +339,7 @@ def _dispose(self): self._disposed = True def _prepare_interactive_mode(self): - if not self._interactive_mode: + if not self.interactive: return if self._version < 261: raise Exception("Interactive mode is only supported starting with version 261") @@ -348,7 +350,7 @@ def _prepare_interactive_mode(self): def _handle_interactive_shell(self): self._started_interactive_shell = False - if not self._interactive_mode: + if not self.interactive: return shell.start_interactive_shell(self) self._started_interactive_shell = True @@ -359,7 +361,7 @@ def wait_with_dialog(self): While that pop-up is displayed, Mechanical's main thread will not be blocked. """ - if not self._interactive_mode: + if not self.interactive: raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: raise Exception("wait_with_dialog is only supported with Mechanical 261") @@ -540,7 +542,7 @@ def plot(self, obj=None) -> None: >>> app.open("path/to/file.mechdat") >>> app.plot() """ - if self._interactive_mode: + if self.interactive: raise Exception("Plotting is not allowed in interactive mode") _plotter = self.plotter(obj) From 57ec539a22e82ab3e4365b88b8550d340eeed60c Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 07:59:55 -0600 Subject: [PATCH 13/20] fix some issue --- src/ansys/mechanical/core/embedding/app.py | 24 ++++++++++--------- .../core/embedding/ipython_shell.py | 13 ---------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index ce822d725..ab3d04d42 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -238,6 +238,15 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): # Set messages to None before BUILDING_GALLERY check self._messages = None + version = kwargs.get("version") + if version is not None: + try: + version = int(version) + except ValueError: + raise ValueError( + "The version must be an integer or of type that can be converted to an integer." + ) + # If the building gallery flag is set, we need to share the instance # This can apply to running the `make -C doc html` command if BUILDING_GALLERY: @@ -257,15 +266,11 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): if len(INSTANCES) > 0: raise Exception("Cannot have more than one embedded mechanical instance!") - version = kwargs.get("version") - if version is not None: - try: - version = int(version) - except ValueError: - raise ValueError( - "The version must be an integer or of type that can be converted to an integer." - ) self._version = initializer.initialize(version) + + if self._version < 261 and self.interactive: + raise Exception("Interactive mode is only supported starting with version 261") + configuration = kwargs.get("config", _get_default_addin_configuration()) if private_appdata: @@ -282,9 +287,6 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): else: additional_args = "" - if self._version < 261 and self.interactive: - raise Exception("Interactive mode is only supported starting with version 261") - self._prepare_interactive_mode() runtime.initialize(self._version, pep8_aliases=pep8_alias) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index a0db0dccf..2fd897a3c 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -120,10 +120,6 @@ def _exec_from_queue(shell) -> bool: Return whether to break out of loop """ - global CODE_QUEUE - global ORIGINAL_RUN_CELL - global RESULT_QUEUE - global SHELL_HOOKS try: code = CODE_QUEUE.get_nowait() except queue.Empty: @@ -144,8 +140,6 @@ def _exec_from_queue(shell) -> bool: def _execution_thread_main(): global EXECUTION_THREAD_ID - global SHUTDOWN_EVENT - global SHELL_HOOKS SHELL_HOOKS.start() shell = InteractiveShell.instance() @@ -160,9 +154,6 @@ def _execution_thread_main(): def _run_cell_in_thread(self, raw_cell, store_history=False, silent=False, shell_futures=True): - global CODE_QUEUE - global RESULT_QUEUE - global SHUTDOWN_EVENT CODE_QUEUE.put(raw_cell) while not SHUTDOWN_EVENT.is_set(): try: @@ -178,9 +169,6 @@ def cleanup(): Must be called before the application exits. May be called from an atexit handler. """ - global CODE_QUEUE - global SHUTDOWN_EVENT - global EXEC_THREAD LOG.info("Shutting down execution thread") CODE_QUEUE.put(None) # Unblock the thread SHUTDOWN_EVENT.set() @@ -217,5 +205,4 @@ def post_ipython_blocks(): def get_shell_hooks(): """Get the shell hooks object.""" - global SHELL_HOOKS return SHELL_HOOKS From 38ad6f6697d8691d886b81399544f11ca57c1c98 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:19:33 -0600 Subject: [PATCH 14/20] fix --- src/ansys/mechanical/core/embedding/app.py | 2 +- src/ansys/mechanical/core/embedding/shell.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index ab3d04d42..89e498f2d 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -367,7 +367,7 @@ def wait_with_dialog(self): raise Exception("wait_with_dialog is only supported in interactive mode!") if self.version < 261: raise Exception("wait_with_dialog is only supported with Mechanical 261") - """Wait with dialog open.""" + # Wait with dialog open self._app.BlockingModalDialog("Wait with dialog", "PyMechanical") @property diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index 3d61f4d7d..d9a977d6f 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -82,11 +82,14 @@ def _using_interactive_ipython(warn: bool): def start_interactive_shell(app): """Start the interactive IPython shell.""" if _using_interactive_ipython(True): - ipython_shell.get_shell_hooks().idle_hook = lambda: embedding_utils.sleep(50) + + def _idle_hook(): + embedding_utils.sleep(50) def _end_hook(): app._dispose() + ipython_shell.get_shell_hooks().idle_hook = _idle_hook ipython_shell.get_shell_hooks().end_hook = _end_hook else: if _use_drain_tracer(): From a0b4578f9f436c89168d8de76af9f535e96d08a4 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:31:35 -0600 Subject: [PATCH 15/20] fix --- src/ansys/mechanical/core/embedding/shell.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py index d9a977d6f..6f990c618 100644 --- a/src/ansys/mechanical/core/embedding/shell.py +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -115,5 +115,8 @@ def initialize_ipython_shell(): if not _using_interactive_ipython(False): return - ipython_shell.get_shell_hooks().start_hook = lambda: pythoncom.CoInitialize() + def _start_hook(): + pythoncom.CoInitialize() + + ipython_shell.get_shell_hooks().start_hook = _start_hook ipython_shell.post_ipython_blocks() From 003ae472687369d98bb083a11b4ad27d3dc9d594 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:35:56 -0600 Subject: [PATCH 16/20] try --- src/ansys/mechanical/core/embedding/ipython_shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index 2fd897a3c..c915be7cb 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -199,7 +199,7 @@ def post_ipython_blocks(): # Patch IPython to delegate to your thread InteractiveShell.run_cell = _run_cell_in_thread.__get__( InteractiveShell.instance(), InteractiveShell - ) + ) # type: ignore LOG.info("IPython now runs all cells in your dedicated thread.") From b5c963b7fc3ba8700fc50cbd1efb275587adda36 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:42:34 -0600 Subject: [PATCH 17/20] try --- src/ansys/mechanical/core/embedding/ipython_shell.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index c915be7cb..9aae1c0a4 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -197,8 +197,8 @@ def post_ipython_blocks(): EXEC_THREAD.start() # Patch IPython to delegate to your thread - InteractiveShell.run_cell = _run_cell_in_thread.__get__( - InteractiveShell.instance(), InteractiveShell + InteractiveShell.run_cell = ( # type: ignore + _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # type: ignore ) # type: ignore LOG.info("IPython now runs all cells in your dedicated thread.") From 06e14b73ab72e3900bc0d1336c3176a19460813b Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:46:48 -0600 Subject: [PATCH 18/20] fix --- src/ansys/mechanical/core/embedding/ipython_shell.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index 9aae1c0a4..514b00fd6 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -197,9 +197,9 @@ def post_ipython_blocks(): EXEC_THREAD.start() # Patch IPython to delegate to your thread - InteractiveShell.run_cell = ( # type: ignore - _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # type: ignore - ) # type: ignore + InteractiveShell.run_cell = ( + _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # pyright: ignore + ) LOG.info("IPython now runs all cells in your dedicated thread.") From 66908c146637e691966a3247201c7078a39f553d Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:50:04 -0600 Subject: [PATCH 19/20] try --- src/ansys/mechanical/core/embedding/ipython_shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index 514b00fd6..e2523f8ff 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -198,7 +198,7 @@ def post_ipython_blocks(): # Patch IPython to delegate to your thread InteractiveShell.run_cell = ( - _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # pyright: ignore + _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # codacy: ignore ) LOG.info("IPython now runs all cells in your dedicated thread.") From ad4b30a0cab5a0ab7b0af0fe50209c063537c894 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa Date: Wed, 12 Nov 2025 08:56:46 -0600 Subject: [PATCH 20/20] fix --- .codacy.yaml | 5 ++++- src/ansys/mechanical/core/embedding/ipython_shell.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.codacy.yaml b/.codacy.yaml index 70fe9bda0..e2d0d3b2b 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -1,2 +1,5 @@ exclude_paths: - - "examples/**" \ No newline at end of file + - "examples/**" + + # _run_cell_in_thread.__get__ causes an incorrect critical codacy error + - "src/ansys/mechanical/core/embedding/ipython_shell.py" \ No newline at end of file diff --git a/src/ansys/mechanical/core/embedding/ipython_shell.py b/src/ansys/mechanical/core/embedding/ipython_shell.py index e2523f8ff..2fd897a3c 100644 --- a/src/ansys/mechanical/core/embedding/ipython_shell.py +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -197,8 +197,8 @@ def post_ipython_blocks(): EXEC_THREAD.start() # Patch IPython to delegate to your thread - InteractiveShell.run_cell = ( - _run_cell_in_thread.__get__(InteractiveShell.instance(), InteractiveShell) # codacy: ignore + InteractiveShell.run_cell = _run_cell_in_thread.__get__( + InteractiveShell.instance(), InteractiveShell ) LOG.info("IPython now runs all cells in your dedicated thread.")