Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .codacy.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
exclude_paths:
- "examples/**"
- "examples/**"

# _run_cell_in_thread.__get__ causes an incorrect critical codacy error
- "src/ansys/mechanical/core/embedding/ipython_shell.py"
1 change: 1 addition & 0 deletions doc/changelog.d/1390.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Embedding with UI
83 changes: 70 additions & 13 deletions src/ansys/mechanical/core/embedding/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -56,6 +57,8 @@
except ImportError:
HAS_ANSYS_GRAPHICS = False

shell.initialize_ipython_shell()


def _get_default_addin_configuration() -> AddinConfiguration:
configuration = AddinConfiguration()
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
208 changes: 208 additions & 0 deletions src/ansys/mechanical/core/embedding/ipython_shell.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading