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/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 diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 9aab96752..89e498f2d 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -40,6 +40,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,6 +57,8 @@ except ImportError: HAS_ANSYS_GRAPHICS = False +shell.initialize_ipython_shell() + def _get_default_addin_configuration() -> AddinConfiguration: configuration = AddinConfiguration() @@ -66,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): @@ -226,9 +232,21 @@ 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 + 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: @@ -248,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: @@ -273,15 +287,20 @@ def __init__(self, db_file=None, private_appdata=False, **kwargs): else: additional_args = "" + self._prepare_interactive_mode() runtime.initialize(self._version, pep8_aliases=pep8_alias) + self._app = _start_application(configuration, self._version, db_file, additional_args) - connect_warnings(self) - self._poster = None self._disposed = False - atexit.register(_dispose_embedded_app, INSTANCES) + atexit.register(_atexit_embedded_app, INSTANCES) INSTANCES.append(self) + self._handle_interactive_shell() + + connect_warnings(self) + self._poster = None + # Clean up the private appdata directory on exit if private_appdata is True if private_appdata: atexit.register(_cleanup_private_appdata, profile) @@ -321,6 +340,41 @@ def _dispose(self): 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) is False: + raise Exception("Interactive mode is only supported on windows") + 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) + self._started_interactive_shell = True + + 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: + """Get whether the application is running in interactive mode.""" + return self._interactive_mode + def open(self, db_file, remove_lock=False): """Open the db file. @@ -490,6 +544,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 new file mode 100644 index 000000000..2fd897a3c --- /dev/null +++ b/src/ansys/mechanical/core/embedding/ipython_shell.py @@ -0,0 +1,208 @@ +# 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. + +"""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 +import time + +from ansys.mechanical.core import LOG + +try: + from IPython import get_ipython + from IPython.core.interactiveshell import InteractiveShell + + 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() +EXEC_THREAD = None +ORIGINAL_RUN_CELL = None +EXECUTION_THREAD_ID: int = None + + +def _idle_sleep(): + time.sleep(0.01) + + +DEFAULT_IDLE_HOOK = _idle_sleep + + +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): + """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. + + Return whether to break out of loop + """ + 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 + + SHELL_HOOKS.start() + shell = InteractiveShell.instance() + EXECUTION_THREAD_ID = threading.get_ident() + while not SHUTDOWN_EVENT.is_set(): + if _exec_from_queue(shell): + break + + # 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): + 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(): + """Cleanup the ipython shell. + + Must be called before the application exits. + May be called from an atexit handler. + """ + LOG.info("Shutting down execution thread") + CODE_QUEUE.put(None) # Unblock the thread + SHUTDOWN_EVENT.set() + EXEC_THREAD.join(timeout=1) + + +def _can_post_ipython_blocks(): + if not HAS_IPYTHON: + raise Exception("`post_ipython_blocks` requires ipython") + + +def in_ipython(): + """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 + 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() + + # 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 get_shell_hooks(): + """Get the shell hooks object.""" + return SHELL_HOOKS diff --git a/src/ansys/mechanical/core/embedding/shell.py b/src/ansys/mechanical/core/embedding/shell.py new file mode 100644 index 000000000..6f990c618 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/shell.py @@ -0,0 +1,122 @@ +# 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 os +import sys +import warnings + +import ansys.mechanical.core.embedding.ipython_shell as ipython_shell +import ansys.mechanical.core.embedding.utils as embedding_utils + +try: + import pythoncom + + HAS_WIN32 = True +except ImportError: + HAS_WIN32 = False + + +def _install_drain_tracer() -> None: + 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` + `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) + + +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 + + if not HAS_WIN32: + if warn: + warnings.warn("""Interactive PyMechanical requires pywin32""") + 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): + + 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(): + _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 end_interactive_shell(): + """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(): + """Initialize the interactive IPython shell.""" + if not _using_interactive_ipython(False): + return + + def _start_hook(): + pythoncom.CoInitialize() + + ipython_shell.get_shell_hooks().start_hook = _start_hook + ipython_shell.post_ipython_blocks() diff --git a/src/ansys/mechanical/core/embedding/utils.py b/src/ansys/mechanical/core/embedding/utils.py index fd873786a..6f11093fe 100644 --- a/src/ansys/mechanical/core/embedding/utils.py +++ b/src/ansys/mechanical/core/embedding/utils.py @@ -25,18 +25,34 @@ 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 + _get_test_helper().Wait(ms) - clr.AddReference("Ans.Common.WB1ManagedUtils") - import Ansys - Ansys.Common.WB1ManagedUtils.TestHelper().Wait(ms) +def drain() -> None: + """Execute all pending work on the main thread. + + Blocks until all the UI messages and other scheduled work complete. + """ + _get_test_helper().Drain() def load_library_windows(library: str) -> int: # pragma: no cover