diff --git a/README.rst b/README.rst index d2c43abe8..e30aebcab 100644 --- a/README.rst +++ b/README.rst @@ -1,153 +1,154 @@ -.. image:: https://raw.githubusercontent.com/ansys/pymechanical/main/doc/source/_static/logo/pymechanical-logo.png - :alt: PyMechanical logo - :width: 580px - - -|pyansys| |pypi| |python| |GH-CI| |codecov| |MIT| |black| - -.. |pyansys| image:: https://img.shields.io/badge/Py-Ansys-ffc107.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABDklEQVQ4jWNgoDfg5mD8vE7q/3bpVyskbW0sMRUwofHD7Dh5OBkZGBgW7/3W2tZpa2tLQEOyOzeEsfumlK2tbVpaGj4N6jIs1lpsDAwMJ278sveMY2BgCA0NFRISwqkhyQ1q/Nyd3zg4OBgYGNjZ2ePi4rB5loGBhZnhxTLJ/9ulv26Q4uVk1NXV/f///////69du4Zdg78lx//t0v+3S88rFISInD59GqIH2esIJ8G9O2/XVwhjzpw5EAam1xkkBJn/bJX+v1365hxxuCAfH9+3b9/+////48cPuNehNsS7cDEzMTAwMMzb+Q2u4dOnT2vWrMHu9ZtzxP9vl/69RVpCkBlZ3N7enoDXBwEAAA+YYitOilMVAAAAAElFTkSuQmCC - :target: https://docs.pyansys.com/ - :alt: PyAnsys - -.. |pypi| image:: https://img.shields.io/pypi/v/ansys-mechanical-core.svg?logo=python&logoColor=white - :target: https://pypi.org/project/ansys-mechanical-core - :alt: PyPI - -.. |python| image:: https://img.shields.io/pypi/pyversions/ansys-mechanical-core?logo=pypi - :target: https://pypi.org/project/ansys-mechanical-core - :alt: Python - -.. |codecov| image:: https://codecov.io/gh/ansys/pymechanical/branch/main/graph/badge.svg - :target: https://app.codecov.io/gh/ansys/pymechanical - :alt: Codecov - -.. |GH-CI| image:: https://github.com/ansys/pymechanical/actions/workflows/ci_cd.yml/badge.svg - :target: https://github.com/ansys/pymechanical/actions/workflows/ci_cd.yml - :alt: GH-CI - -.. |MIT| image:: https://img.shields.io/badge/License-MIT-yellow.svg - :target: https://opensource.org/licenses/MIT - :alt: MIT - -.. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=flat - :target: https://github.com/psf/black - :alt: Black - -.. |pre-commit| image:: https://results.pre-commit.ci/badge/github/ansys/pymechanical/main.svg?style=flat - :target: https://results.pre-commit.ci/latest/github/ansys/pymechanical/main - :alt: pre-commit - -Overview --------- - -PyMechanical brings Ansys Mechanical to Python. It enables your Python programs to use -Mechanical within Python's ecosystem. It includes the ability to: - -- Connect to a remote Mechanical session -- Embed an instance of Mechanical directly as a Python object - - -Install the package -------------------- - -Install PyMechanical using ``pip`` with:: - - pip install ansys-mechanical-core - -For more information, see `Install the package `_ -in the PyMechanical documentation. - - -Dependencies ------------- - -You must have a licensed copy of `Ansys Mechanical `_ -installed. When using an embedded instance, that installation must be runnable from the -same computer as your Python program. When using a remote session, a connection to that -session must be reachable from your Python program. - -Getting started ---------------- - -PyMechanical uses the built-in scripting capabilities of Mechanical. For information on the -scripting APIs available, see the `Scripting in Mechanical Guide -`_ in the -Ansys Help. - -Configuring the mechanical installation -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -On a Windows system, the environment variable ``AWP_ROOT`` is configured when Mechanical is -installed, where ```` is the Mechanical release number, such as ``242`` for release 2024 R2. -PyMechanical automatically uses this environment variable (or variables if there are multiple -installations of different versions) to locate the latest Mechanical installation. On a Linux -system, you must configure the ``AWP_ROOT`` environment variable to point to the -absolute path of a Mechanical installation. - -Starting a remote session -^^^^^^^^^^^^^^^^^^^^^^^^^ - -To start a remote session of Mechanical on your computer from Python, use the ``launch_mechanical()`` -method. This methods returns an object representing the connection to the session: - -.. code:: python - - import ansys.mechanical.core as pymechanical - - mechanical = pymechanical.launch_mechanical() - -Running commands on the remote session -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Given a connection to a remote session, you can send an IronPython script. This uses the built-in -scripting capabilities of Mechanical. Here is an example: - -.. code:: python - - result = mechanical.run_python_script("2+3") - result = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - - -Using an embedded instance of Mechanical as a Python object -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -PyMechanical also supports directly embedding an instance of Mechanical as a Python object. -In this mode, there is no externally running instance of Mechanical. This feature is supported -on Windows and Linux for version 2023 R2 and later. Here is an example: - -.. code:: python - - import ansys.mechanical.core as pymechanical - - app = pymechanical.App() - app.update_globals(globals()) - project_dir = DataModel.Project.ProjectDirectory - -Documentation and issues ------------------------- - -Documentation for the latest stable release of PyMechanical is hosted at `PyMechanical documentation -`_. - -In the upper right corner of the documentation's title bar, there is an option for switching from -viewing the documentation for the latest stable release to viewing the documentation for the -development version or previously released versions. - -You can also `view `_ or -`download `_ the -PyMechanical cheat sheet. This one-page reference provides syntax rules and commands -for using PyMechanical. - -On the `PyMechanical Issues `_ page, -you can create issues to report bugs and request new features. On the `PyMechanical Discussions -`_ page or the `Discussions `_ -page on the Ansys Developer portal, you can post questions, share ideas, and get community feedback. - -To reach the project support team, email `pyansys.core@ansys.com `_. - -Testing and development ------------------------ - -If you would like to test or contribute to the development of PyMechanical, see -`Contribute `_ in +.. image:: https://raw.githubusercontent.com/ansys/pymechanical/main/doc/source/_static/logo/pymechanical-logo.png + :alt: PyMechanical logo + :width: 580px + + +|pyansys| |pypi| |python| |GH-CI| |codecov| |MIT| |black| + +.. |pyansys| image:: https://img.shields.io/badge/Py-Ansys-ffc107.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABDklEQVQ4jWNgoDfg5mD8vE7q/3bpVyskbW0sMRUwofHD7Dh5OBkZGBgW7/3W2tZpa2tLQEOyOzeEsfumlK2tbVpaGj4N6jIs1lpsDAwMJ278sveMY2BgCA0NFRISwqkhyQ1q/Nyd3zg4OBgYGNjZ2ePi4rB5loGBhZnhxTLJ/9ulv26Q4uVk1NXV/f///////69du4Zdg78lx//t0v+3S88rFISInD59GqIH2esIJ8G9O2/XVwhjzpw5EAam1xkkBJn/bJX+v1365hxxuCAfH9+3b9/+////48cPuNehNsS7cDEzMTAwMMzb+Q2u4dOnT2vWrMHu9ZtzxP9vl/69RVpCkBlZ3N7enoDXBwEAAA+YYitOilMVAAAAAElFTkSuQmCC + :target: https://docs.pyansys.com/ + :alt: PyAnsys + +.. |pypi| image:: https://img.shields.io/pypi/v/ansys-mechanical-core.svg?logo=python&logoColor=white + :target: https://pypi.org/project/ansys-mechanical-core + :alt: PyPI + +.. |python| image:: https://img.shields.io/pypi/pyversions/ansys-mechanical-core?logo=pypi + :target: https://pypi.org/project/ansys-mechanical-core + :alt: Python + +.. |codecov| image:: https://codecov.io/gh/ansys/pymechanical/branch/main/graph/badge.svg + :target: https://app.codecov.io/gh/ansys/pymechanical + :alt: Codecov + +.. |GH-CI| image:: https://github.com/ansys/pymechanical/actions/workflows/ci_cd.yml/badge.svg + :target: https://github.com/ansys/pymechanical/actions/workflows/ci_cd.yml + :alt: GH-CI + +.. |MIT| image:: https://img.shields.io/badge/License-MIT-yellow.svg + :target: https://opensource.org/licenses/MIT + :alt: MIT + +.. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=flat + :target: https://github.com/psf/black + :alt: Black + +.. |pre-commit| image:: https://results.pre-commit.ci/badge/github/ansys/pymechanical/main.svg?style=flat + :target: https://results.pre-commit.ci/latest/github/ansys/pymechanical/main + :alt: pre-commit + +Overview +-------- + +PyMechanical brings Ansys Mechanical to Python. It enables your Python programs to use +Mechanical within Python's ecosystem. It includes the ability to: + +- Connect to a remote Mechanical session +- Embed an instance of Mechanical directly as a Python object + + +Install the package +------------------- + +Install PyMechanical using ``pip`` with:: + + pip install ansys-mechanical-core + +For more information, see `Install the package `_ +in the PyMechanical documentation. + + +Dependencies +------------ + +You must have a licensed copy of `Ansys Mechanical `_ +installed. When using an embedded instance, that installation must be runnable from the +same computer as your Python program. When using a remote session, a connection to that +session must be reachable from your Python program. + +Getting started +--------------- + +.. _scripting_guide: https://ansyshelp.ansys.com/Views/Secured/corp/v251/en/act_script/act_script.html + +PyMechanical uses the built-in scripting capabilities of Mechanical. For information on the +scripting APIs available, see the `Scripting in Mechanical Guide <_scripting_guide>`_ in the +Ansys Help. + +Configuring the mechanical installation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +On a Windows system, the environment variable ``AWP_ROOT`` is configured when Mechanical is +installed, where ```` is the Mechanical release number, such as ``251`` for release 2025 R1. +PyMechanical automatically uses this environment variable (or variables if there are multiple +installations of different versions) to locate the latest Mechanical installation. On a Linux +system, you must configure the ``AWP_ROOT`` environment variable to point to the +absolute path of a Mechanical installation. + +Starting a remote session +^^^^^^^^^^^^^^^^^^^^^^^^^ + +To start a remote session of Mechanical on your computer from Python, use the ``launch_mechanical()`` +method. This methods returns an object representing the connection to the session: + +.. code:: python + + import ansys.mechanical.core as pymechanical + + mechanical = pymechanical.launch_mechanical() + +Running commands on the remote session +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Given a connection to a remote session, you can send an IronPython script. This uses the built-in +scripting capabilities of Mechanical. Here is an example: + +.. code:: python + + result = mechanical.run_python_script("2+3") + result = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + + +Using an embedded instance of Mechanical as a Python object +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PyMechanical also supports directly embedding an instance of Mechanical as a Python object. +In this mode, there is no externally running instance of Mechanical. This feature is supported +on Windows and Linux for version 2023 R2 and later. Here is an example: + +.. code:: python + + import ansys.mechanical.core as pymechanical + + app = pymechanical.App() + app.update_globals(globals()) + project_dir = DataModel.Project.ProjectDirectory + +Documentation and issues +------------------------ + +Documentation for the latest stable release of PyMechanical is hosted at `PyMechanical documentation +`_. + +In the upper right corner of the documentation's title bar, there is an option for switching from +viewing the documentation for the latest stable release to viewing the documentation for the +development version or previously released versions. + +You can also `view `_ or +`download `_ the +PyMechanical cheat sheet. This one-page reference provides syntax rules and commands +for using PyMechanical. + +On the `PyMechanical Issues `_ page, +you can create issues to report bugs and request new features. On the `PyMechanical Discussions +`_ page or the `Discussions `_ +page on the Ansys Developer portal, you can post questions, share ideas, and get community feedback. + +To reach the project support team, email `pyansys.core@ansys.com `_. + +Testing and development +----------------------- + +If you would like to test or contribute to the development of PyMechanical, see +`Contribute `_ in the PyMechanical documentation. \ No newline at end of file diff --git a/doc/changelog.d/1045.maintenance.md b/doc/changelog.d/1045.maintenance.md new file mode 100644 index 000000000..10865a473 --- /dev/null +++ b/doc/changelog.d/1045.maintenance.md @@ -0,0 +1 @@ +Update default product version to 25R1 \ No newline at end of file diff --git a/doc/source/cheatsheet/cheat_sheet.qmd b/doc/source/cheatsheet/cheat_sheet.qmd index a500813c2..e00ded098 100644 --- a/doc/source/cheatsheet/cheat_sheet.qmd +++ b/doc/source/cheatsheet/cheat_sheet.qmd @@ -1,215 +1,215 @@ ---- -title: PyMechanical cheat sheet -format: cheat_sheet-pdf -params: - version: main -footer: PyMechanical -footerlinks: - - urls: 'https://mechanical.docs.pyansys.com/version/stable/' - text: Documentation - - urls: 'https://mechanical.docs.pyansys.com/version/stable/getting_started/index.html' - text: Getting started - - urls: 'https://mechanical.docs.pyansys.com/version/stable/examples/index.html' - text: Examples - - urls: 'https://mechanical.docs.pyansys.com/version/stable/user_guide_scripting/index.html' - text: Mechanical scripting - - urls: 'https://mechanical.docs.pyansys.com/version/stable/api/index.html' - text: API reference - - urls: 'https://mechanical.docs.pyansys.com/version/stable/kil/index.html' - text: Known issues and limitations -execute: - # output: false - eval: false - -latex-clean: true -jupyter: - jupytext: - text_representation: - extension: .qmd - format_name: quarto - format_version: '1.0' - jupytext_version: 1.16.1 - kernelspec: - display_name: Python 3 (ipykernel) - language: python - name: python3 ---- - -# Connect to Mechanical Remote session - -## Launch and connect to session -```{python} -#| eval: false -# Launch an instance -from ansys.mechanical.core import launch_mechanical -mechanical = launch_mechanical() -``` -## Launch Mechanical UI -```{python} -#| eval: false -mechanical = launch_mechanical(batch=False) -``` -## Launch by version -Verify the license and version of Mechanical that is used: -```{python} -#| eval: false -print(mechanical) -``` -Launch a specific version of Mechanical: -```{python} -#| eval: false -from ansys.mechanical.core import find_mechanical -wb_exe = find_mechanical(242)[0] -# 'Ansys Inc\\....\\winx64\\AnsysWBU.exe' -mechanical = launch_mechanical( - exec_file=wb_exe, verbose_mechanical=True, - batch=True - ) -print(mechanical) -``` -## Launch Mechanical using the CLI -```{python} -#| eval: false -ansys-mechanical -r 242 --port 10000 -g -``` -## Manually connect to the Mechanical session -```{python} -#| eval: false -from ansys.mechanical.core import connect_to_mechanical -# Connect locally -mechanical = connect_to_mechanical(port=10000) - # Or -# Connect remotely, to the IP address or hostname -mechanical = connect_to_mechanical( - "192.168.0.1", port=10000 - ) -``` -## Send commands to Mechanical -Run a single command: -```{python} -#| eval: false -result1 = mechanical.run_python_script("2+3") -result2 = mechanical.run_python_script( - "DataModel.Project.ProjectDirectory" -) -mechanical.run_python_script( - "Model.AddStaticStructuralAnalysis()" -) -``` -Evaluate a block of commands: -```{python} -#| eval: false -# Import a material -commands = """ -cu_mat_file_path = ( - r'D:\Workdir\copper.xml'.replace("\\", "\\\\") -) -materials = Model.Materials -materials.Import(cu_mat__file_path) -""" -mechanical.run_python_script(commands) -``` -## Execute a Python script: -```{python} -#| eval: false -mechanical.run_python_script_from_file(file_path) -``` -## Import a Mechanical file and print the count of bodies: -```{python} -#| eval: false -file = r"D:\\Workdir\\bracket.mechdb" -command = f'DataModel.Project.Open("{file}")' -mechanical.run_python_script(command) -mechanical.run_python_script(""" -allbodies = Model.GetChildren( - DataModelObjectCategory.Body, True) -""") -mechanical.run_python_script("allbodies.Count") -``` -## Perform project-specific operations: -```{python} -#| eval: false - -# Get the project directory -mechanical.project_directory -# List the files in the working directory. -mechanical.list_files() -# Save -mechanical.run_python_script( -"ExtAPI.DataModel.Project.Save(r'D:\\Workdir')") -# Log in two ways: -mechanical._log.info("This is a useful message.") -mechanical.log_message("INFO", "info message") -# Exit -mechanical.exit(force=True) -``` - -# Load a Mechanical embedded instance - - -## Start an instance of App -```{python} -#| eval: false -from ansys.mechanical.core import App -app = App(version=242) -print(app) -``` -Extract and merge global API entry points: -```{python} -#| eval: false - -# Extract global API entry points from Mechanical -# Merge them into your Python global variables -app.update_globals(globals()) -``` -Access entry points from Python: -```{python} -#| eval: false - -ExtAPI # Application.ExtAPI -DataModel # Application.DataModel -Model # Application.DataModel.Project.Model -Tree # Application.DataModel.Tree -Graphics # Application.ExtAPI.Graphics -``` -Import a file and print the count of bodies -```{python} -#| eval: false - -file = r"D:\\Workdir\\bracket.mechdb" -app.open(file) -app.update_globals(globals()) -allbodies = Model.GetChildren( - DataModelObjectCategory.Body, True) -print(allbodies.Count) -``` -## Turn on warning logging: -```{python} -#| eval: false - -import logging -from ansys.mechanical.core import App -from ansys.mechanical.core.embedding.logger -import Configuration,Logger -Configuration.configure(level=logging.WARNING, -to_stdout=True) -app = App(version=242) -Logger.error("Test Error Message") -``` -## Visualize geometry in 3D: -```{python} -#| eval: false -# requires Mechanical version >= 24R2 -app.plot() -``` -## Print project structure as tree: -```{python} -#| eval: false -app.print_tree() -# print only 20 lines -app.print_tree(max_lines=20) -``` - -```{python} -#| eval: false +--- +title: PyMechanical cheat sheet +format: cheat_sheet-pdf +params: + version: main +footer: PyMechanical +footerlinks: + - urls: 'https://mechanical.docs.pyansys.com/version/stable/' + text: Documentation + - urls: 'https://mechanical.docs.pyansys.com/version/stable/getting_started/index.html' + text: Getting started + - urls: 'https://mechanical.docs.pyansys.com/version/stable/examples/index.html' + text: Examples + - urls: 'https://mechanical.docs.pyansys.com/version/stable/user_guide_scripting/index.html' + text: Mechanical scripting + - urls: 'https://mechanical.docs.pyansys.com/version/stable/api/index.html' + text: API reference + - urls: 'https://mechanical.docs.pyansys.com/version/stable/kil/index.html' + text: Known issues and limitations +execute: + # output: false + eval: false + +latex-clean: true +jupyter: + jupytext: + text_representation: + extension: .qmd + format_name: quarto + format_version: '1.0' + jupytext_version: 1.16.1 + kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Connect to Mechanical Remote session + +## Launch and connect to session +```{python} +#| eval: false +# Launch an instance +from ansys.mechanical.core import launch_mechanical +mechanical = launch_mechanical() +``` +## Launch Mechanical UI +```{python} +#| eval: false +mechanical = launch_mechanical(batch=False) +``` +## Launch by version +Verify the license and version of Mechanical that is used: +```{python} +#| eval: false +print(mechanical) +``` +Launch a specific version of Mechanical: +```{python} +#| eval: false +from ansys.mechanical.core import find_mechanical +wb_exe = find_mechanical(251)[0] +# 'Ansys Inc\\....\\winx64\\AnsysWBU.exe' +mechanical = launch_mechanical( + exec_file=wb_exe, verbose_mechanical=True, + batch=True + ) +print(mechanical) +``` +## Launch Mechanical using the CLI +```{python} +#| eval: false +ansys-mechanical -r 251 --port 10000 -g +``` +## Manually connect to the Mechanical session +```{python} +#| eval: false +from ansys.mechanical.core import connect_to_mechanical +# Connect locally +mechanical = connect_to_mechanical(port=10000) + # Or +# Connect remotely, to the IP address or hostname +mechanical = connect_to_mechanical( + "192.168.0.1", port=10000 + ) +``` +## Send commands to Mechanical +Run a single command: +```{python} +#| eval: false +result1 = mechanical.run_python_script("2+3") +result2 = mechanical.run_python_script( + "DataModel.Project.ProjectDirectory" +) +mechanical.run_python_script( + "Model.AddStaticStructuralAnalysis()" +) +``` +Evaluate a block of commands: +```{python} +#| eval: false +# Import a material +commands = """ +cu_mat_file_path = ( + r'D:\Workdir\copper.xml'.replace("\\", "\\\\") +) +materials = Model.Materials +materials.Import(cu_mat__file_path) +""" +mechanical.run_python_script(commands) +``` +## Execute a Python script: +```{python} +#| eval: false +mechanical.run_python_script_from_file(file_path) +``` +## Import a Mechanical file and print the count of bodies: +```{python} +#| eval: false +file = r"D:\\Workdir\\bracket.mechdb" +command = f'DataModel.Project.Open("{file}")' +mechanical.run_python_script(command) +mechanical.run_python_script(""" +allbodies = Model.GetChildren( + DataModelObjectCategory.Body, True) +""") +mechanical.run_python_script("allbodies.Count") +``` +## Perform project-specific operations: +```{python} +#| eval: false + +# Get the project directory +mechanical.project_directory +# List the files in the working directory. +mechanical.list_files() +# Save +mechanical.run_python_script( +"ExtAPI.DataModel.Project.Save(r'D:\\Workdir')") +# Log in two ways: +mechanical._log.info("This is a useful message.") +mechanical.log_message("INFO", "info message") +# Exit +mechanical.exit(force=True) +``` + +# Load a Mechanical embedded instance + + +## Start an instance of App +```{python} +#| eval: false +from ansys.mechanical.core import App +app = App(version=251) +print(app) +``` +Extract and merge global API entry points: +```{python} +#| eval: false + +# Extract global API entry points from Mechanical +# Merge them into your Python global variables +app.update_globals(globals()) +``` +Access entry points from Python: +```{python} +#| eval: false + +ExtAPI # Application.ExtAPI +DataModel # Application.DataModel +Model # Application.DataModel.Project.Model +Tree # Application.DataModel.Tree +Graphics # Application.ExtAPI.Graphics +``` +Import a file and print the count of bodies +```{python} +#| eval: false + +file = r"D:\\Workdir\\bracket.mechdb" +app.open(file) +app.update_globals(globals()) +allbodies = Model.GetChildren( + DataModelObjectCategory.Body, True) +print(allbodies.Count) +``` +## Turn on warning logging: +```{python} +#| eval: false + +import logging +from ansys.mechanical.core import App +from ansys.mechanical.core.embedding.logger +import Configuration,Logger +Configuration.configure(level=logging.WARNING, +to_stdout=True) +app = App(version=251) +Logger.error("Test Error Message") +``` +## Visualize geometry in 3D: +```{python} +#| eval: false +# requires Mechanical version >= 24R2 +app.plot() +``` +## Print project structure as tree: +```{python} +#| eval: false +app.print_tree() +# print only 20 lines +app.print_tree(max_lines=20) +``` + +```{python} +#| eval: false ``` \ No newline at end of file diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 8c5313012..5b4587ca8 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -131,16 +131,16 @@ This section provides answers to frequently asked questions. .. code-block:: shell Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 09:35:09 + Product Version:251 + Software build date: 11/27/2024 09:34:44 .. tab-item:: Without License .. code-block:: shell Ansys Mechanical [] - Product Version:242 - Software build date: 06/03/2024 09:35:09 + Product Version:251 + Software build date: 11/27/2024 09:34:44 Alternatively, once the ``app`` is created ``readonly`` method can be used to see if license is active. If license is not checked out then it is in read only mode. @@ -156,7 +156,7 @@ This section provides answers to frequently asked questions. $ mechanical-env python >>> import ansys.mechanical.core as mech - >>> app=mech.App(version=42) + >>> app=mech.App(version=251) or diff --git a/doc/source/getting_started/docker.rst b/doc/source/getting_started/docker.rst index 659106915..2c91ea905 100644 --- a/doc/source/getting_started/docker.rst +++ b/doc/source/getting_started/docker.rst @@ -93,8 +93,8 @@ Verify your connection with this code: >>> mechanical Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 + Product Version:251 + Software build date: 11/27/2024 09:34:44 Additional considerations ------------------------- diff --git a/doc/source/getting_started/installation.rst b/doc/source/getting_started/installation.rst index c437d73aa..9b17131b7 100644 --- a/doc/source/getting_started/installation.rst +++ b/doc/source/getting_started/installation.rst @@ -67,10 +67,10 @@ This package is required to use PyMechanical. or - >>> find_mechanical(version=242) # for specific version + >>> find_mechanical(version=251) # for specific version - ('C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe', 24.2) # windows - ('/usr/ansys_inc/v242/aisol/.workbench', 24.2) # Linux + ('C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe', 25.1) # windows + ('/usr/ansys_inc/v251/aisol/.workbench', 25.1) # Linux If you install Ansys in a directory other than the default or typical location, you can save this directory path using the @@ -82,15 +82,15 @@ and ``version_from_path`` functions to verify the path and version. .. code:: pycon >>> from ansys.tools.path import save_mechanical_path, find_mechanical - >>> save_mechanical_path("home/username/ansys_inc/v242/aisol/.workbench") + >>> save_mechanical_path("home/username/ansys_inc/v251/aisol/.workbench") >>> path = get_mechanical_path() >>> print(path) - /home/username/ansys_inc/v242/aisol/.workbench + /home/username/ansys_inc/v251/aisol/.workbench >>> version = version_from_path("mechanical", path) - 242 + 251 Verify a remote session ^^^^^^^^^^^^^^^^^^^^^^^ @@ -104,8 +104,8 @@ Verify your installation by starting a remote session of Mechanical from Python: >>> mechanical Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 + Product Version:251 + Software build date: 11/27/2024 09:34:44 If you see a response from the server, you can begin using Mechanical as a service. @@ -135,8 +135,8 @@ Inside of Python, use the following commands to load an embedded instance: >>> app = App() >>> print(app) Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 + Product Version:251 + Software build date: 11/27/2024 09:34:44 .. LINKS AND REFERENCES .. _ansys_tools_path_api: https://github.com/psf/black diff --git a/doc/source/getting_started/running_mechanical.rst b/doc/source/getting_started/running_mechanical.rst index ac8c2773e..9abf6ca47 100644 --- a/doc/source/getting_started/running_mechanical.rst +++ b/doc/source/getting_started/running_mechanical.rst @@ -1,283 +1,283 @@ -.. _using_standard_install: - -Launching PyMechanical -====================== - -The ``ansys-mechanical-core`` package requires either a local or -remote instance of Mechanical to communicate with. This page describes -how Mechanical is installed from the Ansys standard installer and -describes how you launch and interface with Mechanical from Python. - -Install Mechanical ------------------- - -Mechanical is installed by default from the Ansys standard installer. -When you run the standard installer, look under the **Structural Mechanics** -heading to verify that the **Mechanical Products** checkbox is selected. -Although options in the standard installer might change, this image provides -a reference: - -.. figure:: ../images/unified_install_2023R1.jpg - :width: 400pt - -Launch a remote Mechanical session ----------------------------------- - -You can use PyMechanical to launch a Mechanical session on the local machine -Python is running on. Alternatively, you can run Mechanical's command line -directly on any machine to start Mechanical in server mode and then use its -IP address to manually connect to it from Python. - -Launch Mechanical on the local machine using Python -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When Mechanical is installed locally on your machine, you can use the -`launch_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.launch_mechanical>`_ -method to launch and automatically connect to Mechanical. While this method provides the -easiest and fastest way to launch Mechanical, it only works with a local Mechanical installation. - -Launch Mechanical locally with this code: - -.. code:: pycon - - >>> from ansys.mechanical.core import launch_mechanical - >>> mechanical = launch_mechanical() - >>> mechanical - - Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 - -Launch Mechanical from the command line -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The `ansys-mechanical` utility is installed automatically with PyMechanical, -and can be used to run Mechanical from the command line. To obtain help on -usage, type the following command: - -.. code:: console - - $ ansys-mechanical --help - - Usage: ansys-mechanical [OPTIONS] - - CLI tool to run mechanical. - - USAGE: - - The following example demonstrates the main use of this tool: - - $ ansys-mechanical -r 242 -g - - Starting Ansys Mechanical version 2024R1 in graphical mode... - - Options: - -h, --help Show this message and exit. - -p, --project-file TEXT Opens Mechanical project file (.mechdb). Cannot - be mixed with -i - --private-appdata Make the appdata folder private. This enables you - to run parallel instances of Mechanical. - --port INTEGER Start mechanical in server mode with the given - port number - -i, --input-script TEXT Name of the input Python script. Cannot be mixed - with -p - --features TEXT Beta feature flags to set, as a semicolon - delimited list. Options: ['MultistageHarmonic', - 'ThermalShells'] - --exit Exit the application after running an input - script. You can only use this command with - --input-script argument (-i). The command - defaults to true you are not running the - application in graphical mode. The ``exit`` - command is only supported in version 2024 R1 or - later. - -s, --show-welcome-screen Show the welcome screen. You use this screen to - open a file. This argument only affects the - application when in graphical mode. - --debug Show a debug dialog window at the start of the - process. - -r, --revision INTEGER Ansys Revision number, e.g. "242" or "241". If - none is specified, uses the default from ansys- - tools-path - -g, --graphical Graphical mode - - ... - -You can launch Mechanical in server mode from the command line and then -manually connect to the server. Use the `port` argument to select the port. - -.. code:: - - ansys-mechanical --port 10000 - -Connect to a Mechanical session -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can connect to a Mechanical session from the same host or from an external host. - -Assuming that Mechanical is running locally at the default IP address (``127.0.0.1``) on the -default port (``10000``), you would use this code to connect to it with this code: - -.. code:: python - - from ansys.mechanical.core import Mechanical - - mechanical = Mechanical() - -Alternatively, you can use the -`connect_to_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.connect_to_mechanical>`_ -for same functionality. - -.. code:: python - - from ansys.mechanical.core import connect_to_mechanical - - mechanical = connect_to_mechanical() - - -Now assume that a remote instance of Mechanical has been started in server mode. To connect to -the computer on your local area network that is running Mechanical, you can use either -an IP address and port or a hostname and port. - -**IP address and port** - -Assume that Mechanical is running remotely at IP address ``192.168.0.1`` on port ``10000``. - -You would connect to it with this code: - -.. code:: python - - mechanical = Mechanical("192.168.0.1", port=10000) - -or - -.. code:: python - - mechanical = connect_to_mechanical("192.168.0.1", port=10000) - -**Hostname and port** - -Assume that Mechanical is running remotely at hostname ``myremotemachine`` on port ``10000``. - -You would connect to it with this code: - -.. code:: python - - mechanical = Mechanical("myremotemachine", port=10000) - -or - -.. code:: python - - mechanical = connect_to_mechanical("myremotemachine", port=10000) - -Launching issues ----------------- - -For any number of reasons, launching Mechanical can fail. Some approaches -follow for debugging launch failures. - -Manually set the location of the executable file -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you have a non-standard installation of Mechanical, PyMechanical might -not be able to find your installation. In this case, you should manually -set the location of your Mechanical executable file as the first parameter -for the `launch_mechanical()`_ method. - -**On Windows** - -.. code:: python - - from ansys.mechanical.core import launch_mechanical - - exec_loc = "C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe" - mechanical = launch_mechanical(exec_loc) - -**On Linux** - -.. code:: python - - from ansys.mechanical.core import launch_mechanical - - exec_loc = "/usr/ansys_inc/v242/aisol/.workbench" - mechanical = launch_mechanical(exec_loc) - -If, when using the `launch_mechanical()`_ -method, Mechanical still fails to launch or hangs while launching, pass the -``verbose_mechanical=True`` parameter. This prints the output of Mechanical in the Python console. -You can then use this output to debug why Mechanical isn't launching. - -.. Note:: - - On Windows, output is limited because of the way Mechanical launches. - -Debug from the command line -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You may need to run the ``launch`` command from the command line to debug why Mechanical is not launching. -running the launch command from the command line. - -Open a terminal and run the following command: - -.. code:: console - - ansys-mechanical -g --port 10000 - -If the preceding command for your operating system doesn't launch Mechanical, you might have -a variety of issues, including: - -- License server setup -- Running behind a VPN -- Missing dependencies - -Embed a Mechanical instance ---------------------------- - -The instructions for embedding a Mechanical instance are different on -Windows and Linux. While the Python code is the same in both cases, -Linux requires some additional environment variables. - -Python code -~~~~~~~~~~~ - -.. code:: pycon - - >>> from ansys.mechanical.core import App - >>> mechanical = App() - >>> mechanical - Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 - -Additional information for Linux -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Starting with 2023 R2, it is possible to embed an instance of Mechanical on Linux. -However, because of differences in how Mechanical works on Linux, you cannot simply -run Python as usual. On Linux, certain environment variables must be set for the Python -process before it starts. You can set up these environment variables using the ``mechanical-env`` -script which is part of PyMechanical - -.. code:: shell - - $ mechanical-env python - -Licensing issues ----------------- - -`PADT `_ has an `Ansys `_ -product section. Posts about licensing are common. - -If you are responsible for maintaining an Ansys license or have a personal installation -of Ansys, you likely can access the -`Licensing `_ -section of the Ansys Help, where you can view or download the *Ansys, Inc. Licensing Guide* for -comprehensive licensing information. - -VPN issues ----------- - -Sometimes, Mechanical has issues starting when VPN software is running. For more information, -access the `Mechanical Users Guide`_ -in the Ansys Help. +.. _using_standard_install: + +Launching PyMechanical +====================== + +The ``ansys-mechanical-core`` package requires either a local or +remote instance of Mechanical to communicate with. This page describes +how Mechanical is installed from the Ansys standard installer and +describes how you launch and interface with Mechanical from Python. + +Install Mechanical +------------------ + +Mechanical is installed by default from the Ansys standard installer. +When you run the standard installer, look under the **Structural Mechanics** +heading to verify that the **Mechanical Products** checkbox is selected. +Although options in the standard installer might change, this image provides +a reference: + +.. figure:: ../images/unified_install_2023R1.jpg + :width: 400pt + +Launch a remote Mechanical session +---------------------------------- + +You can use PyMechanical to launch a Mechanical session on the local machine +Python is running on. Alternatively, you can run Mechanical's command line +directly on any machine to start Mechanical in server mode and then use its +IP address to manually connect to it from Python. + +Launch Mechanical on the local machine using Python +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When Mechanical is installed locally on your machine, you can use the +`launch_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.launch_mechanical>`_ +method to launch and automatically connect to Mechanical. While this method provides the +easiest and fastest way to launch Mechanical, it only works with a local Mechanical installation. + +Launch Mechanical locally with this code: + +.. code:: pycon + + >>> from ansys.mechanical.core import launch_mechanical + >>> mechanical = launch_mechanical() + >>> mechanical + + Ansys Mechanical [Ansys Mechanical Enterprise] + Product Version:251 + Software build date: 11/27/2024 09:34:44 + +Launch Mechanical from the command line +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `ansys-mechanical` utility is installed automatically with PyMechanical, +and can be used to run Mechanical from the command line. To obtain help on +usage, type the following command: + +.. code:: console + + $ ansys-mechanical --help + + Usage: ansys-mechanical [OPTIONS] + + CLI tool to run mechanical. + + USAGE: + + The following example demonstrates the main use of this tool: + + $ ansys-mechanical -r 251 -g + + Starting Ansys Mechanical version 2025R1 in graphical mode... + + Options: + -h, --help Show this message and exit. + -p, --project-file TEXT Opens Mechanical project file (.mechdb). Cannot + be mixed with -i + --private-appdata Make the appdata folder private. This enables you + to run parallel instances of Mechanical. + --port INTEGER Start mechanical in server mode with the given + port number + -i, --input-script TEXT Name of the input Python script. Cannot be mixed + with -p + --features TEXT Beta feature flags to set, as a semicolon + delimited list. Options: ['MultistageHarmonic', + 'ThermalShells'] + --exit Exit the application after running an input + script. You can only use this command with + --input-script argument (-i). The command + defaults to true you are not running the + application in graphical mode. The ``exit`` + command is only supported in version 2024 R1 or + later. + -s, --show-welcome-screen Show the welcome screen. You use this screen to + open a file. This argument only affects the + application when in graphical mode. + --debug Show a debug dialog window at the start of the + process. + -r, --revision INTEGER Ansys Revision number, e.g. "232", "241", "242" or "251". + If none is specified, uses the default from ansys- + tools-path + -g, --graphical Graphical mode + + ... + +You can launch Mechanical in server mode from the command line and then +manually connect to the server. Use the `port` argument to select the port. + +.. code:: + + ansys-mechanical --port 10000 + +Connect to a Mechanical session +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can connect to a Mechanical session from the same host or from an external host. + +Assuming that Mechanical is running locally at the default IP address (``127.0.0.1``) on the +default port (``10000``), you would use this code to connect to it with this code: + +.. code:: python + + from ansys.mechanical.core import Mechanical + + mechanical = Mechanical() + +Alternatively, you can use the +`connect_to_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.connect_to_mechanical>`_ +for same functionality. + +.. code:: python + + from ansys.mechanical.core import connect_to_mechanical + + mechanical = connect_to_mechanical() + + +Now assume that a remote instance of Mechanical has been started in server mode. To connect to +the computer on your local area network that is running Mechanical, you can use either +an IP address and port or a hostname and port. + +**IP address and port** + +Assume that Mechanical is running remotely at IP address ``192.168.0.1`` on port ``10000``. + +You would connect to it with this code: + +.. code:: python + + mechanical = Mechanical("192.168.0.1", port=10000) + +or + +.. code:: python + + mechanical = connect_to_mechanical("192.168.0.1", port=10000) + +**Hostname and port** + +Assume that Mechanical is running remotely at hostname ``myremotemachine`` on port ``10000``. + +You would connect to it with this code: + +.. code:: python + + mechanical = Mechanical("myremotemachine", port=10000) + +or + +.. code:: python + + mechanical = connect_to_mechanical("myremotemachine", port=10000) + +Launching issues +---------------- + +For any number of reasons, launching Mechanical can fail. Some approaches +follow for debugging launch failures. + +Manually set the location of the executable file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you have a non-standard installation of Mechanical, PyMechanical might +not be able to find your installation. In this case, you should manually +set the location of your Mechanical executable file as the first parameter +for the `launch_mechanical()`_ method. + +**On Windows** + +.. code:: python + + from ansys.mechanical.core import launch_mechanical + + exec_loc = "C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe" + mechanical = launch_mechanical(exec_loc) + +**On Linux** + +.. code:: python + + from ansys.mechanical.core import launch_mechanical + + exec_loc = "/usr/ansys_inc/v251/aisol/.workbench" + mechanical = launch_mechanical(exec_loc) + +If, when using the `launch_mechanical()`_ +method, Mechanical still fails to launch or hangs while launching, pass the +``verbose_mechanical=True`` parameter. This prints the output of Mechanical in the Python console. +You can then use this output to debug why Mechanical isn't launching. + +.. Note:: + + On Windows, output is limited because of the way Mechanical launches. + +Debug from the command line +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You may need to run the ``launch`` command from the command line to debug why Mechanical is not launching. +running the launch command from the command line. + +Open a terminal and run the following command: + +.. code:: console + + ansys-mechanical -g --port 10000 + +If the preceding command for your operating system doesn't launch Mechanical, you might have +a variety of issues, including: + +- License server setup +- Running behind a VPN +- Missing dependencies + +Embed a Mechanical instance +--------------------------- + +The instructions for embedding a Mechanical instance are different on +Windows and Linux. While the Python code is the same in both cases, +Linux requires some additional environment variables. + +Python code +~~~~~~~~~~~ + +.. code:: pycon + + >>> from ansys.mechanical.core import App + >>> mechanical = App() + >>> mechanical + Ansys Mechanical [Ansys Mechanical Enterprise] + Product Version:251 + Software build date: 11/27/2024 09:34:44 + +Additional information for Linux +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Starting with 2023 R2, it is possible to embed an instance of Mechanical on Linux. +However, because of differences in how Mechanical works on Linux, you cannot simply +run Python as usual. On Linux, certain environment variables must be set for the Python +process before it starts. You can set up these environment variables using the ``mechanical-env`` +script which is part of PyMechanical + +.. code:: shell + + $ mechanical-env python + +Licensing issues +---------------- + +`PADT `_ has an `Ansys `_ +product section. Posts about licensing are common. + +If you are responsible for maintaining an Ansys license or have a personal installation +of Ansys, you likely can access the +`Licensing `_ +section of the Ansys Help, where you can view or download the *Ansys, Inc. Licensing Guide* for +comprehensive licensing information. + +VPN issues +---------- + +Sometimes, Mechanical has issues starting when VPN software is running. For more information, +access the `Mechanical Users Guide`_ +in the Ansys Help. diff --git a/doc/source/getting_started/wsl.rst b/doc/source/getting_started/wsl.rst index e3ec110af..964f3a4c2 100644 --- a/doc/source/getting_started/wsl.rst +++ b/doc/source/getting_started/wsl.rst @@ -70,7 +70,7 @@ To install Ansys products in WSL, perform these steps: .. code:: bash - tar xvzf STRUCTURES_2022R2_LINX64.tgz + tar xvzf STRUCTURES_2025R1_LINX64.tgz 3. To install Mechanical, go into the folder where the files have been extracted diff --git a/doc/source/index.rst b/doc/source/index.rst index 73478d69e..f425395e2 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -92,7 +92,7 @@ Python API to interact with `Ansys Mechanical`_ (FEA software for structural eng Issues and limitations on both PyMechanical and Mechanical. - :bdg-primary-line:`23R2` :bdg-primary-line:`24R1` :bdg-primary-line:`24R2` + :bdg-primary-line:`23R2` :bdg-primary-line:`24R1` :bdg-primary-line:`24R2` :bdg-primary-line:`25R1` .. grid-item-card:: Contribute :fa:`people-group` :padding: 2 2 2 2 diff --git a/doc/source/links.rst b/doc/source/links.rst index 40bf1f4ba..2da92c612 100644 --- a/doc/source/links.rst +++ b/doc/source/links.rst @@ -1,27 +1,28 @@ -.. # Pyansys -.. _pyansys: https://docs.pyansys.com/ -.. _pyansys_support: pyansys.support@ansys.com - -.. # PyMechanical related -.. _pymechanical_issue: https://github.com/ansys/pymechanical/issues -.. # PyMechanical Embedding Examples related -.. _pymechanical_embedding_ex_doc: https://embedding.examples.mechanical.docs.pyansys.com/ -.. _pymechanical_embedding_ex_repo: https://github.com/ansys/pymechanical-embedding-examples -.. _pymechanical_embedding_ex_basic: https://embedding.examples.mechanical.docs.pyansys.com/examples/index.html - -.. # PyMechanical Remote Sessions Examples related -.. _pymechanical_remote_ex_doc: https://examples.mechanical.docs.pyansys.com/ -.. _pymechanical_remote_ex_repo: https://github.com/ansys/pymechanical-examples -.. _pymechanical_remote_ex_all: https://examples.mechanical.docs.pyansys.com/examples/index.html -.. _Scripting in Mechanical Guide: https://ansyshelp.ansys.com/Views/Secured/corp/%%VERSION%%/en/act_script/act_script.html -.. _Mechanical Users Guide: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/wb_sim/ds_Home.html -.. _Chapter 6: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/installation/unix_silent.html -.. _Chapter 7: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/installation/win_silent.html - -.. # Mechanical related -.. _Mechanical API Documentation: https://scripting.mechanical.docs.pyansys.com/version/dev/api/index.html -.. _Mechanical scripting interface APIs: https://developer.ansys.com/docs/mechanical-scripting-interface/api/index.md -.. _ACT API Reference Guide: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v242/en/act_ref/act_ref.html -.. _Mechanical API known issues and limitations: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/act_script/mech_apis_KIL.html?q=known%20issues -.. _ACT known issues and limitations: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/act_dev/act_dev_knownissues.html +.. # Pyansys +.. _pyansys: https://docs.pyansys.com/ +.. _pyansys_support: pyansys.support@ansys.com + +.. # PyMechanical related +.. _pymechanical_issue: https://github.com/ansys/pymechanical/issues +.. # PyMechanical Embedding Examples related +.. _pymechanical_embedding_ex_doc: https://embedding.examples.mechanical.docs.pyansys.com/ +.. _pymechanical_embedding_ex_repo: https://github.com/ansys/pymechanical-embedding-examples +.. _pymechanical_embedding_ex_basic: https://embedding.examples.mechanical.docs.pyansys.com/examples/index.html + +.. # PyMechanical Remote Sessions Examples related +.. _pymechanical_remote_ex_doc: https://examples.mechanical.docs.pyansys.com/ +.. _pymechanical_remote_ex_repo: https://github.com/ansys/pymechanical-examples +.. _pymechanical_remote_ex_all: https://examples.mechanical.docs.pyansys.com/examples/index.html +.. _Scripting in Mechanical Guide: https://ansyshelp.ansys.com/Views/Secured/corp/%%VERSION%%/en/act_script/act_script.html +.. _Mechanical Users Guide: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/wb_sim/ds_Home.html +.. _Chapter 6: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/installation/unix_silent.html +.. _Chapter 7: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/installation/win_silent.html + +.. # Mechanical related +.. _Mechanical API Documentation: https://scripting.mechanical.docs.pyansys.com/version/dev/api/index.html +.. _Mechanical scripting interface APIs: https://developer.ansys.com/docs/mechanical-scripting-interface/api/index.md +.. _ACT API Reference Guide: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/act_ref/act_ref.html + +.. _Mechanical API known issues and limitations: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/act_script/mech_apis_KIL.html?q=known%20issues +.. _ACT known issues and limitations: https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/%%VERSION%%/en/act_dev/act_dev_knownissues.html .. _Ansys Mechanical: https://www.ansys.com/products/structures/ansys-mechanical \ No newline at end of file diff --git a/doc/source/user_guide_embedding/index.rst b/doc/source/user_guide_embedding/index.rst index 31e57079a..322454950 100644 --- a/doc/source/user_guide_embedding/index.rst +++ b/doc/source/user_guide_embedding/index.rst @@ -1,120 +1,120 @@ -.. _ref_user_guide_embedding: - -Embedded instance -================= - -This section provides an overview of how you use PyMechanical to embed -an instance of Mechanical in Python. - -.. - This toctree must be a top-level index to get it to show up in - pydata_sphinx_theme. - -.. toctree:: - :maxdepth: 1 - :hidden: - - self - configuration - globals - logging - libraries - -Overview --------- - -The `App <../api/ansys/mechanical/core/embedding/app/App.html>`_ class provides -a Mechanical instance: - -.. code:: python - - from ansys.mechanical.core import App - - app = App() - ns = app.DataModel.Project.Model.AddNamedSelection() - -The `App`_ class has access to the global scripting entry points that are -available from built-in Mechanical scripting: - -* ExtAPI: ``Application.ExtAPI`` -* DataModel: ``Application.DataModel`` -* Model: ``Application.DataModel.Project.Model`` -* Tree: ``Application.DataModel.Tree`` -* Graphics: ``Application.ExtAPI.Graphics`` - -Besides scripting entry points, many other types and objects are available from -built-in Mechanical scripting. To learn how to import scripting entry points, -namespaces, and types, see :ref:`ref_embedding_user_guide_globals`. - -Additional configuration ------------------------- - -By default, an instance of the `App`_ class -uses the same Addin configuration as standalone Mechanical. To customize Addins, see -:ref:`ref_embedding_user_guide_addin_configuration`. - -Diagnosing problems with embedding ----------------------------------- - -In some cases, debugging the embedded Mechanical instance may require additional logging. -For information on how to configure logging, see :ref:`ref_embedding_user_guide_logging`. - -Running PyMechanical embedding scripts inside Mechanical with IronPython ------------------------------------------------------------------------- - -If your PyMechanical embedding script does not use any other third-party Python package, such as `NumPy`, -it is possible to adapt it so that it can run inside of Mechanical with IronPython. -The scripting occurs inside Mechanical's command line interface. For instance, consider the following PyMechanical code: - -.. code:: python - - from ansys.mechanical.core import App - - app = App() - app.update_globals(globals()) - ns = DataModel.Project.Model.AddNamedSelection() - ns.Name = "Jarvis" - -The above code can be written as a Python file, such as ``file.py`` with only the following content: - -.. code:: python - - ns = DataModel.Project.Model.AddNamedSelection() - ns.Name = "Jarvis" - -Because the file does not contain the PyMechanical import statements, you can run -``file.py`` using the command line inside Mechanical. - -**Using command line interface (CLI)** - -This can be achieved on both the Windows and Linux platforms using -``ansys-mechanical`` cli from the virtual environment where ``ansys-mechanical-core`` -has been installed. Activate the virtual environment and then use CLI to run the scripts. -If multiple Mechanical versions are installed in the same system, -versions can be specified using ``-r`` flag. Use ``-h`` for more information. - -.. code:: - - ansys-mechanical -i file.py - -.. note:: - - Alternately user can use the following commands in the command prompt of Windows and the terminal - for Linux systems. - - **On Windows** - - .. code:: - - "C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe -DSApplet -AppModeMech -script file.py" - - PowerShell users can run the preceding command without including the opening and - closing quotation marks. - - **On Linux** - - .. code:: - - /usr/ansys_inc/v242/aisol/.workbench -DSApplet -AppModeMech -script file.py - - On either Windows or Linux, add the command line argument ``-b`` to run the script in batch mode. +.. _ref_user_guide_embedding: + +Embedded instance +================= + +This section provides an overview of how you use PyMechanical to embed +an instance of Mechanical in Python. + +.. + This toctree must be a top-level index to get it to show up in + pydata_sphinx_theme. + +.. toctree:: + :maxdepth: 1 + :hidden: + + self + configuration + globals + logging + libraries + +Overview +-------- + +The `App <../api/ansys/mechanical/core/embedding/app/App.html>`_ class provides +a Mechanical instance: + +.. code:: python + + from ansys.mechanical.core import App + + app = App() + ns = app.DataModel.Project.Model.AddNamedSelection() + +The `App`_ class has access to the global scripting entry points that are +available from built-in Mechanical scripting: + +* ExtAPI: ``Application.ExtAPI`` +* DataModel: ``Application.DataModel`` +* Model: ``Application.DataModel.Project.Model`` +* Tree: ``Application.DataModel.Tree`` +* Graphics: ``Application.ExtAPI.Graphics`` + +Besides scripting entry points, many other types and objects are available from +built-in Mechanical scripting. To learn how to import scripting entry points, +namespaces, and types, see :ref:`ref_embedding_user_guide_globals`. + +Additional configuration +------------------------ + +By default, an instance of the `App`_ class +uses the same Addin configuration as standalone Mechanical. To customize Addins, see +:ref:`ref_embedding_user_guide_addin_configuration`. + +Diagnosing problems with embedding +---------------------------------- + +In some cases, debugging the embedded Mechanical instance may require additional logging. +For information on how to configure logging, see :ref:`ref_embedding_user_guide_logging`. + +Running PyMechanical embedding scripts inside Mechanical with IronPython +------------------------------------------------------------------------ + +If your PyMechanical embedding script does not use any other third-party Python package, such as `NumPy`, +it is possible to adapt it so that it can run inside of Mechanical with IronPython. +The scripting occurs inside Mechanical's command line interface. For instance, consider the following PyMechanical code: + +.. code:: python + + from ansys.mechanical.core import App + + app = App() + app.update_globals(globals()) + ns = DataModel.Project.Model.AddNamedSelection() + ns.Name = "Jarvis" + +The above code can be written as a Python file, such as ``file.py`` with only the following content: + +.. code:: python + + ns = DataModel.Project.Model.AddNamedSelection() + ns.Name = "Jarvis" + +Because the file does not contain the PyMechanical import statements, you can run +``file.py`` using the command line inside Mechanical. + +**Using command line interface (CLI)** + +This can be achieved on both the Windows and Linux platforms using +``ansys-mechanical`` cli from the virtual environment where ``ansys-mechanical-core`` +has been installed. Activate the virtual environment and then use CLI to run the scripts. +If multiple Mechanical versions are installed in the same system, +versions can be specified using ``-r`` flag. Use ``-h`` for more information. + +.. code:: + + ansys-mechanical -i file.py + +.. note:: + + Alternately user can use the following commands in the command prompt of Windows and the terminal + for Linux systems. + + **On Windows** + + .. code:: + + "C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe -DSApplet -AppModeMech -script file.py" + + PowerShell users can run the preceding command without including the opening and + closing quotation marks. + + **On Linux** + + .. code:: + + /usr/ansys_inc/v251/aisol/.workbench -DSApplet -AppModeMech -script file.py + + On either Windows or Linux, add the command line argument ``-b`` to run the script in batch mode. diff --git a/doc/source/user_guide_embedding/libraries.rst b/doc/source/user_guide_embedding/libraries.rst index 592216e7c..2abf49853 100644 --- a/doc/source/user_guide_embedding/libraries.rst +++ b/doc/source/user_guide_embedding/libraries.rst @@ -1,36 +1,36 @@ -.. _ref_embedding_user_guide_libraries: - -Libraries -========= - -.. note:: - - This is an experimental feature. Some of these libraries may not work. - -Most of Mechanical's scripting APIs are implemented in C#. However, there are a small number -of Python modules that are distributed with the installation of Mechanical that can be used -from within the Mechanical Scripting Pane. These modules are not available for use from an -embedded instance of Mechanical in Python because Python does not know where to find them. - -But, in order to use these modules, you need to use the experimental function -``add_mechanical_python_libraries`` to help Python locate them and make it possible to import -them. In addition, it is necessary to first initialize the embedded instance of Mechanical -because these libraries may expect the .NET Common Language Runtime to be initialized as well -as for the appropriate C# libraries to be loaded. - -To use the above function, run the following: - -.. code:: python - - - from ansys.mechanical.core import App - from ansys.mechanical.core.embedding import add_mechanical_python_libraries - - app = App(version=242) - - add_mechanical_python_libraries(app) - import materials # This is materials.py that's shipped with Mechanical v242 - -.. warning:: - +.. _ref_embedding_user_guide_libraries: + +Libraries +========= + +.. note:: + + This is an experimental feature. Some of these libraries may not work. + +Most of Mechanical's scripting APIs are implemented in C#. However, there are a small number +of Python modules that are distributed with the installation of Mechanical that can be used +from within the Mechanical Scripting Pane. These modules are not available for use from an +embedded instance of Mechanical in Python because Python does not know where to find them. + +But, in order to use these modules, you need to use the experimental function +``add_mechanical_python_libraries`` to help Python locate them and make it possible to import +them. In addition, it is necessary to first initialize the embedded instance of Mechanical +because these libraries may expect the .NET Common Language Runtime to be initialized as well +as for the appropriate C# libraries to be loaded. + +To use the above function, run the following: + +.. code:: python + + + from ansys.mechanical.core import App + from ansys.mechanical.core.embedding import add_mechanical_python_libraries + + app = App(version=251) + + add_mechanical_python_libraries(app) + import materials # This is materials.py that's shipped with Mechanical v251 + +.. warning:: + Using version as argument to ``add_mechanical_python_libraries()`` is deprecated. \ No newline at end of file diff --git a/doc/source/user_guide_session/index.rst b/doc/source/user_guide_session/index.rst index e09753fa9..1c088fb34 100644 --- a/doc/source/user_guide_session/index.rst +++ b/doc/source/user_guide_session/index.rst @@ -1,85 +1,85 @@ -.. _ref_user_guide_session: - -Remote session -============== - -This section provides an overview of how you use PyMechanical as a client -to a remote Mechanical session. - -.. - This toctreemust be a top level index to get it to show up in - pydata_sphinx_theme - -.. toctree:: - :maxdepth: 1 - :hidden: - - self - server-launcher - mechanical - pool - -Overview --------- - -The `launch_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.launch_mechanical>`_ method -creates an instance of the `Mechanical <../api/ansys/mechanical/core/mechanical/Mechanical.html>`_ -class in the background and sends commands to it as a service. Because errors and warnings -are processed Pythonically, you can develop a script in real time without worrying about -whether the script runs correctly when deployed in batch mode. - -Here is how you use the `launch_mechanical()`_ method to launch Mechanical from Python in gRPC mode: - -.. code:: python - - import os - from ansys.mechanical.core import launch_mechanical - - mechanical = launch_mechanical() - -If multiple versions of product are installed, then you can use ``exec_file`` argument -to select the version of the product to launch. - -.. code:: python - - exec_file_path = "C:/Program Files/ANSYS Inc/v242/aisol/bin/win64/AnsysWBU.exe" - mechanical = launch_mechanical( - exec_file=exec_file_path, batch=False, cleanup_on_exit=False - ) - -If ``batch`` option is set ``True`` the Mechanical launches without GUI. The ``cleanup_on_exit`` -option decides whether product exits at the end of the PyMechanical script or not. - -.. note:: - ``version`` argument is used only if PyPIM is configured. For general cases, use ``exec_file`` - -You can send genuine Python class commands to the application when Mechanical is active. -For example, you can send a Python script: - -.. code:: python - - result = mechanical.run_python_script("2+3") - result = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - -Mechanical interactively returns the result of each command that you send, -saving the result to the logging module. - -Errors are caught immediately. In the following code, an invalid command is sent, -and an error is raised: - -.. code:: pycon - - >>> mechanical.run_python_script("2****3") - grpc.RpcError: - "unexpected token '**'" - -Because the error is caught immediately, you can write your Mechanical scripts in -Python, run them interactively, and then run them in batch without worrying if the -scripts run correctly. This would not be the case if you had instead outputted the -scripts that you wrote to script files. - -The `Mechanical`_ class supports -much more than sending text to Mechanical. It includes higher-level wrapping -that provides for better scripting and interaction with Mechanical. For information -on advanced methods for interacting with Mechanical, see :ref:`ref_examples`. - +.. _ref_user_guide_session: + +Remote session +============== + +This section provides an overview of how you use PyMechanical as a client +to a remote Mechanical session. + +.. + This toctreemust be a top level index to get it to show up in + pydata_sphinx_theme + +.. toctree:: + :maxdepth: 1 + :hidden: + + self + server-launcher + mechanical + pool + +Overview +-------- + +The `launch_mechanical() <../api/ansys/mechanical/core/mechanical/index.html#mechanical.launch_mechanical>`_ method +creates an instance of the `Mechanical <../api/ansys/mechanical/core/mechanical/Mechanical.html>`_ +class in the background and sends commands to it as a service. Because errors and warnings +are processed Pythonically, you can develop a script in real time without worrying about +whether the script runs correctly when deployed in batch mode. + +Here is how you use the `launch_mechanical()`_ method to launch Mechanical from Python in gRPC mode: + +.. code:: python + + import os + from ansys.mechanical.core import launch_mechanical + + mechanical = launch_mechanical() + +If multiple versions of product are installed, then you can use ``exec_file`` argument +to select the version of the product to launch. + +.. code:: python + + exec_file_path = "C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe" + mechanical = launch_mechanical( + exec_file=exec_file_path, batch=False, cleanup_on_exit=False + ) + +If ``batch`` option is set ``True`` the Mechanical launches without GUI. The ``cleanup_on_exit`` +option decides whether product exits at the end of the PyMechanical script or not. + +.. note:: + ``version`` argument is used only if PyPIM is configured. For general cases, use ``exec_file`` + +You can send genuine Python class commands to the application when Mechanical is active. +For example, you can send a Python script: + +.. code:: python + + result = mechanical.run_python_script("2+3") + result = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + +Mechanical interactively returns the result of each command that you send, +saving the result to the logging module. + +Errors are caught immediately. In the following code, an invalid command is sent, +and an error is raised: + +.. code:: pycon + + >>> mechanical.run_python_script("2****3") + grpc.RpcError: + "unexpected token '**'" + +Because the error is caught immediately, you can write your Mechanical scripts in +Python, run them interactively, and then run them in batch without worrying if the +scripts run correctly. This would not be the case if you had instead outputted the +scripts that you wrote to script files. + +The `Mechanical`_ class supports +much more than sending text to Mechanical. It includes higher-level wrapping +that provides for better scripting and interaction with Mechanical. For information +on advanced methods for interacting with Mechanical, see :ref:`ref_examples`. + diff --git a/doc/source/user_guide_session/pool.rst b/doc/source/user_guide_session/pool.rst index 1172ce8fe..2a370a062 100644 --- a/doc/source/user_guide_session/pool.rst +++ b/doc/source/user_guide_session/pool.rst @@ -1,83 +1,83 @@ -Create a pool of Mechanical servers -=================================== - -The `LocalMechanicalPool <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html>`_ -class simplifies creating and connecting to multiple servers of the -`Mechanical <../api/ansys/mechanical/core/mechanical/Mechanical.html>`_ class for batch -processing. You can use this class for batch processing a set of input files or -other batch-related processes. - -This code shows how to create a pool with 10 instances: - -.. code:: pycon - - >>> from ansys.mechanical.core import LocalMechanicalPool - >>> pool = LocalMechanicalPool(10, version="242") - 'Mechanical Pool with 10 active instances' - -When you are creating a pool, you can supply additional keyword arguments. -For example, to restart failed instances, you can set ``restart_failed=True``: - -.. code:: pycon - - >>> import os - >>> my_path = os.getcmd() - >>> pool = LocalMechanicalPool(10, version="242", restart_failed=True) - Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] - -You can access each individual instance of Mechanical with this code: - -.. code:: pycon - - >>> pool[0] - - -Because this is a *self-healing pool*, if an instance of Mechanical stops -during a batch process, this instance is automatically restarted. When creating -the pool, you can disable this behavior by setting ``restart_failed=False``. - -Run a set of input files ------------------------- - -You can use the pool to run a set of pre-generated input files using the -`run_batch() <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html#LocalMechanicalPool.run_batch>`_ method. - -For example, you can run the first set of 20 verification files with this code: - -.. code:: pycon - - >>>>>> from ansys.mechanical.core import examples - >>> files = [f"test{index}.py" for index in range(1, 21)] - >>> outputs = pool.run_batch(files) - >>> len(outputs) - 20 - -Run a user-defined function ---------------------------- - -While the previous example uses the `run_batch()`_ -method to run a set of inputs files, you can also use the -`map() <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html#LocalMechanicalPool.map>`_ method to run a custom user-defined function on -each instance of Mechanical over a set of input files. - -.. code:: pycon - - >>> completed_indices = [] - >>> def func(mechanical, input_file, index): - ... # input_file, index = args - ... mechanical.clear() - ... output = mechanical.run_python_script_from_file(input_file) - ... completed_indices.append(index) - ... return output - ... - >>> inputs = [("test{index}.py", i) for i in range(1, 10)] - >>> output = pool.map(func, inputs, progress_bar=True, wait=True) - ['result1', - 'result2', - 'result3', - 'result4', - 'result5', - 'result6', - 'result7', - 'result8', - 'result9'] +Create a pool of Mechanical servers +=================================== + +The `LocalMechanicalPool <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html>`_ +class simplifies creating and connecting to multiple servers of the +`Mechanical <../api/ansys/mechanical/core/mechanical/Mechanical.html>`_ class for batch +processing. You can use this class for batch processing a set of input files or +other batch-related processes. + +This code shows how to create a pool with 10 instances: + +.. code:: pycon + + >>> from ansys.mechanical.core import LocalMechanicalPool + >>> pool = LocalMechanicalPool(10, version="251") + 'Mechanical Pool with 10 active instances' + +When you are creating a pool, you can supply additional keyword arguments. +For example, to restart failed instances, you can set ``restart_failed=True``: + +.. code:: pycon + + >>> import os + >>> my_path = os.getcmd() + >>> pool = LocalMechanicalPool(10, version="251", restart_failed=True) + Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] + +You can access each individual instance of Mechanical with this code: + +.. code:: pycon + + >>> pool[0] + + +Because this is a *self-healing pool*, if an instance of Mechanical stops +during a batch process, this instance is automatically restarted. When creating +the pool, you can disable this behavior by setting ``restart_failed=False``. + +Run a set of input files +------------------------ + +You can use the pool to run a set of pre-generated input files using the +`run_batch() <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html#LocalMechanicalPool.run_batch>`_ method. + +For example, you can run the first set of 20 verification files with this code: + +.. code:: pycon + + >>>>>> from ansys.mechanical.core import examples + >>> files = [f"test{index}.py" for index in range(1, 21)] + >>> outputs = pool.run_batch(files) + >>> len(outputs) + 20 + +Run a user-defined function +--------------------------- + +While the previous example uses the `run_batch()`_ +method to run a set of inputs files, you can also use the +`map() <../api/ansys/mechanical/core/pool/LocalMechanicalPool.html#LocalMechanicalPool.map>`_ method to run a custom user-defined function on +each instance of Mechanical over a set of input files. + +.. code:: pycon + + >>> completed_indices = [] + >>> def func(mechanical, input_file, index): + ... # input_file, index = args + ... mechanical.clear() + ... output = mechanical.run_python_script_from_file(input_file) + ... completed_indices.append(index) + ... return output + ... + >>> inputs = [("test{index}.py", i) for i in range(1, 10)] + >>> output = pool.map(func, inputs, progress_bar=True, wait=True) + ['result1', + 'result2', + 'result3', + 'result4', + 'result5', + 'result6', + 'result7', + 'result8', + 'result9'] diff --git a/doc/source/user_guide_session/server-launcher.rst b/doc/source/user_guide_session/server-launcher.rst index 24a815762..e92de84fd 100644 --- a/doc/source/user_guide_session/server-launcher.rst +++ b/doc/source/user_guide_session/server-launcher.rst @@ -24,13 +24,13 @@ Mechanical executable file. .. code:: - Enter location of Mechanical executable: /usr/ansys_inc/v242/aisol/.workbench + Enter location of Mechanical executable: /usr/ansys_inc/v251/aisol/.workbench **On Windows** .. code:: - Enter location of Mechanical executable: C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe + Enter location of Mechanical executable: C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe The settings file for Mechanical is stored locally. You do not need to enter the path again. If you must change the path, perhaps to change the default @@ -40,7 +40,7 @@ version of Mechanical, run the following: from ansys.mechanical import core as pymechanical - new_path = "C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe" + new_path = "C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe" pymechanical.change_default_mechanical_path(new_path) For more information, see the `change_default_mechanical_path() <../api/_autosummary/ansys.tools.path.change_default_mechanical_path.html#ansys.tools.path.change_default_mechanical_path>`_ @@ -55,7 +55,7 @@ Mechanical executable file. from ansys.mechanical.core import launch_mechanical - mechanical = launch_mechanical(exec_file="/usr/ansys_inc/v242/aisol/.workbench") + mechanical = launch_mechanical(exec_file="/usr/ansys_inc/v251/aisol/.workbench") **On Windows** @@ -64,7 +64,7 @@ Mechanical executable file. from ansys.mechanical.core import launch_mechanical mechanical = launch_mechanical( - exec_file="C:\\Program File\\ANSYS Inc\\v242\\aisol\\bin\\winx64\\AnsysWBU.exe" + exec_file="C:\\Program File\\ANSYS Inc\\v251\\aisol\\bin\\winx64\\AnsysWBU.exe" ) You can use the ``additional_switches`` keyword argument to specify additional arguments. @@ -73,7 +73,7 @@ You can use the ``additional_switches`` keyword argument to specify additional a from ansys.mechanical.core import launch_mechanical - custom_exec = "/usr/ansys_inc/v242/aisol/.workbench" + custom_exec = "/usr/ansys_inc/v251/aisol/.workbench" add_switch = f"-featureflags mechanical.material.import;" mechanical = launch_mechanical(additional_switches=add_switch) diff --git a/docker/make_container.rst b/docker/make_container.rst index 5b80b04df..22b19e162 100644 --- a/docker/make_container.rst +++ b/docker/make_container.rst @@ -1,139 +1,138 @@ - -Create your own Mechanical Docker container -=========================================== - -.. warning:: You need a valid Ansys license and an Ansys account to - complete the steps in this section. - -You can create your own Mechanical Docker container following -the steps on this page. -These steps use a local Ubuntu machine to generate the needed -files for the Mechanical container by installing Ansys products first -and then copying the generated files to the container. - - -Requirements -============ - -* A Linux machine, preferable with Ubuntu 20.04 or later. - CentOS Linux distribution is no longer supported. - This machine needs to have `Docker `_ installed. - -* A valid Ansys account. Your Ansys reseller should have - provided you with an account. - -* These files are provided: - - * `Dockerfile `_ - - * `.dockerignore `_ - - -Procedure -========= - -Download Mechanical installation files --------------------------------------------- - -Download the latest Mechanical version from the Ansys Customer Portal -(`Current Release `_). -You need to have a valid Ansys account with access to -the products to download. - -If you do not Ansys account information, contact your -IT manager. - - -Install Mechanical product --------------------------------- - -To install Mechanical product on an Ubuntu machine you can follow -`Install Mechanical `_ -if you are using the graphical user interface -or `Install Ansys products in WSL `_ -for the command line interface. The later approach can be reused with small changes in a -continuous integration workflow. - -To reduce the size of the final image, you might want to -install the minimal files by using: - -.. code:: bash - - sh /path-to-mechanical-installer \ - -silent -overwrite_preview -mechapdl -lsdyna \ - -install_dir /path-to-install-mechanical/ - - # example - # sh /home/username/download/linx/INSTALL \ - # -silent -overwrite_preview -mechapdl -lsdyna \ - # -install_dir /install/ansys_inc/ - - -Use ``sudo`` if you do not have write permissions in the installation directory. -The ``-mechapdl`` command installs Mechanical. - -Take note of where you are installing Ansys because the -directory path is need in the following section. - -Build Docker image ------------------- - -To build the Docker image, you must create a directory and copy -all the files you need in the image into this directory. - -The steps to copy these files and build the image are provided in the following script, -which you should modify to adapt it to your needs. - -.. code:: bash - - # Create env vars for the Dockerfile - export ANS_MAJOR_VERSION=25 - - export ANS_MINOR_VERSION=1 - export ANS_VERSION=${ANS_MAJOR_VERSION}${ANS_MINOR_VERSION} - - export TAG=mechanical:${ANS_MAJOR_VERSION}.${ANS_MINOR_VERSION} - # example: if Mechanical v251 is installed under /install/ansys_inc/v251 - - # use /install for path_to_mechanical_installation - export MECHANICAL_INSTALL_LOCATION=/path_to_mechanical_installation/ - - # example: if pymechanical is cloned under /some_location/pymechanical - # use /some_location for path-to-pymechanical - export PYMECHANICAL_LOCATION=/path-to-pymechanical - - # Create working directory - cd ${MECHANICAL_INSTALL_LOCATION} - - # Copy the Docker files - cp ${PYMECHANICAL_LOCATION}/pymechanical/docker/${ANS_VERSION}/Dockerfile . - cp ${PYMECHANICAL_LOCATION}/pymechanical/docker/${ANS_VERSION}/.dockerignore . - - # Build Docker image - sudo docker build -t $TAG --build-arg VERSION=$ANS_VERSION . - -Take note of the these paths: - -* ``path-to-pymechanical`` is the path where PyMechanical repository is located. -* ``path_to_mechanical_installation`` is the path to where you have locally installed Mechanical. - -Not all installation files are copied. In fact, the files ignored during the copying -are described in the `.dockerignore file `_. - -The Docker container configuration needed to build the container is described in the -`Dockerfile `_. - - -Summary -======= - - -* **Step 1:** Download the latest Mechanical version from the Ansys Customer Portal - (`Current Release `_). - -* **Step 2:** Install Mechanical in a known folder. You can reuse your local - installation if it is updated and the machine is running the same Ubuntu - version as the target Ubuntu Docker version. - -* **Step 3:** Build the Docker image with the provided Docker configuration files - and script. + +Create your own Mechanical Docker container +=========================================== + +.. warning:: You need a valid Ansys license and an Ansys account to + complete the steps in this section. + +You can create your own Mechanical Docker container following +the steps on this page. +These steps use a local Ubuntu machine to generate the needed +files for the Mechanical container by installing Ansys products first +and then copying the generated files to the container. + + +Requirements +============ + +* A Linux machine, preferable with Ubuntu 20.04 or later. + CentOS Linux distribution is no longer supported. + This machine needs to have `Docker `_ installed. + +* A valid Ansys account. Your Ansys reseller should have + provided you with an account. + +* These files are provided: + + * `Dockerfile `_ + + * `.dockerignore `_ + + +Procedure +========= + +Download Mechanical installation files +-------------------------------------------- + +Download the latest Mechanical version from the Ansys Customer Portal +(`Current Release `_). +You need to have a valid Ansys account with access to +the products to download. + +If you do not Ansys account information, contact your +IT manager. + + +Install Mechanical product +-------------------------------- + +To install Mechanical product on an Ubuntu machine you can follow +`Install Mechanical `_ +if you are using the graphical user interface +or `Install Ansys products in WSL `_ +for the command line interface. The later approach can be reused with small changes in a +continuous integration workflow. + +To reduce the size of the final image, you might want to +install the minimal files by using: + +.. code:: bash + + sh /path-to-mechanical-installer \ + -silent -overwrite_preview -mechapdl -lsdyna \ + -install_dir /path-to-install-mechanical/ + + # example + # sh /home/username/download/linx/INSTALL \ + # -silent -overwrite_preview -mechapdl -lsdyna \ + # -install_dir /install/ansys_inc/ + + +Use ``sudo`` if you do not have write permissions in the installation directory. +The ``-mechapdl`` command installs Mechanical. + +Take note of where you are installing Ansys because the +directory path is need in the following section. + +Build Docker image +------------------ + +To build the Docker image, you must create a directory and copy +all the files you need in the image into this directory. + +The steps to copy these files and build the image are provided in the following script, +which you should modify to adapt it to your needs. + +.. code:: bash + + # Create env vars for the Dockerfile + export ANS_MAJOR_VERSION=25 + export ANS_MINOR_VERSION=1 + export ANS_VERSION=${ANS_MAJOR_VERSION}${ANS_MINOR_VERSION} + + export TAG=mechanical:${ANS_MAJOR_VERSION}.${ANS_MINOR_VERSION} + # example: if Mechanical v251 is installed under /install/ansys_inc/v251 + + # use /install for path_to_mechanical_installation + export MECHANICAL_INSTALL_LOCATION=/path_to_mechanical_installation/ + + # example: if pymechanical is cloned under /some_location/pymechanical + # use /some_location for path-to-pymechanical + export PYMECHANICAL_LOCATION=/path-to-pymechanical + + # Create working directory + cd ${MECHANICAL_INSTALL_LOCATION} + + # Copy the Docker files + cp ${PYMECHANICAL_LOCATION}/pymechanical/docker/${ANS_VERSION}/Dockerfile . + cp ${PYMECHANICAL_LOCATION}/pymechanical/docker/${ANS_VERSION}/.dockerignore . + + # Build Docker image + sudo docker build -t $TAG --build-arg VERSION=$ANS_VERSION . + +Take note of the these paths: + +* ``path-to-pymechanical`` is the path where PyMechanical repository is located. +* ``path_to_mechanical_installation`` is the path to where you have locally installed Mechanical. + +Not all installation files are copied. In fact, the files ignored during the copying +are described in the `.dockerignore file `_. + +The Docker container configuration needed to build the container is described in the +`Dockerfile `_. + + +Summary +======= + + +* **Step 1:** Download the latest Mechanical version from the Ansys Customer Portal + (`Current Release `_). + +* **Step 2:** Install Mechanical in a known folder. You can reuse your local + installation if it is updated and the machine is running the same Ubuntu + version as the target Ubuntu Docker version. + +* **Step 3:** Build the Docker image with the provided Docker configuration files + and script. diff --git a/src/ansys/mechanical/core/_version.py b/src/ansys/mechanical/core/_version.py index 6d535e240..80335ea11 100644 --- a/src/ansys/mechanical/core/_version.py +++ b/src/ansys/mechanical/core/_version.py @@ -1,47 +1,48 @@ -# 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. - -"""Version of ansys-mechanical-core module. - -On the ``main`` branch, use 'dev0' to denote a development version. -For example: - -# major, minor, patch -version_info = 0, 58, 'dev0' - -""" - -try: - import importlib.metadata as importlib_metadata -except ModuleNotFoundError: # pragma: no cover - import importlib_metadata - -# Read from the pyproject.toml -# major, minor, patch -__version__ = importlib_metadata.version("ansys-mechanical-core") - -SUPPORTED_MECHANICAL_VERSIONS = { - 242: "2024R2", - 241: "2024R1", - 232: "2023R2", -} -"""Supported mechanical versions in descending order.""" +# 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. + +"""Version of ansys-mechanical-core module. + +On the ``main`` branch, use 'dev0' to denote a development version. +For example: + +# major, minor, patch +version_info = 0, 58, 'dev0' + +""" + +try: + import importlib.metadata as importlib_metadata +except ModuleNotFoundError: # pragma: no cover + import importlib_metadata + +# Read from the pyproject.toml +# major, minor, patch +__version__ = importlib_metadata.version("ansys-mechanical-core") + +SUPPORTED_MECHANICAL_VERSIONS = { + 251: "2025R1", + 242: "2024R2", + 241: "2024R1", + 232: "2023R2", +} +"""Supported mechanical versions in descending order.""" diff --git a/src/ansys/mechanical/core/embedding/app.py b/src/ansys/mechanical/core/embedding/app.py index 80e79524b..708b3151a 100644 --- a/src/ansys/mechanical/core/embedding/app.py +++ b/src/ansys/mechanical/core/embedding/app.py @@ -1,611 +1,610 @@ -# 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. - -"""Main application class for embedded Mechanical.""" -from __future__ import annotations - -import atexit -import os -from pathlib import Path -import shutil -import typing -import warnings - -from ansys.mechanical.core.embedding import initializer, runtime -from ansys.mechanical.core.embedding.addins import AddinConfiguration -from ansys.mechanical.core.embedding.appdata import UniqueUserProfile -from ansys.mechanical.core.embedding.imports import global_entry_points, global_variables -from ansys.mechanical.core.embedding.poster import Poster -from ansys.mechanical.core.embedding.ui import launch_ui -from ansys.mechanical.core.embedding.warnings import connect_warnings, disconnect_warnings - -if typing.TYPE_CHECKING: - # Make sure to run ``ansys-mechanical-ideconfig`` to add the autocomplete settings to VS Code - # Run ``ansys-mechanical-ideconfig --help`` for more information - import Ansys # pragma: no cover - -try: - import ansys.tools.visualization_interface # noqa: F401 - - HAS_ANSYS_VIZ = True - """Whether or not PyVista exists.""" -except ImportError: - HAS_ANSYS_VIZ = False - - -def _get_default_addin_configuration() -> AddinConfiguration: - configuration = AddinConfiguration() - return configuration - - -INSTANCES = [] -"""List of instances.""" - - -def _dispose_embedded_app(instances): # pragma: nocover - if len(instances) > 0: - instance = instances[0] - instance._dispose() - - -def _cleanup_private_appdata(profile: UniqueUserProfile): - profile.cleanup() - - -def _start_application(configuration: AddinConfiguration, version, db_file) -> "App": - import clr - - clr.AddReference("Ansys.Mechanical.Embedding") - import Ansys - - if configuration.no_act_addins: - os.environ["ANSYS_MECHANICAL_STANDALONE_NO_ACT_EXTENSIONS"] = "1" - - addin_configuration_name = configuration.addin_configuration - # Starting with version 241 we can pass a configuration name to the constructor - # of Application - if int(version) >= 241: - return Ansys.Mechanical.Embedding.Application(db_file, addin_configuration_name) - else: - return Ansys.Mechanical.Embedding.Application(db_file) - - -class GetterWrapper(object): - """Wrapper class around an attribute of an object.""" - - def __init__(self, obj, getter): - """Create a new instance of GetterWrapper.""" - # immortal class which provides wrapped object - self.__dict__["_immortal_object"] = obj - # function to get the wrapped object from the immortal class - self.__dict__["_get_wrapped_object"] = getter - - def __getattr__(self, attr): - """Wrap getters to the wrapped object.""" - if attr in self.__dict__: - return getattr(self, attr) - return getattr(self._get_wrapped_object(self._immortal_object), attr) - - def __setattr__(self, attr, value): - """Wrap setters to the wrapped object.""" - if attr in self.__dict__: - setattr(self, attr, value) - setattr(self._get_wrapped_object(self._immortal_object), attr, value) - - -class App: - """Mechanical embedding Application. - - Parameters - ---------- - db_file : str, optional - Path to a mechanical database file (.mechdat or .mechdb). - version : int, optional - Version number of the Mechanical application. - private_appdata : bool, optional - Setting for a temporary AppData directory. Default is False. - Enables running parallel instances of Mechanical. - config : AddinConfiguration, optional - Configuration for addins. By default "Mechanical" is used and ACT Addins are disabled. - copy_profile : bool, optional - Whether to copy the user profile when private_appdata is True. Default is True. - - Examples - -------- - Create App with Mechanical project file and version: - - >>> from ansys.mechanical.core import App - >>> app = App(db_file="path/to/file.mechdat", version=241, pri) - - - Disable copying the user profile when private appdata is enabled - - >>> app = App(private_appdata=True, copy_profile=False) - - Create App with "Mechanical" configuration and no ACT Addins - - >>> from ansys.mechanical.core.embedding import AddinConfiguration - >>> from ansys.mechanical.core import App - >>> config = AddinConfiguration("Mechanical") - >>> config.no_act_addins = True - >>> app = App(config=config) - """ - - def __init__(self, db_file=None, private_appdata=False, **kwargs): - """Construct an instance of the mechanical Application.""" - global INSTANCES - from ansys.mechanical.core import BUILDING_GALLERY - - if BUILDING_GALLERY: - if len(INSTANCES) != 0: - instance: App = INSTANCES[0] - instance._share(self) - if db_file is not None: - self.open(db_file) - return - 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) - configuration = kwargs.get("config", _get_default_addin_configuration()) - - if private_appdata: - copy_profile = kwargs.get("copy_profile", True) - new_profile_name = f"PyMechanical-{os.getpid()}" - profile = UniqueUserProfile(new_profile_name, copy_profile=copy_profile) - profile.update_environment(os.environ) - atexit.register(_cleanup_private_appdata, profile) - - self._app = _start_application(configuration, self._version, db_file) - runtime.initialize(self._version) - connect_warnings(self) - self._poster = None - - self._disposed = False - atexit.register(_dispose_embedded_app, INSTANCES) - INSTANCES.append(self) - self._updated_scopes: typing.List[typing.Dict[str, typing.Any]] = [] - self._subscribe() - - def __repr__(self): - """Get the product info.""" - import clr - - clr.AddReference("Ansys.Mechanical.Application") - import Ansys - - return Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString - - def __enter__(self): # pragma: no cover - """Enter the scope.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): # pragma: no cover - """Exit the scope.""" - self._dispose() - - def _dispose(self): - if self._disposed: - return - self._unsubscribe() - disconnect_warnings(self) - self._app.Dispose() - self._disposed = True - - def open(self, db_file, remove_lock=False): - """Open the db file. - - Parameters - ---------- - db_file : str - Path to a Mechanical database file (.mechdat or .mechdb). - remove_lock : bool, optional - Whether or not to remove the lock file if it exists before opening the project file. - """ - if remove_lock: - lock_file = Path(self.DataModel.Project.ProjectDirectory) / ".mech_lock" - # Remove the lock file if it exists before opening the project file - if lock_file.exists(): - warnings.warn( - f"Removing the lock file, {lock_file}, before opening the project. \ -This may corrupt the project file.", - UserWarning, - stacklevel=2, - ) - lock_file.unlink() - - self.DataModel.Project.Open(db_file) - - def save(self, path=None): - """Save the project.""" - if path is not None: - self.DataModel.Project.Save(path) - else: - self.DataModel.Project.Save() - - def save_as(self, path: str, overwrite: bool = False): - """ - Save the project as a new file. - - If the `overwrite` flag is enabled, the current saved file is replaced with the new file. - - Parameters - ---------- - path : str - The path where the file needs to be saved. - overwrite : bool, optional - Whether the file should be overwritten if it already exists (default is False). - - Raises - ------ - Exception - If the file already exists at the specified path and `overwrite` is False. - - Notes - ----- - For version 232, if `overwrite` is True, the existing file and its associated directory - (if any) will be removed before saving the new file. - """ - if not os.path.exists(path): - self.DataModel.Project.SaveAs(path) - return - - if not overwrite: - raise Exception( - f"File already exists in {path}, Use ``overwrite`` flag to " - "replace the existing file." - ) - if self.version < 241: # pragma: no cover - file_name = os.path.basename(path) - file_dir = os.path.dirname(path) - associated_dir = os.path.join(file_dir, os.path.splitext(file_name)[0] + "_Mech_Files") - - # Remove existing files and associated folder - os.remove(path) - if os.path.exists(associated_dir): - shutil.rmtree(associated_dir) - # Save the new file - self.DataModel.Project.SaveAs(path) - else: - self.DataModel.Project.SaveAs(path, overwrite) - - def launch_gui(self, delete_tmp_on_close: bool = True, dry_run: bool = False): - """Launch the GUI.""" - launch_ui(self, delete_tmp_on_close, dry_run) - - def new(self): - """Clear to a new application.""" - self.DataModel.Project.New() - - def close(self): - """Close the active project.""" - # Call New() to remove the lock file of the - # current project on close. - self.DataModel.Project.New() - - def exit(self): - """Exit the application.""" - self._unsubscribe() - if self.version < 241: - self.ExtAPI.Application.Close() - else: - self.ExtAPI.Application.Exit() - - def execute_script(self, script: str) -> typing.Any: - """Execute the given script with the internal IronPython engine.""" - SCRIPT_SCOPE = "pymechanical-internal" - if not hasattr(self, "script_engine"): - import clr - - clr.AddReference("Ansys.Mechanical.Scripting") - import Ansys - - engine_type = Ansys.Mechanical.Scripting.ScriptEngineType.IronPython - script_engine = Ansys.Mechanical.Scripting.EngineFactory.CreateEngine(engine_type) - empty_scope = False - debug_mode = False - script_engine.CreateScope(SCRIPT_SCOPE, empty_scope, debug_mode) - self.script_engine = script_engine - light_mode = True - args = None - rets = None - script_result = self.script_engine.ExecuteCode(script, SCRIPT_SCOPE, light_mode, args, rets) - error_msg = f"Failed to execute the script" - if script_result is None: - raise Exception(error_msg) - if script_result.Error is not None: - error_msg += f": {script_result.Error.Message}" - raise Exception(error_msg) - return script_result.Value - - def execute_script_from_file(self, file_path=None): - """Execute the given script from file with the internal IronPython engine.""" - text_file = open(file_path, "r", encoding="utf-8") - data = text_file.read() - text_file.close() - return self.execute_script(data) - - def plotter(self) -> None: - """Return ``ansys.tools.visualization_interface.Plotter`` object.""" - if not HAS_ANSYS_VIZ: - warnings.warn( - "Installation of viz option required! Use pip install ansys-mechanical-core[viz]" - ) - return - - if self.version < 242: - warnings.warn("Plotting is only supported with version 2024R2 and later!") - return - - # TODO Check if anything loaded inside app or else show warning and return - - from ansys.mechanical.core.embedding.viz.embedding_plotter import to_plotter - - return to_plotter(self) - - def plot(self) -> None: - """Visualize the model in 3d. - - Requires installation using the viz option. E.g. - pip install ansys-mechanical-core[viz] - - Examples - -------- - >>> from ansys.mechanical.core import App - >>> app = App() - >>> app.open("path/to/file.mechdat") - >>> app.plot() - """ - _plotter = self.plotter() - - if _plotter is None: - return - - return _plotter.show() - - @property - def poster(self) -> Poster: - """Returns an instance of Poster.""" - if self._poster is None: - self._poster = Poster() - return self._poster - - @property - def DataModel(self) -> Ansys.Mechanical.DataModel.Interfaces.DataModelObject: - """Return the DataModel.""" - return GetterWrapper(self._app, lambda app: app.DataModel) - - @property - def ExtAPI(self) -> Ansys.ACT.Interfaces.Mechanical.IMechanicalExtAPI: - """Return the ExtAPI object.""" - return GetterWrapper(self._app, lambda app: app.ExtAPI) - - @property - def Tree(self) -> Ansys.ACT.Automation.Mechanical.Tree: - """Return the Tree object.""" - return GetterWrapper(self._app, lambda app: app.DataModel.Tree) - - @property - def Model(self) -> Ansys.ACT.Automation.Mechanical.Model: - """Return the Model object.""" - return GetterWrapper(self._app, lambda app: app.DataModel.Project.Model) - - @property - def Graphics(self) -> Ansys.ACT.Common.Graphics.MechanicalGraphicsWrapper: - """Return the Graphics object.""" - return GetterWrapper(self._app, lambda app: app.ExtAPI.Graphics) - - @property - def readonly(self): - """Return whether the Mechanical object is read-only.""" - import Ansys - - return Ansys.ACT.Mechanical.MechanicalAPI.Instance.ReadOnlyMode - - @property - def version(self): - """Returns the version of the app.""" - return self._version - - @property - def project_directory(self): - """Returns the current project directory.""" - return self.DataModel.Project.ProjectDirectory - - def _share(self, other) -> None: - """Shares the state of self with other. - - Other is another instance of App. - This is used when the BUILDING_GALLERY flag is on. - In that mode, multiple instance of App are used, but - they all point to the same underlying application - object. Because of that, special care needs to be - taken to properly share the state. Other will be - a "weak reference", which doesn't own anything. - """ - # the other app is not expecting to have a project - # already loaded - self.new() - - # set up the type hint (typing.Self is python3.11+) - other: App = other - - # copy `self` state to other. - other._app = self._app - other._version = self._version - other._poster = self._poster - other._updated_scopes = self._updated_scopes - - # all events will be handled by the original App instance - other._subscribed = False - - # finally, set the other disposed flag to be true - # so that the shutdown sequence isn't duplicated - other._disposed = True - - def _subscribe(self): - try: - # This will throw an error when using pythonnet because - # EventSource isn't defined on the IApplication interface - self.ExtAPI.Application.EventSource.OnWorkbenchReady += self._on_workbench_ready - self._subscribed = True - except: - self._subscribed = False - - def _unsubscribe(self): - if not self._subscribed: - return - self._subscribed = False - self.ExtAPI.Application.EventSource.OnWorkbenchReady -= self._on_workbench_ready - - def _on_workbench_ready(self, sender, args) -> None: - self._update_all_globals() - - def update_globals( - self, globals_dict: typing.Dict[str, typing.Any], enums: bool = True - ) -> None: - """Update global variables. - - When scripting inside Mechanical, the Mechanical UI automatically - sets global variables in Python. PyMechanical cannot do that automatically, - but this method can be used. - - By default, all enums will be imported too. To avoid including enums, set - the `enums` argument to False. - - Examples - -------- - >>> from ansys.mechanical.core import App - >>> app = App() - >>> app.update_globals(globals()) - """ - self._updated_scopes.append(globals_dict) - globals_dict.update(global_variables(self, enums)) - - def _update_all_globals(self) -> None: - for scope in self._updated_scopes: - scope.update(global_entry_points(self)) - - def _print_tree(self, node, max_lines, lines_count, indentation): - """Recursively print till provided maximum lines limit. - - Each object in the tree is expected to have the following attributes: - - Name: The name of the object. - - Suppressed : Print as suppressed, if object is suppressed. - - Children: Checks if object have children. - Each child node is expected to have the all these attributes. - - Parameters - ---------- - lines_count: int, optional - The current count of lines printed. Default is 0. - indentation: str, optional - The indentation string used for printing the tree structure. Default is "". - """ - if lines_count >= max_lines and max_lines != -1: - print(f"... truncating after {max_lines} lines") - return lines_count - - if not hasattr(node, "Name"): - raise AttributeError("Object must have a 'Name' attribute") - - node_name = node.Name - if hasattr(node, "Suppressed") and node.Suppressed is True: - node_name += " (Suppressed)" - if hasattr(node, "ObjectState"): - if str(node.ObjectState) == "UnderDefined": - node_name += " (?)" - elif str(node.ObjectState) == "Solved" or str(node.ObjectState) == "FullyDefined": - node_name += " (✓)" - elif str(node.ObjectState) == "NotSolved" or str(node.ObjectState) == "Obsolete": - node_name += " (⚡︎)" - elif str(node.ObjectState) == "SolveFailed": - node_name += " (✕)" - print(f"{indentation}├── {node_name}") - lines_count += 1 - - if lines_count >= max_lines and max_lines != -1: - print(f"... truncating after {max_lines} lines") - return lines_count - - if hasattr(node, "Children") and node.Children is not None and node.Children.Count > 0: - for child in node.Children: - lines_count = self._print_tree(child, max_lines, lines_count, indentation + "| ") - if lines_count >= max_lines and max_lines != -1: - break - - return lines_count - - def print_tree(self, node=None, max_lines=80, lines_count=0, indentation=""): - """ - Print the hierarchical tree representation of the Mechanical project structure. - - Parameters - ---------- - node: DataModel object, optional - The starting object of the tree. - max_lines: int, optional - The maximum number of lines to print. Default is 80. If set to -1, no limit is applied. - - Raises - ------ - AttributeError - If the node does not have the required attributes. - - Examples - -------- - >>> from ansys.mechanical.core import App - >>> app = App() - >>> app.update_globals(globals()) - >>> app.print_tree() - ... ├── Project - ... | ├── Model - ... | | ├── Geometry Imports (⚡︎) - ... | | ├── Geometry (?) - ... | | ├── Materials (✓) - ... | | ├── Coordinate Systems (✓) - ... | | | ├── Global Coordinate System (✓) - ... | | ├── Remote Points (✓) - ... | | ├── Mesh (?) - - >>> app.print_tree(Model, 3) - ... ├── Model - ... | ├── Geometry Imports (⚡︎) - ... | ├── Geometry (?) - ... ... truncating after 3 lines - - >>> app.print_tree(max_lines=2) - ... ├── Project - ... | ├── Model - ... ... truncating after 2 lines - """ - if node is None: - node = self.DataModel.Project - - self._print_tree(node, max_lines, lines_count, indentation) +# 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. + +"""Main application class for embedded Mechanical.""" +from __future__ import annotations + +import atexit +import os +from pathlib import Path +import shutil +import typing +import warnings + +from ansys.mechanical.core.embedding import initializer, runtime +from ansys.mechanical.core.embedding.addins import AddinConfiguration +from ansys.mechanical.core.embedding.appdata import UniqueUserProfile +from ansys.mechanical.core.embedding.imports import global_entry_points, global_variables +from ansys.mechanical.core.embedding.poster import Poster +from ansys.mechanical.core.embedding.ui import launch_ui +from ansys.mechanical.core.embedding.warnings import connect_warnings, disconnect_warnings + +if typing.TYPE_CHECKING: + # Make sure to run ``ansys-mechanical-ideconfig`` to add the autocomplete settings to VS Code + # Run ``ansys-mechanical-ideconfig --help`` for more information + import Ansys # pragma: no cover + +try: + import ansys.tools.visualization_interface # noqa: F401 + + HAS_ANSYS_VIZ = True + """Whether or not PyVista exists.""" +except ImportError: + HAS_ANSYS_VIZ = False + + +def _get_default_addin_configuration() -> AddinConfiguration: + configuration = AddinConfiguration() + return configuration + + +INSTANCES = [] +"""List of instances.""" + + +def _dispose_embedded_app(instances): # pragma: nocover + if len(instances) > 0: + instance = instances[0] + instance._dispose() + + +def _cleanup_private_appdata(profile: UniqueUserProfile): + profile.cleanup() + + +def _start_application(configuration: AddinConfiguration, version, db_file) -> "App": + import clr + + clr.AddReference("Ansys.Mechanical.Embedding") + import Ansys + + if configuration.no_act_addins: + os.environ["ANSYS_MECHANICAL_STANDALONE_NO_ACT_EXTENSIONS"] = "1" + + addin_configuration_name = configuration.addin_configuration + # Starting with version 241 we can pass a configuration name to the constructor + # of Application + if int(version) >= 241: + return Ansys.Mechanical.Embedding.Application(db_file, addin_configuration_name) + else: + return Ansys.Mechanical.Embedding.Application(db_file) + + +class GetterWrapper(object): + """Wrapper class around an attribute of an object.""" + + def __init__(self, obj, getter): + """Create a new instance of GetterWrapper.""" + # immortal class which provides wrapped object + self.__dict__["_immortal_object"] = obj + # function to get the wrapped object from the immortal class + self.__dict__["_get_wrapped_object"] = getter + + def __getattr__(self, attr): + """Wrap getters to the wrapped object.""" + if attr in self.__dict__: + return getattr(self, attr) + return getattr(self._get_wrapped_object(self._immortal_object), attr) + + def __setattr__(self, attr, value): + """Wrap setters to the wrapped object.""" + if attr in self.__dict__: + setattr(self, attr, value) + setattr(self._get_wrapped_object(self._immortal_object), attr, value) + + +class App: + """Mechanical embedding Application. + + Parameters + ---------- + db_file : str, optional + Path to a mechanical database file (.mechdat or .mechdb). + version : int, optional + Version number of the Mechanical application. + private_appdata : bool, optional + Setting for a temporary AppData directory. Default is False. + Enables running parallel instances of Mechanical. + config : AddinConfiguration, optional + Configuration for addins. By default "Mechanical" is used and ACT Addins are disabled. + copy_profile : bool, optional + Whether to copy the user profile when private_appdata is True. Default is True. + + Examples + -------- + Create App with Mechanical project file and version: + + >>> from ansys.mechanical.core import App + >>> app = App(db_file="path/to/file.mechdat", version=251) + + Disable copying the user profile when private appdata is enabled + + >>> app = App(private_appdata=True, copy_profile=False) + + Create App with "Mechanical" configuration and no ACT Addins + + >>> from ansys.mechanical.core.embedding import AddinConfiguration + >>> from ansys.mechanical.core import App + >>> config = AddinConfiguration("Mechanical") + >>> config.no_act_addins = True + >>> app = App(config=config) + """ + + def __init__(self, db_file=None, private_appdata=False, **kwargs): + """Construct an instance of the mechanical Application.""" + global INSTANCES + from ansys.mechanical.core import BUILDING_GALLERY + + if BUILDING_GALLERY: + if len(INSTANCES) != 0: + instance: App = INSTANCES[0] + instance._share(self) + if db_file is not None: + self.open(db_file) + return + 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) + configuration = kwargs.get("config", _get_default_addin_configuration()) + + if private_appdata: + copy_profile = kwargs.get("copy_profile", True) + new_profile_name = f"PyMechanical-{os.getpid()}" + profile = UniqueUserProfile(new_profile_name, copy_profile=copy_profile) + profile.update_environment(os.environ) + atexit.register(_cleanup_private_appdata, profile) + + self._app = _start_application(configuration, self._version, db_file) + runtime.initialize(self._version) + connect_warnings(self) + self._poster = None + + self._disposed = False + atexit.register(_dispose_embedded_app, INSTANCES) + INSTANCES.append(self) + self._updated_scopes: typing.List[typing.Dict[str, typing.Any]] = [] + self._subscribe() + + def __repr__(self): + """Get the product info.""" + import clr + + clr.AddReference("Ansys.Mechanical.Application") + import Ansys + + return Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString + + def __enter__(self): # pragma: no cover + """Enter the scope.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): # pragma: no cover + """Exit the scope.""" + self._dispose() + + def _dispose(self): + if self._disposed: + return + self._unsubscribe() + disconnect_warnings(self) + self._app.Dispose() + self._disposed = True + + def open(self, db_file, remove_lock=False): + """Open the db file. + + Parameters + ---------- + db_file : str + Path to a Mechanical database file (.mechdat or .mechdb). + remove_lock : bool, optional + Whether or not to remove the lock file if it exists before opening the project file. + """ + if remove_lock: + lock_file = Path(self.DataModel.Project.ProjectDirectory) / ".mech_lock" + # Remove the lock file if it exists before opening the project file + if lock_file.exists(): + warnings.warn( + f"Removing the lock file, {lock_file}, before opening the project. \ +This may corrupt the project file.", + UserWarning, + stacklevel=2, + ) + lock_file.unlink() + + self.DataModel.Project.Open(db_file) + + def save(self, path=None): + """Save the project.""" + if path is not None: + self.DataModel.Project.Save(path) + else: + self.DataModel.Project.Save() + + def save_as(self, path: str, overwrite: bool = False): + """ + Save the project as a new file. + + If the `overwrite` flag is enabled, the current saved file is replaced with the new file. + + Parameters + ---------- + path : str + The path where the file needs to be saved. + overwrite : bool, optional + Whether the file should be overwritten if it already exists (default is False). + + Raises + ------ + Exception + If the file already exists at the specified path and `overwrite` is False. + + Notes + ----- + For version 232, if `overwrite` is True, the existing file and its associated directory + (if any) will be removed before saving the new file. + """ + if not os.path.exists(path): + self.DataModel.Project.SaveAs(path) + return + + if not overwrite: + raise Exception( + f"File already exists in {path}, Use ``overwrite`` flag to " + "replace the existing file." + ) + if self.version < 241: # pragma: no cover + file_name = os.path.basename(path) + file_dir = os.path.dirname(path) + associated_dir = os.path.join(file_dir, os.path.splitext(file_name)[0] + "_Mech_Files") + + # Remove existing files and associated folder + os.remove(path) + if os.path.exists(associated_dir): + shutil.rmtree(associated_dir) + # Save the new file + self.DataModel.Project.SaveAs(path) + else: + self.DataModel.Project.SaveAs(path, overwrite) + + def launch_gui(self, delete_tmp_on_close: bool = True, dry_run: bool = False): + """Launch the GUI.""" + launch_ui(self, delete_tmp_on_close, dry_run) + + def new(self): + """Clear to a new application.""" + self.DataModel.Project.New() + + def close(self): + """Close the active project.""" + # Call New() to remove the lock file of the + # current project on close. + self.DataModel.Project.New() + + def exit(self): + """Exit the application.""" + self._unsubscribe() + if self.version < 241: + self.ExtAPI.Application.Close() + else: + self.ExtAPI.Application.Exit() + + def execute_script(self, script: str) -> typing.Any: + """Execute the given script with the internal IronPython engine.""" + SCRIPT_SCOPE = "pymechanical-internal" + if not hasattr(self, "script_engine"): + import clr + + clr.AddReference("Ansys.Mechanical.Scripting") + import Ansys + + engine_type = Ansys.Mechanical.Scripting.ScriptEngineType.IronPython + script_engine = Ansys.Mechanical.Scripting.EngineFactory.CreateEngine(engine_type) + empty_scope = False + debug_mode = False + script_engine.CreateScope(SCRIPT_SCOPE, empty_scope, debug_mode) + self.script_engine = script_engine + light_mode = True + args = None + rets = None + script_result = self.script_engine.ExecuteCode(script, SCRIPT_SCOPE, light_mode, args, rets) + error_msg = f"Failed to execute the script" + if script_result is None: + raise Exception(error_msg) + if script_result.Error is not None: + error_msg += f": {script_result.Error.Message}" + raise Exception(error_msg) + return script_result.Value + + def execute_script_from_file(self, file_path=None): + """Execute the given script from file with the internal IronPython engine.""" + text_file = open(file_path, "r", encoding="utf-8") + data = text_file.read() + text_file.close() + return self.execute_script(data) + + def plotter(self) -> None: + """Return ``ansys.tools.visualization_interface.Plotter`` object.""" + if not HAS_ANSYS_VIZ: + warnings.warn( + "Installation of viz option required! Use pip install ansys-mechanical-core[viz]" + ) + return + + if self.version < 242: + warnings.warn("Plotting is only supported with version 2024R2 and later!") + return + + # TODO Check if anything loaded inside app or else show warning and return + + from ansys.mechanical.core.embedding.viz.embedding_plotter import to_plotter + + return to_plotter(self) + + def plot(self) -> None: + """Visualize the model in 3d. + + Requires installation using the viz option. E.g. + pip install ansys-mechanical-core[viz] + + Examples + -------- + >>> from ansys.mechanical.core import App + >>> app = App() + >>> app.open("path/to/file.mechdat") + >>> app.plot() + """ + _plotter = self.plotter() + + if _plotter is None: + return + + return _plotter.show() + + @property + def poster(self) -> Poster: + """Returns an instance of Poster.""" + if self._poster is None: + self._poster = Poster() + return self._poster + + @property + def DataModel(self) -> Ansys.Mechanical.DataModel.Interfaces.DataModelObject: + """Return the DataModel.""" + return GetterWrapper(self._app, lambda app: app.DataModel) + + @property + def ExtAPI(self) -> Ansys.ACT.Interfaces.Mechanical.IMechanicalExtAPI: + """Return the ExtAPI object.""" + return GetterWrapper(self._app, lambda app: app.ExtAPI) + + @property + def Tree(self) -> Ansys.ACT.Automation.Mechanical.Tree: + """Return the Tree object.""" + return GetterWrapper(self._app, lambda app: app.DataModel.Tree) + + @property + def Model(self) -> Ansys.ACT.Automation.Mechanical.Model: + """Return the Model object.""" + return GetterWrapper(self._app, lambda app: app.DataModel.Project.Model) + + @property + def Graphics(self) -> Ansys.ACT.Common.Graphics.MechanicalGraphicsWrapper: + """Return the Graphics object.""" + return GetterWrapper(self._app, lambda app: app.ExtAPI.Graphics) + + @property + def readonly(self): + """Return whether the Mechanical object is read-only.""" + import Ansys + + return Ansys.ACT.Mechanical.MechanicalAPI.Instance.ReadOnlyMode + + @property + def version(self): + """Returns the version of the app.""" + return self._version + + @property + def project_directory(self): + """Returns the current project directory.""" + return self.DataModel.Project.ProjectDirectory + + def _share(self, other) -> None: + """Shares the state of self with other. + + Other is another instance of App. + This is used when the BUILDING_GALLERY flag is on. + In that mode, multiple instance of App are used, but + they all point to the same underlying application + object. Because of that, special care needs to be + taken to properly share the state. Other will be + a "weak reference", which doesn't own anything. + """ + # the other app is not expecting to have a project + # already loaded + self.new() + + # set up the type hint (typing.Self is python3.11+) + other: App = other + + # copy `self` state to other. + other._app = self._app + other._version = self._version + other._poster = self._poster + other._updated_scopes = self._updated_scopes + + # all events will be handled by the original App instance + other._subscribed = False + + # finally, set the other disposed flag to be true + # so that the shutdown sequence isn't duplicated + other._disposed = True + + def _subscribe(self): + try: + # This will throw an error when using pythonnet because + # EventSource isn't defined on the IApplication interface + self.ExtAPI.Application.EventSource.OnWorkbenchReady += self._on_workbench_ready + self._subscribed = True + except: + self._subscribed = False + + def _unsubscribe(self): + if not self._subscribed: + return + self._subscribed = False + self.ExtAPI.Application.EventSource.OnWorkbenchReady -= self._on_workbench_ready + + def _on_workbench_ready(self, sender, args) -> None: + self._update_all_globals() + + def update_globals( + self, globals_dict: typing.Dict[str, typing.Any], enums: bool = True + ) -> None: + """Update global variables. + + When scripting inside Mechanical, the Mechanical UI automatically + sets global variables in Python. PyMechanical cannot do that automatically, + but this method can be used. + + By default, all enums will be imported too. To avoid including enums, set + the `enums` argument to False. + + Examples + -------- + >>> from ansys.mechanical.core import App + >>> app = App() + >>> app.update_globals(globals()) + """ + self._updated_scopes.append(globals_dict) + globals_dict.update(global_variables(self, enums)) + + def _update_all_globals(self) -> None: + for scope in self._updated_scopes: + scope.update(global_entry_points(self)) + + def _print_tree(self, node, max_lines, lines_count, indentation): + """Recursively print till provided maximum lines limit. + + Each object in the tree is expected to have the following attributes: + - Name: The name of the object. + - Suppressed : Print as suppressed, if object is suppressed. + - Children: Checks if object have children. + Each child node is expected to have the all these attributes. + + Parameters + ---------- + lines_count: int, optional + The current count of lines printed. Default is 0. + indentation: str, optional + The indentation string used for printing the tree structure. Default is "". + """ + if lines_count >= max_lines and max_lines != -1: + print(f"... truncating after {max_lines} lines") + return lines_count + + if not hasattr(node, "Name"): + raise AttributeError("Object must have a 'Name' attribute") + + node_name = node.Name + if hasattr(node, "Suppressed") and node.Suppressed is True: + node_name += " (Suppressed)" + if hasattr(node, "ObjectState"): + if str(node.ObjectState) == "UnderDefined": + node_name += " (?)" + elif str(node.ObjectState) == "Solved" or str(node.ObjectState) == "FullyDefined": + node_name += " (✓)" + elif str(node.ObjectState) == "NotSolved" or str(node.ObjectState) == "Obsolete": + node_name += " (⚡︎)" + elif str(node.ObjectState) == "SolveFailed": + node_name += " (✕)" + print(f"{indentation}├── {node_name}") + lines_count += 1 + + if lines_count >= max_lines and max_lines != -1: + print(f"... truncating after {max_lines} lines") + return lines_count + + if hasattr(node, "Children") and node.Children is not None and node.Children.Count > 0: + for child in node.Children: + lines_count = self._print_tree(child, max_lines, lines_count, indentation + "| ") + if lines_count >= max_lines and max_lines != -1: + break + + return lines_count + + def print_tree(self, node=None, max_lines=80, lines_count=0, indentation=""): + """ + Print the hierarchical tree representation of the Mechanical project structure. + + Parameters + ---------- + node: DataModel object, optional + The starting object of the tree. + max_lines: int, optional + The maximum number of lines to print. Default is 80. If set to -1, no limit is applied. + + Raises + ------ + AttributeError + If the node does not have the required attributes. + + Examples + -------- + >>> from ansys.mechanical.core import App + >>> app = App() + >>> app.update_globals(globals()) + >>> app.print_tree() + ... ├── Project + ... | ├── Model + ... | | ├── Geometry Imports (⚡︎) + ... | | ├── Geometry (?) + ... | | ├── Materials (✓) + ... | | ├── Coordinate Systems (✓) + ... | | | ├── Global Coordinate System (✓) + ... | | ├── Remote Points (✓) + ... | | ├── Mesh (?) + + >>> app.print_tree(Model, 3) + ... ├── Model + ... | ├── Geometry Imports (⚡︎) + ... | ├── Geometry (?) + ... ... truncating after 3 lines + + >>> app.print_tree(max_lines=2) + ... ├── Project + ... | ├── Model + ... ... truncating after 2 lines + """ + if node is None: + node = self.DataModel.Project + + self._print_tree(node, max_lines, lines_count, indentation) diff --git a/src/ansys/mechanical/core/embedding/initializer.py b/src/ansys/mechanical/core/embedding/initializer.py index 3bb3d200d..bf5160dc1 100644 --- a/src/ansys/mechanical/core/embedding/initializer.py +++ b/src/ansys/mechanical/core/embedding/initializer.py @@ -34,7 +34,12 @@ INITIALIZED_VERSION = None """Constant for the initialized version.""" -SUPPORTED_MECHANICAL_EMBEDDING_VERSIONS = {242: "2024R2", 241: "2024R1", 232: "2023R2"} +SUPPORTED_MECHANICAL_EMBEDDING_VERSIONS = { + 251: "2025R1", + 242: "2024R2", + 241: "2024R1", + 232: "2023R2", +} """Supported Mechanical embedding versions on Windows.""" diff --git a/src/ansys/mechanical/core/embedding/logger/__init__.py b/src/ansys/mechanical/core/embedding/logger/__init__.py index 20818e19d..617ea2969 100644 --- a/src/ansys/mechanical/core/embedding/logger/__init__.py +++ b/src/ansys/mechanical/core/embedding/logger/__init__.py @@ -1,219 +1,219 @@ -# 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. - -"""Embedding logger. - -Module to interact with the built-in logging system of Mechanical. - -Usage ------ - -Configuring logger -~~~~~~~~~~~~~~~~~~ - -Configuring the logger can be done using the :class:`Configuration ` class: - -.. code:: python - import ansys.mechanical.core as mech - from ansys.mechanical.core.embedding.logger import Configuration, Logger - - Configuration.configure(level=logging.INFO, to_stdout=True, base_directory=None) - app = mech.App(version=242) - -Then, the :class:`Logger ` class can be used to write messages to the log: - -.. code:: python - - Logger.error("message") - - -""" - -import logging -import os -import typing - -from ansys.mechanical.core.embedding import initializer -from ansys.mechanical.core.embedding.logger import environ, linux_api, sinks, windows_api - -LOGGING_SINKS: typing.Set[int] = set() -"""Constant for logging sinks.""" - -LOGGING_CONTEXT: str = "PYMECHANICAL" -"""Constant for logging context.""" - - -def _get_backend() -> ( - typing.Union[windows_api.APIBackend, linux_api.APIBackend, environ.EnvironBackend] -): - """Get the appropriate logger backend. - - Before embedding is initialized, logging is configured via environment variables. - After embedding is initialized, logging is configured by making API calls into the - Mechanical logging system. - - However, the API is mostly the same in both cases, though some methods only work - in one of the two backends. - - Setting the base directory only works before initializing. - Actually logging a message or flushing the log only works after initializing. - """ - # TODO - use abc instead of a union type? - embedding_initialized = initializer.INITIALIZED_VERSION is not None - if not embedding_initialized: - return environ.EnvironBackend() - if os.name == "nt": - return windows_api.APIBackend() - return linux_api.APIBackend() - - -class Configuration: - """Configures logger for Mechanical embedding.""" - - @classmethod - def configure(cls, level=logging.WARNING, directory=None, base_directory=None, to_stdout=True): - """Configure the logger for PyMechanical embedding. - - Parameters - ---------- - level : int, optional - Level of logging that is defined in the ``logging`` package. The default is 'DEBUG'. - Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. - directory : str, optional - Directory to write log file to. The default is ``None``, but by default the log - will appear somewhere in the system temp folder. - base_directory: str, optional - Base directory to write log files to. Each instance of Mechanical will write its - log to a time-stamped subfolder within this directory. This is only possible to set - before Mechanical is initialized. - to_stdout : bool, optional - Whether to write log messages to the standard output, which is the - command line. The default is ``True``. - """ - # Set up the global log configuration. - cls.set_log_directory(directory) - cls.set_log_base_directory(base_directory) - - # Set up the sink-specific log configuration and store to global state. - cls._store_stdout_sink_enabled(to_stdout) - file_sink_enabled = directory is not None or base_directory is not None - cls._store_file_sink_enabled(file_sink_enabled) - - # Commit the sink-specific log configuration global state to the backend. - cls._commit_enabled_configuration() - cls.set_log_level(level) - - @classmethod - def set_log_to_stdout(cls, value: bool) -> None: - """Configure logging to write to the standard output.""" - cls._store_stdout_sink_enabled(value) - cls._commit_enabled_configuration() - - @classmethod - def set_log_to_file(cls, value: bool) -> None: - """Configure logging to write to a file.""" - cls._store_file_sink_enabled(value) - cls._commit_enabled_configuration() - - @classmethod - def set_log_level(cls, level: int) -> None: - """Set the log level for all configured sinks.""" - if len(LOGGING_SINKS) == 0: - raise Exception("No logging backend configured!") - cls._commit_level_configuration(level) - - @classmethod - def set_log_directory(cls, value: str) -> None: - """Configure logging to write to a directory.""" - if value is None: - return - _get_backend().set_directory(value) - - @classmethod - def set_log_base_directory(cls, directory: str) -> None: - """Configure logging to write in a time-stamped subfolder in this directory.""" - if directory is None: - return - _get_backend().set_base_directory(directory) - - @classmethod - def _commit_level_configuration(cls, level: int) -> None: - for sink in LOGGING_SINKS: - _get_backend().set_log_level(level, sink) - - @classmethod - def _commit_enabled_configuration(cls) -> None: - for sink in LOGGING_SINKS: - _get_backend().enable(sink) - - @classmethod - def _store_stdout_sink_enabled(cls, value: bool) -> None: - if value: - LOGGING_SINKS.add(sinks.StandardSinks.CONSOLE) - else: - LOGGING_SINKS.discard(sinks.StandardSinks.CONSOLE) - - @classmethod - def _store_file_sink_enabled(cls, value: bool) -> None: - if value: - LOGGING_SINKS.add(sinks.StandardSinks.STANDARD_LOG_FILE) - else: - LOGGING_SINKS.discard(sinks.StandardSinks.STANDARD_LOG_FILE) - - -class Logger: - """Provides the ``Logger`` class for embedding.""" - - @classmethod - def flush(cls): - """Flush the log.""" - _get_backend().flush() - - @classmethod - def can_log_message(cls, level: int) -> bool: - """Get whether a message at this level is logged.""" - return _get_backend().can_log_message(level) - - @classmethod - def debug(cls, msg: str): - """Write a debug message to the log.""" - _get_backend().log_message(logging.DEBUG, LOGGING_CONTEXT, msg) - - @classmethod - def error(cls, msg: str): - """Write a error message to the log.""" - _get_backend().log_message(logging.ERROR, LOGGING_CONTEXT, msg) - - @classmethod - def info(cls, msg: str): - """Write an info message to the log.""" - _get_backend().log_message(logging.INFO, LOGGING_CONTEXT, msg) - - @classmethod - def warning(cls, msg: str): - """Write a warning message to the log.""" - _get_backend().log_message(logging.WARNING, LOGGING_CONTEXT, msg) - - @classmethod - def fatal(cls, msg: str): - """Write a fatal message to the log.""" - _get_backend().log_message(logging.FATAL, LOGGING_CONTEXT, msg) +# 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. + +"""Embedding logger. + +Module to interact with the built-in logging system of Mechanical. + +Usage +----- + +Configuring logger +~~~~~~~~~~~~~~~~~~ + +Configuring the logger can be done using the :class:`Configuration ` class: + +.. code:: python + import ansys.mechanical.core as mech + from ansys.mechanical.core.embedding.logger import Configuration, Logger + + Configuration.configure(level=logging.INFO, to_stdout=True, base_directory=None) + app = mech.App(version=251) + +Then, the :class:`Logger ` class can be used to write messages to the log: + +.. code:: python + + Logger.error("message") + + +""" + +import logging +import os +import typing + +from ansys.mechanical.core.embedding import initializer +from ansys.mechanical.core.embedding.logger import environ, linux_api, sinks, windows_api + +LOGGING_SINKS: typing.Set[int] = set() +"""Constant for logging sinks.""" + +LOGGING_CONTEXT: str = "PYMECHANICAL" +"""Constant for logging context.""" + + +def _get_backend() -> ( + typing.Union[windows_api.APIBackend, linux_api.APIBackend, environ.EnvironBackend] +): + """Get the appropriate logger backend. + + Before embedding is initialized, logging is configured via environment variables. + After embedding is initialized, logging is configured by making API calls into the + Mechanical logging system. + + However, the API is mostly the same in both cases, though some methods only work + in one of the two backends. + + Setting the base directory only works before initializing. + Actually logging a message or flushing the log only works after initializing. + """ + # TODO - use abc instead of a union type? + embedding_initialized = initializer.INITIALIZED_VERSION is not None + if not embedding_initialized: + return environ.EnvironBackend() + if os.name == "nt": + return windows_api.APIBackend() + return linux_api.APIBackend() + + +class Configuration: + """Configures logger for Mechanical embedding.""" + + @classmethod + def configure(cls, level=logging.WARNING, directory=None, base_directory=None, to_stdout=True): + """Configure the logger for PyMechanical embedding. + + Parameters + ---------- + level : int, optional + Level of logging that is defined in the ``logging`` package. The default is 'DEBUG'. + Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. + directory : str, optional + Directory to write log file to. The default is ``None``, but by default the log + will appear somewhere in the system temp folder. + base_directory: str, optional + Base directory to write log files to. Each instance of Mechanical will write its + log to a time-stamped subfolder within this directory. This is only possible to set + before Mechanical is initialized. + to_stdout : bool, optional + Whether to write log messages to the standard output, which is the + command line. The default is ``True``. + """ + # Set up the global log configuration. + cls.set_log_directory(directory) + cls.set_log_base_directory(base_directory) + + # Set up the sink-specific log configuration and store to global state. + cls._store_stdout_sink_enabled(to_stdout) + file_sink_enabled = directory is not None or base_directory is not None + cls._store_file_sink_enabled(file_sink_enabled) + + # Commit the sink-specific log configuration global state to the backend. + cls._commit_enabled_configuration() + cls.set_log_level(level) + + @classmethod + def set_log_to_stdout(cls, value: bool) -> None: + """Configure logging to write to the standard output.""" + cls._store_stdout_sink_enabled(value) + cls._commit_enabled_configuration() + + @classmethod + def set_log_to_file(cls, value: bool) -> None: + """Configure logging to write to a file.""" + cls._store_file_sink_enabled(value) + cls._commit_enabled_configuration() + + @classmethod + def set_log_level(cls, level: int) -> None: + """Set the log level for all configured sinks.""" + if len(LOGGING_SINKS) == 0: + raise Exception("No logging backend configured!") + cls._commit_level_configuration(level) + + @classmethod + def set_log_directory(cls, value: str) -> None: + """Configure logging to write to a directory.""" + if value is None: + return + _get_backend().set_directory(value) + + @classmethod + def set_log_base_directory(cls, directory: str) -> None: + """Configure logging to write in a time-stamped subfolder in this directory.""" + if directory is None: + return + _get_backend().set_base_directory(directory) + + @classmethod + def _commit_level_configuration(cls, level: int) -> None: + for sink in LOGGING_SINKS: + _get_backend().set_log_level(level, sink) + + @classmethod + def _commit_enabled_configuration(cls) -> None: + for sink in LOGGING_SINKS: + _get_backend().enable(sink) + + @classmethod + def _store_stdout_sink_enabled(cls, value: bool) -> None: + if value: + LOGGING_SINKS.add(sinks.StandardSinks.CONSOLE) + else: + LOGGING_SINKS.discard(sinks.StandardSinks.CONSOLE) + + @classmethod + def _store_file_sink_enabled(cls, value: bool) -> None: + if value: + LOGGING_SINKS.add(sinks.StandardSinks.STANDARD_LOG_FILE) + else: + LOGGING_SINKS.discard(sinks.StandardSinks.STANDARD_LOG_FILE) + + +class Logger: + """Provides the ``Logger`` class for embedding.""" + + @classmethod + def flush(cls): + """Flush the log.""" + _get_backend().flush() + + @classmethod + def can_log_message(cls, level: int) -> bool: + """Get whether a message at this level is logged.""" + return _get_backend().can_log_message(level) + + @classmethod + def debug(cls, msg: str): + """Write a debug message to the log.""" + _get_backend().log_message(logging.DEBUG, LOGGING_CONTEXT, msg) + + @classmethod + def error(cls, msg: str): + """Write a error message to the log.""" + _get_backend().log_message(logging.ERROR, LOGGING_CONTEXT, msg) + + @classmethod + def info(cls, msg: str): + """Write an info message to the log.""" + _get_backend().log_message(logging.INFO, LOGGING_CONTEXT, msg) + + @classmethod + def warning(cls, msg: str): + """Write a warning message to the log.""" + _get_backend().log_message(logging.WARNING, LOGGING_CONTEXT, msg) + + @classmethod + def fatal(cls, msg: str): + """Write a fatal message to the log.""" + _get_backend().log_message(logging.FATAL, LOGGING_CONTEXT, msg) diff --git a/src/ansys/mechanical/core/embedding/resolver.py b/src/ansys/mechanical/core/embedding/resolver.py index ebf8fe4da..af211e9e0 100644 --- a/src/ansys/mechanical/core/embedding/resolver.py +++ b/src/ansys/mechanical/core/embedding/resolver.py @@ -1,41 +1,41 @@ -# 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. - -"""This is the .NET assembly resolving for embedding Ansys Mechanical. - -Note that for some Mechanical Addons - additional resolving may be -necessary. A resolve handler is shipped with Ansys Mechanical on windows -starting in version 23.1 and on linux starting in version 23.2 -""" - - -def resolve(version): - """Resolve function for all versions of Ansys Mechanical.""" - import clr # isort: skip - import System # isort: skip - - clr.AddReference("Ansys.Mechanical.Embedding") - import Ansys # isort: skip - - assembly_resolver = Ansys.Mechanical.Embedding.AssemblyResolver - resolve_handler = assembly_resolver.MechanicalResolveEventHandler - System.AppDomain.CurrentDomain.AssemblyResolve += resolve_handler +# 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. + +"""This is the .NET assembly resolving for embedding Ansys Mechanical. + +Note that for some Mechanical Addons - additional resolving may be +necessary. A resolve handler is shipped with Ansys Mechanical on Windows +starting in version 23.1 and on Linux starting in version 23.2 +""" + + +def resolve(version): + """Resolve function for all versions of Ansys Mechanical.""" + import clr # isort: skip + import System # isort: skip + + clr.AddReference("Ansys.Mechanical.Embedding") + import Ansys # isort: skip + + assembly_resolver = Ansys.Mechanical.Embedding.AssemblyResolver + resolve_handler = assembly_resolver.MechanicalResolveEventHandler + System.AppDomain.CurrentDomain.AssemblyResolve += resolve_handler diff --git a/src/ansys/mechanical/core/ide_config.py b/src/ansys/mechanical/core/ide_config.py index e7c1252e7..3c7813a11 100644 --- a/src/ansys/mechanical/core/ide_config.py +++ b/src/ansys/mechanical/core/ide_config.py @@ -1,212 +1,212 @@ -# 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. - -"""Convenience CLI to run mechanical.""" - -import json -import os -from pathlib import Path -import re -import site -import sys - -import ansys.tools.path as atp -import click - - -def get_stubs_location(): - """Find the ansys-mechanical-stubs installation location in site-packages. - - Returns - ------- - pathlib.Path - The path to the ansys-mechanical-stubs installation in site-packages. - """ - site_packages = site.getsitepackages() - prefix_path = sys.prefix.replace("\\", "\\\\") - site_packages_regex = re.compile(f"{prefix_path}.*site-packages$") - site_packages_paths = list(filter(site_packages_regex.match, site_packages)) - - if len(site_packages_paths) == 1: - # Get the stubs location - stubs_location = Path(site_packages_paths[0]) / "ansys" / "mechanical" / "stubs" - return stubs_location - - raise Exception("Could not retrieve the location of the ansys-mechanical-stubs package.") - - -def get_stubs_versions(stubs_location: Path): - """Retrieve the revision numbers in ansys-mechanical-stubs. - - Parameters - ---------- - pathlib.Path - The path to the ansys-mechanical-stubs installation in site-packages. - - Returns - ------- - list - The list containing minimum and maximum versions in the ansys-mechanical-stubs package. - """ - # Get revision numbers in stubs folder - revns = [ - int(revision[1:]) - for revision in os.listdir(stubs_location) - if os.path.isdir(os.path.join(stubs_location, revision)) and revision.startswith("v") - ] - return revns - - -def _vscode_impl( - target: str = "user", - revision: int = None, -): - """Get the IDE configuration for autocomplete in VS Code. - - Parameters - ---------- - target: str - The type of settings to update. Either "user" or "workspace" in VS Code. - By default, it's ``user``. - revision: int - The Mechanical revision number. For example, "242". - If unspecified, it finds the default Mechanical version from ansys-tools-path. - """ - # Update the user or workspace settings - if target == "user": - # Get the path to the user's settings.json file depending on the platform - if "win" in sys.platform: - settings_json = ( - Path(os.environ.get("APPDATA")) / "Code" / "User" / "settings.json" - ) # pragma: no cover - elif "lin" in sys.platform: - settings_json = ( - Path(os.environ.get("HOME")) / ".config" / "Code" / "User" / "settings.json" - ) - elif target == "workspace": - # Get the current working directory - current_dir = Path.cwd() - # Get the path to the settings.json file based on the git root & .vscode folder - settings_json = current_dir / ".vscode" / "settings.json" - - # Location where the stubs are installed -> .venv/Lib/site-packages, for example - stubs_location = get_stubs_location() / f"v{revision}" - - # The settings to add to settings.json for autocomplete to work - settings_json_data = { - "python.autoComplete.extraPaths": [str(stubs_location)], - "python.analysis.extraPaths": [str(stubs_location)], - } - # Pretty print dictionary - pretty_dict = json.dumps(settings_json_data, indent=4) - - print(f"Update {settings_json} with the following information:\n") - - if target == "workspace": - print( - "Note: Please ensure the .vscode folder is in the root of your project or repository.\n" - ) - - print(pretty_dict) - - -def _cli_impl( - ide: str = "vscode", - target: str = "user", - revision: int = None, -): - """Provide the user with the path to the settings.json file and IDE settings. - - Parameters - ---------- - ide: str - The IDE to set up autocomplete settings. By default, it's ``vscode``. - target: str - The type of settings to update. Either "user" or "workspace" in VS Code. - By default, it's ``user``. - revision: int - The Mechanical revision number. For example, "242". - If unspecified, it finds the default Mechanical version from ansys-tools-path. - """ - # Get the ansys-mechanical-stubs install location - stubs_location = get_stubs_location() - # Get all revision numbers available in ansys-mechanical-stubs - revns = get_stubs_versions(stubs_location) - # Check the IDE and raise an exception if it's not VS Code - if revision < min(revns) or revision > max(revns): - raise Exception(f"PyMechanical Stubs are not available for {revision}") - elif ide != "vscode": - raise Exception(f"{ide} is not supported at the moment.") - else: - return _vscode_impl(target, revision) - - -@click.command() -@click.help_option("--help", "-h") -@click.option( - "--ide", - default="vscode", - type=str, - help="The IDE being used. By default, it's ``vscode``.", -) -@click.option( - "--target", - default="user", - type=str, - help="The type of settings to update - either ``user`` or ``workspace`` settings in VS Code.", -) -@click.option( - "--revision", - default=None, - type=int, - help='The Mechanical revision number, e.g. "242" or "241". If unspecified,\ -it finds and uses the default version from ansys-tools-path.', -) -def cli(ide: str, target: str, revision: int) -> None: - """CLI tool to update settings.json files for autocomplete with ansys-mechanical-stubs. - - Parameters - ---------- - ide: str - The IDE to set up autocomplete settings. By default, it's ``vscode``. - target: str - The type of settings to update. Either "user" or "workspace" in VS Code. - By default, it's ``user``. - revision: int - The Mechanical revision number. For example, "242". - If unspecified, it finds the default Mechanical version from ansys-tools-path. - - Usage - ----- - The following example demonstrates the main use of this tool: - - $ ansys-mechanical-ideconfig --ide vscode --target user --revision 242 - - """ - exe = atp.get_mechanical_path(allow_input=False, version=revision) - version = atp.version_from_path("mechanical", exe) - - return _cli_impl( - ide, - target, - version, - ) +# 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. + +"""Convenience CLI to run mechanical.""" + +import json +import os +from pathlib import Path +import re +import site +import sys + +import ansys.tools.path as atp +import click + + +def get_stubs_location(): + """Find the ansys-mechanical-stubs installation location in site-packages. + + Returns + ------- + pathlib.Path + The path to the ansys-mechanical-stubs installation in site-packages. + """ + site_packages = site.getsitepackages() + prefix_path = sys.prefix.replace("\\", "\\\\") + site_packages_regex = re.compile(f"{prefix_path}.*site-packages$") + site_packages_paths = list(filter(site_packages_regex.match, site_packages)) + + if len(site_packages_paths) == 1: + # Get the stubs location + stubs_location = Path(site_packages_paths[0]) / "ansys" / "mechanical" / "stubs" + return stubs_location + + raise Exception("Could not retrieve the location of the ansys-mechanical-stubs package.") + + +def get_stubs_versions(stubs_location: Path): + """Retrieve the revision numbers in ansys-mechanical-stubs. + + Parameters + ---------- + pathlib.Path + The path to the ansys-mechanical-stubs installation in site-packages. + + Returns + ------- + list + The list containing minimum and maximum versions in the ansys-mechanical-stubs package. + """ + # Get revision numbers in stubs folder + revns = [ + int(revision[1:]) + for revision in os.listdir(stubs_location) + if os.path.isdir(os.path.join(stubs_location, revision)) and revision.startswith("v") + ] + return revns + + +def _vscode_impl( + target: str = "user", + revision: int = None, +): + """Get the IDE configuration for autocomplete in VS Code. + + Parameters + ---------- + target: str + The type of settings to update. Either "user" or "workspace" in VS Code. + By default, it's ``user``. + revision: int + The Mechanical revision number. For example, "251". + If unspecified, it finds the default Mechanical version from ansys-tools-path. + """ + # Update the user or workspace settings + if target == "user": + # Get the path to the user's settings.json file depending on the platform + if "win" in sys.platform: + settings_json = ( + Path(os.environ.get("APPDATA")) / "Code" / "User" / "settings.json" + ) # pragma: no cover + elif "lin" in sys.platform: + settings_json = ( + Path(os.environ.get("HOME")) / ".config" / "Code" / "User" / "settings.json" + ) + elif target == "workspace": + # Get the current working directory + current_dir = Path.cwd() + # Get the path to the settings.json file based on the git root & .vscode folder + settings_json = current_dir / ".vscode" / "settings.json" + + # Location where the stubs are installed -> .venv/Lib/site-packages, for example + stubs_location = get_stubs_location() / f"v{revision}" + + # The settings to add to settings.json for autocomplete to work + settings_json_data = { + "python.autoComplete.extraPaths": [str(stubs_location)], + "python.analysis.extraPaths": [str(stubs_location)], + } + # Pretty print dictionary + pretty_dict = json.dumps(settings_json_data, indent=4) + + print(f"Update {settings_json} with the following information:\n") + + if target == "workspace": + print( + "Note: Please ensure the .vscode folder is in the root of your project or repository.\n" + ) + + print(pretty_dict) + + +def _cli_impl( + ide: str = "vscode", + target: str = "user", + revision: int = None, +): + """Provide the user with the path to the settings.json file and IDE settings. + + Parameters + ---------- + ide: str + The IDE to set up autocomplete settings. By default, it's ``vscode``. + target: str + The type of settings to update. Either "user" or "workspace" in VS Code. + By default, it's ``user``. + revision: int + The Mechanical revision number. For example, "251". + If unspecified, it finds the default Mechanical version from ansys-tools-path. + """ + # Get the ansys-mechanical-stubs install location + stubs_location = get_stubs_location() + # Get all revision numbers available in ansys-mechanical-stubs + revns = get_stubs_versions(stubs_location) + # Check the IDE and raise an exception if it's not VS Code + if revision < min(revns) or revision > max(revns): + raise Exception(f"PyMechanical Stubs are not available for {revision}") + elif ide != "vscode": + raise Exception(f"{ide} is not supported at the moment.") + else: + return _vscode_impl(target, revision) + + +@click.command() +@click.help_option("--help", "-h") +@click.option( + "--ide", + default="vscode", + type=str, + help="The IDE being used. By default, it's ``vscode``.", +) +@click.option( + "--target", + default="user", + type=str, + help="The type of settings to update - either ``user`` or ``workspace`` settings in VS Code.", +) +@click.option( + "--revision", + default=None, + type=int, + help='The Mechanical revision number, e.g. "251" or "242". If unspecified,\ +it finds and uses the default version from ansys-tools-path.', +) +def cli(ide: str, target: str, revision: int) -> None: + """CLI tool to update settings.json files for autocomplete with ansys-mechanical-stubs. + + Parameters + ---------- + ide: str + The IDE to set up autocomplete settings. By default, it's ``vscode``. + target: str + The type of settings to update. Either "user" or "workspace" in VS Code. + By default, it's ``user``. + revision: int + The Mechanical revision number. For example, "251". + If unspecified, it finds the default Mechanical version from ansys-tools-path. + + Usage + ----- + The following example demonstrates the main use of this tool: + + $ ansys-mechanical-ideconfig --ide vscode --target user --revision 251 + + """ + exe = atp.get_mechanical_path(allow_input=False, version=revision) + version = atp.version_from_path("mechanical", exe) + + return _cli_impl( + ide, + target, + version, + ) diff --git a/src/ansys/mechanical/core/mechanical.py b/src/ansys/mechanical/core/mechanical.py index 58ac5c0f1..6651f5ef8 100644 --- a/src/ansys/mechanical/core/mechanical.py +++ b/src/ansys/mechanical/core/mechanical.py @@ -1,2324 +1,2324 @@ -# 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. - -"""Connect to Mechanical gRPC server and issues commands.""" -import atexit -from contextlib import closing -import datetime -import fnmatch -from functools import wraps -import glob -import os -import pathlib -import socket -import threading -import time -import weakref - -import ansys.api.mechanical.v0.mechanical_pb2 as mechanical_pb2 -import ansys.api.mechanical.v0.mechanical_pb2_grpc as mechanical_pb2_grpc -import ansys.platform.instancemanagement as pypim -from ansys.platform.instancemanagement import Instance -import ansys.tools.path as atp -import grpc - -import ansys.mechanical.core as pymechanical -from ansys.mechanical.core import LOG -from ansys.mechanical.core.errors import ( - MechanicalExitedError, - MechanicalRuntimeError, - VersionError, - protect_grpc, -) -from ansys.mechanical.core.launcher import MechanicalLauncher -from ansys.mechanical.core.misc import ( - check_valid_ip, - check_valid_port, - check_valid_start_instance, - threaded, -) - -# Checking if tqdm is installed. -# If it is, the default value for progress_bar is true. -try: - from tqdm import tqdm - - _HAS_TQDM = True - """Whether or not tqdm is installed.""" -except ModuleNotFoundError: # pragma: no cover - _HAS_TQDM = False - -# Default 256 MB message length -MAX_MESSAGE_LENGTH = int(os.environ.get("PYMECHANICAL_MAX_MESSAGE_LENGTH", 256 * 1024**2)) -"""Default message length.""" - -# Chunk sizes for streaming and file streaming -DEFAULT_CHUNK_SIZE = 256 * 1024 # 256 kB -"""Default chunk size.""" -DEFAULT_FILE_CHUNK_SIZE = 1024 * 1024 # 1MB -"""Default file chunk size.""" - - -def setup_logger(loglevel="INFO", log_file=True, mechanical_instance=None): - """Initialize the logger for the given mechanical instance.""" - # Return existing log if this function has already been called - if hasattr(setup_logger, "log"): - return setup_logger.log - else: - setup_logger.log = LOG.add_instance_logger("Mechanical", mechanical_instance) - - setup_logger.log.setLevel(loglevel) - - if log_file: - if isinstance(log_file, str): - setup_logger.log.log_to_file(filename=log_file, level=loglevel) - - return setup_logger.log - - -def suppress_logging(func): - """Decorate a function to suppress the logging for a Mechanical instance.""" - - @wraps(func) - def wrapper(*args, **kwargs): - mechanical = args[0] - prior_log_level = mechanical.log.level - if prior_log_level != "CRITICAL": - mechanical.set_log_level("CRITICAL") - - out = func(*args, **kwargs) - - if prior_log_level != "CRITICAL": - mechanical.set_log_level(prior_log_level) - - return out - - return wrapper - - -LOCALHOST = "127.0.0.1" -"""Localhost address.""" - -MECHANICAL_DEFAULT_PORT = 10000 -"""Default Mechanical port.""" - -GALLERY_INSTANCE = [None] -"""List of gallery instances.""" - - -def _cleanup_gallery_instance(): # pragma: no cover - """Clean up any leftover instances of Mechanical from building the gallery.""" - if GALLERY_INSTANCE[0] is not None: - mechanical = Mechanical( - ip=GALLERY_INSTANCE[0]["ip"], - port=GALLERY_INSTANCE[0]["port"], - ) - mechanical.exit(force=True) - - -atexit.register(_cleanup_gallery_instance) - - -def port_in_use(port, host=LOCALHOST): - """Check whether a port is in use at the given host. - - You must actually *bind* the address. Just checking if you can create - a socket is insufficient because it is possible to run into permission - errors like:: - - An attempt was made to access a socket in a way forbidden by its - access permissions. - """ - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - if sock.connect_ex((host, port)) == 0: - return True - else: - return False - - -def check_ports(port_range, ip="localhost"): - """Check the state of ports in a port range.""" - ports = {} - for port in port_range: - ports[port] = port_in_use(port, ip) - return ports - - -def close_all_local_instances(port_range=None, use_thread=True): - """Close all Mechanical instances within a port range. - - You can use this method when cleaning up from a failed pool or - batch run. - - Parameters - ---------- - port_range : list, optional - List of a range of ports to use when cleaning up Mechanical. The - default is ``None``, in which case the ports managed by - PyMechanical are used. - - use_thread : bool, optional - Whether to use threads to close the Mechanical instances. - The default is ``True``. So this call will return immediately. - - Examples - -------- - Close all Mechanical instances connected on local ports. - - >>> import ansys.mechanical.core as pymechanical - >>> pymechanical.close_all_local_instances() - - """ - if port_range is None: - port_range = pymechanical.LOCAL_PORTS - - @threaded - def close_mechanical_threaded(port, name="Closing Mechanical instance in a thread"): - close_mechanical(port, name) - - def close_mechanical(port, name="Closing Mechanical instance"): - try: - mechanical = Mechanical(port=port) - LOG.debug(f"{name}: {mechanical.name}.") - mechanical.exit(force=True) - except OSError: # pragma: no cover - pass - - ports = check_ports(port_range) - for port_temp, state in ports.items(): - if state: - if use_thread: - close_mechanical_threaded(port_temp) - else: - close_mechanical(port_temp) - - -def create_ip_file(ip, path): - """Create the ``mylocal.ip`` file needed to change the IP address of the gRPC server.""" - file_name = os.path.join(path, "mylocal.ip") - with open(file_name, "w", encoding="utf-8") as f: - f.write(ip) - - -def get_mechanical_path(allow_input=True): - """Get path. - - Deprecated - use `ansys.tools.path.get_mechanical_path` instead - """ - return atp.get_mechanical_path(allow_input) - - -def check_valid_mechanical(): - """Change to see if the default Mechanical path is valid. - - Example (windows) - ----------------- - - >>> from ansys.mechanical.core import mechanical - >>> from ansys.tools.path import change_default_mechanical_path - >>> mechanical_path = 'C:/Program Files/ANSYS Inc/v242/aisol/bin/win64/AnsysWBU.exe' - >>> change_default_mechanical_path(mechanical_pth) - >>> mechanical.check_valid_mechanical() - True - - - """ - mechanical_path = atp.get_mechanical_path(False) - if mechanical_path is None: - return False - mechanical_version = atp.version_from_path("mechanical", mechanical_path) - return not (mechanical_version < 232 and os.name != "posix") - - -def change_default_mechanical_path(exe_loc): - """Change default path. - - Deprecated - use `ansys.tools.path.change_default_mechanical_path` instead. - """ - return atp.change_default_mechanical_path(exe_loc) - - -def save_mechanical_path(exe_loc=None): # pragma: no cover - """Save path. - - Deprecated - use `ansys.tools.path.save_mechanical_path` instead. - """ - return atp.save_mechanical_path(exe_loc) - - -client_to_server_loglevel = { - "DEBUG": 1, - "INFO": 2, - "WARN": 3, - "WARNING": 3, - "ERROR": 4, - "CRITICAL": 5, -} - - -class Mechanical(object): - """Connects to a gRPC Mechanical server and allows commands to be passed.""" - - # Required by `_name` method to be defined before __init__ be - _ip = None - _port = None - - def __init__( - self, - ip=None, - port=None, - timeout=60.0, - loglevel="WARNING", - log_file=False, - log_mechanical=None, - cleanup_on_exit=False, - channel=None, - remote_instance=None, - keep_connection_alive=True, - **kwargs, - ): - """Initialize the member variable based on the arguments. - - Parameters - ---------- - ip : str, optional - IP address to connect to the server. The default is ``None`` - in which case ``localhost`` is used. - port : int, optional - Port to connect to the Mecahnical server. The default is ``None``, - in which case ``10000`` is used. - timeout : float, optional - Maximum allowable time for connecting to the Mechanical server. - The default is ``60.0``. - loglevel : str, optional - Level of messages to print to the console. The default is ``WARNING``. - - - ``ERROR`` prints only error messages. - - ``WARNING`` prints warning and error messages. - - ``INFO`` prints info, warning and error messages. - - ``DEBUG`` prints debug, info, warning and error messages. - - log_file : bool, optional - Whether to copy the messages to a file named ``logs.log``, which is - located where the Python script is executed. The default is ``False``. - log_mechanical : str, optional - Path to the output file on the local disk for writing every script - command to. The default is ``None``. However, you might set - ``"log_mechanical='pymechanical_log.txt"`` to write all commands that are - sent to Mechanical via PyMechanical in this file so that you can use them - to run a script within Mechanical without PyMechanical. - cleanup_on_exit : bool, optional - Whether to exit Mechanical when Python exits. The default is ``False``, - in which case Mechanical is not exited when the garbage for this Mechanical - instance is collected. - channel : grpc.Channel, optional - gRPC channel to use for the connection. The default is ``None``. - You can use this parameter as an alternative to the ``ip`` and ``port`` - parameters. - remote_instance : ansys.platform.instancemanagement.Instance - Corresponding remote instance when Mechanical is launched - through PyPIM. The default is ``None``. If a remote instance - is specified, this instance is deleted when the - :func:`mecahnical.exit ` - function is called. - keep_connection_alive : bool, optional - Whether to keep the gRPC connection alive by running a background thread - and making dummy calls for remote connections. The default is ``True``. - - Examples - -------- - Connect to a Mechanical instance already running on locally on the - default port (``10000``). - - >>> from ansys.mechanical import core as pymechanical - >>> mechanical = pymechanical.Mechanical() - - Connect to a Mechanical instance running on the LAN on a default port. - - >>> mechanical = pymechanical.Mechanical('192.168.1.101') - - Connect to a Mechanical instance running on the LAN on a non-default port. - - >>> mechanical = pymechanical.Mechanical('192.168.1.101', port=60001) - - If you want to customize the channel, you can connect directly to gRPC channels. - For example, if you want to create an insecure channel with a maximum message - length of 8 MB, you would run: - - >>> import grpc - >>> channel_temp = grpc.insecure_channel( - ... '127.0.0.1:10000', - ... options=[ - ... ("grpc.max_receive_message_length", 8*1024**2), - ... ], - ... ) - >>> mechanical = pymechanical.Mechanical(channel=channel_temp) - """ - self._remote_instance = remote_instance - self._channel = channel - self._keep_connection_alive = keep_connection_alive - - self._locked = False # being used within MechanicalPool - - # ip could be a machine name. Convert it to an IP address. - ip_temp = ip - if channel is not None: - if ip is not None or port is not None: - raise ValueError( - "If `channel` is specified, neither `port` nor `ip` can be specified." - ) - elif ip is None: - ip_temp = "127.0.0.1" - else: - ip_temp = socket.gethostbyname(ip) # Converting ip or host name to ip - - self._ip = ip_temp - self._port = port - - self._start_parm = kwargs - - self._cleanup_on_exit = cleanup_on_exit - self._busy = False # used to check if running a command on the server - - self._local = ip_temp in ["127.0.0.1", "127.0.1.1", "localhost"] - if "local" in kwargs: # pragma: no cover # allow this to be overridden - self._local = kwargs["local"] - - self._health_response_queue = None - self._exiting = False - self._exited = None - - self._version = None - - if port is None: - port = MECHANICAL_DEFAULT_PORT - self._port = port - - self._stub = None - self._timeout = timeout - - if channel is None: - self._channel = self._create_channel(ip_temp, port) - else: - self._channel = channel - - self._logLevel = loglevel - self._log_file = log_file - self._log_mechanical = log_mechanical - - self._log = LOG.add_instance_logger(self.name, self, level=loglevel) # instance logger - # adding a file handler to the logger - if log_file: - if not isinstance(log_file, str): - log_file = "instance.log" - self._log.log_to_file(filename=log_file, level=loglevel) - - self._log_file_mechanical = log_mechanical - if log_mechanical: - if not isinstance(log_mechanical, str): - self._log_file_mechanical = "pymechanical_log.txt" - else: - self._log_file_mechanical = log_mechanical - - # temporarily disable logging - # useful when we run some dummy calls - self._disable_logging = False - - if self._local: - self.log_info("Mechanical connection is treated as local.") - else: - self.log_info("Mechanical connection is treated as remote.") - - # connect and validate to the channel - self._multi_connect(timeout=timeout) - - self.log_info("Mechanical is ready to accept grpc calls.") - - def __del__(self): # pragma: no cover - """Clean up on exit.""" - if self._cleanup_on_exit: - try: - self.exit(force=True) - except grpc.RpcError as e: - self.log_error(f"exit: {e}") - - # def _set_log_level(self, level): - # """Set an alias for the log level.""" - # self.set_log_level(level) - - @property - def log(self): - """Log associated with the current Mechanical instance.""" - return self._log - - @property - def version(self) -> str: - """Get the Mechanical version based on the instance. - - Examples - -------- - Get the version of the connected Mechanical instance. - - >>> mechanical.version - '242' - """ - if self._version is None: - try: - self._disable_logging = True - script = ( - 'clr.AddReference("Ans.Utilities")\n' - "import Ansys\n" - "config = Ansys.Utilities.ApplicationConfiguration.DefaultConfiguration\n" - "config.VersionInfo.VersionString" - ) - self._version = self.run_python_script(script) - except grpc.RpcError: # pragma: no cover - raise - finally: - self._disable_logging = False - return self._version - - @property - def name(self): - """Name (unique identifier) of the Mechanical instance.""" - try: - if self._channel is not None: - if self._remote_instance is not None: # pragma: no cover - return f"GRPC_{self._channel._channel._channel.target().decode()}" - else: - return f"GRPC_{self._channel._channel.target().decode()}" - except Exception as e: # pragma: no cover - LOG.error(f"Error getting the Mechanical instance name: {str(e)}") - - return f"GRPC_instance_{id(self)}" # pragma: no cover - - @property - def busy(self): - """Return True when the Mechanical gRPC server is executing a command.""" - return self._busy - - @property - def locked(self): - """Instance is in use within a pool.""" - return self._locked - - @locked.setter - def locked(self, new_value): - """Instance is in use within a pool.""" - self._locked = new_value - - def _multi_connect(self, n_attempts=5, timeout=60): - """Try to connect over a series of attempts to the channel. - - Parameters - ---------- - n_attempts : int, optional - Number of connection attempts. The default is ``5``. - timeout : float, optional - Maximum allowable time in seconds for establishing a connection. - The default is ``60``. - """ - # This prevents a single failed connection from blocking other attempts - connected = False - attempt_timeout = timeout / n_attempts - self.log_debug( - f"timetout:{timeout} n_attempts:{n_attempts} attempt_timeout={attempt_timeout}" - ) - - max_time = time.time() + timeout - i = 1 - while time.time() < max_time and i <= n_attempts: - self.log_debug(f"Connection attempt {i} with attempt timeout {attempt_timeout}s") - connected = self._connect(timeout=attempt_timeout) - - if connected: - self.log_debug(f"Connection attempt {i} succeeded.") - break - - i += 1 - else: # pragma: no cover - self.log_debug( - f"Reached either maximum amount of connection attempts " - f"({n_attempts}) or timeout ({timeout} s)." - ) - - if not connected: # pragma: no cover - raise IOError(f"Unable to connect to Mechanical instance at {self._channel_str}.") - - @property - def _channel_str(self): - """Target string, generally in the form of ``ip:port``, such as ``127.0.0.1:10000``.""" - if self._channel is not None: - if self._remote_instance is not None: - return self._channel._channel._channel.target().decode() # pragma: no cover - else: - return self._channel._channel.target().decode() - return "" # pragma: no cover - - def _connect(self, timeout=12, enable_health_check=False): - """Connect a gRPC channel to a remote or local Mechanical instance. - - Parameters - ---------- - timeout : float - Maximum allowable time in seconds for establishing a connection. The - default is ``12``. - enable_health_check : bool, optional - Whether to enable a check to see if the connection is healthy. - The default is ``False``. - """ - self._state = grpc.channel_ready_future(self._channel) - self._stub = mechanical_pb2_grpc.MechanicalServiceStub(self._channel) - - # verify connection - time_start = time.time() - while ((time.time() - time_start) < timeout) and not self._state._matured: - time.sleep(0.01) - - if not self._state._matured: # pragma: no cover - return False - - self.log_debug("Established a connection to the Mechanical gRPC server.") - - self.wait_till_mechanical_is_ready(timeout) - - # keeps Mechanical session alive - self._timer = None - if not self._local and self._keep_connection_alive: # pragma: no cover - self._initialised = threading.Event() - self._t_trigger = time.time() - self._t_delay = 30 - self._timer = threading.Thread( - target=Mechanical._threaded_heartbeat, args=(weakref.proxy(self),) - ) - self._timer.daemon = True - self._timer.start() - - # enable health check - if enable_health_check: # pragma: no cover - self._enable_health_check() - - self.__server_version = None - - return True - - def _enable_health_check(self): # pragma: no cover - """Place the status of the health check in the health response queue.""" - # lazy imports here to speed up module load - from grpc_health.v1 import health_pb2, health_pb2_grpc - - def _consume_responses(response_iterator, response_queue): - try: - for response in response_iterator: - response_queue.put(response) - # NOTE: We're doing absolutely nothing with this as - # this point since the server-side health check - # doesn't change state. - except Exception: - if self._exiting: - return - self._exited = True - raise MechanicalExitedError( - "Lost connection with the Mechanical gRPC server." - ) from None - - # enable health check - from queue import Queue - - request = health_pb2.HealthCheckRequest() - self._health_stub = health_pb2_grpc.HealthStub(self._channel) - rendezvous = self._health_stub.Watch(request) - - # health check feature implemented after 2023 R1 - try: - status = rendezvous.next() - except Exception as err: - if err.code().name != "UNIMPLEMENTED": - raise err - return - - if status.status != health_pb2.HealthCheckResponse.SERVING: - raise MechanicalRuntimeError( - "Cannot enable health check and/or connect to the Mechanical server." - ) - - self._health_response_queue = Queue() - - # allow main process to exit by setting daemon to true - thread = threading.Thread( - target=_consume_responses, - args=(rendezvous, self._health_response_queue), - daemon=True, - ) - thread.start() - - def _threaded_heartbeat(self): # pragma: no cover - """To call from a thread to verify that a Mechanical instance is alive.""" - self._initialised.set() - while True: - if self._exited: - break - try: - time.sleep(self._t_delay) - if not self.is_alive: - break - except ReferenceError: - break - # except Exception: - # continue - - def _create_channel(self, ip, port): - """Create an unsecured gRPC channel.""" - check_valid_ip(ip) - - # open the channel - channel_str = f"{ip}:{port}" - LOG.debug(f"Opening insecure channel at {channel_str}.") - return grpc.insecure_channel( - channel_str, - options=[ - ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH), - ], - ) - - @property - def is_alive(self) -> bool: - """Whether there is an active connection to the Mechanical gRPC server.""" - if self._exited: - return False - - if self._busy: # pragma: no cover - return True - - try: # pragma: no cover - self._make_dummy_call() - return True - except grpc.RpcError: - return False - - @staticmethod - def set_log_level(loglevel): - """Set the log level. - - Parameters - ---------- - loglevel : str, int - Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"`` - and ``"ERROR"``. - - Examples - -------- - Set the log level to the ``"DEBUG"`` level. - - # >>> mechanical.set_log_level('DEBUG') - # - # Set the log level to info - # - # >>> mechanical.set_log_level('INFO') - # - # Set the log level to warning - # - # >>> mechanical.set_log_level('WARNING') - # - # Set the log level to error - # - # >>> mechanical.set_log_level('ERROR') - """ - if isinstance(loglevel, str): - loglevel = loglevel.upper() - setup_logger(loglevel=loglevel) - - def get_product_info(self): - """Get product information by running a script on the Mechanical gRPC server.""" - - def _get_jscript_product_info_command(): - return ( - 'ExtAPI.Application.ScriptByName("jscript").ExecuteCommand' - '("var productInfo = DS.Script.getProductInfo();returnFromScript(productInfo);")' - ) - - def _get_python_product_info_command(): - return ( - 'clr.AddReference("Ansys.Mechanical.Application")\n' - "Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString" - ) - - try: - self._disable_logging = True - if int(self.version) >= 232: - script = _get_python_product_info_command() - else: - script = _get_jscript_product_info_command() - return self.run_python_script(script) - except grpc.RpcError: - raise - finally: - self._disable_logging = False - - @suppress_logging - def __repr__(self): - """Get the user-readable string form of the Mechanical instance.""" - try: - if self._exited: - return "Mechanical exited." - return self.get_product_info() - except grpc.RpcError: - return "Error getting product info." - - def launch(self, cleanup_on_exit=True): - """Launch Mechanical in batch or UI mode. - - Parameters - ---------- - cleanup_on_exit : bool, optional - Whether to exit Mechanical when Python exits. The default is ``True``. - When ``False``, Mechanical is not exited when the garbage for this - Mechanical instance is collected. - """ - if not self._local: - raise RuntimeError("Can only launch with a local instance of Mechanical.") - - # let us respect the current cleanup behavior - if self._cleanup_on_exit: - self.exit() - - exec_file = self._start_parm.get("exec_file", get_mechanical_path(allow_input=False)) - batch = self._start_parm.get("batch", True) - additional_switches = self._start_parm.get("additional_switches", None) - additional_envs = self._start_parm.get("additional_envs", None) - port = launch_grpc( - exec_file=exec_file, - batch=batch, - additional_switches=additional_switches, - additional_envs=additional_envs, - verbose=True, - ) - # update the new cleanup behavior - self._cleanup_on_exit = cleanup_on_exit - self._port = port - self._channel = self._create_channel(self._ip, port) - self._connect(port) - - self.log_info("Mechanical is ready to accept gRPC calls.") - - def wait_till_mechanical_is_ready(self, wait_time=-1): - """Wait until Mechanical is ready. - - Parameters - ---------- - wait_time : float, optional - Maximum allowable time in seconds for connecting to the Mechanical gRPC server. - """ - time_1 = datetime.datetime.now() - - sleep_time = 0.5 - if wait_time == -1: # pragma: no cover - self.log_info("Waiting for Mechanical to be ready...") - else: - self.log_info(f"Waiting for Mechanical to be ready. Maximum wait time: {wait_time}s") - - while not self.__isMechanicalReady(): - time_2 = datetime.datetime.now() - time_interval = time_2 - time_1 - time_interval_seconds = int(time_interval.total_seconds()) - - self.log_debug( - f"Mechanical is not ready. You've been waiting for {time_interval_seconds}." - ) - if self._timeout != -1: - if time_interval_seconds > wait_time: - self.log_debug( - f"Allowed wait time {wait_time}s. " - f"Waited so for {time_interval_seconds}s, " - f"before throwing the error." - ) - raise RuntimeError( - f"Couldn't connect to Mechanical. " f"Waited for {time_interval_seconds}s." - ) - - time.sleep(sleep_time) - - time_2 = datetime.datetime.now() - time_interval = time_2 - time_1 - time_interval_seconds = int(time_interval.total_seconds()) - - self.log_info(f"Mechanical is ready. It took {time_interval_seconds} seconds to verify.") - - def __isMechanicalReady(self): - """Whether the Mechanical gRPC server is ready. - - Returns - ------- - bool - ``True`` if Mechanical is ready, ``False`` otherwise. - """ - try: - script = "ExtAPI.DataModel.Project.ProductVersion" - self.run_python_script(script) - except grpc.RpcError as error: - self.log_debug(f"Mechanical is not ready. Error:{error}.") - return False - - return True - - @staticmethod - def convert_to_server_log_level(log_level): - """Convert the log level to the server log level. - - Parameters - ---------- - log_level : str - Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, - ``"ERROR"``, and ``"CRITICAL"``. - - Returns - ------- - Converted log level for the server. - """ - value = client_to_server_loglevel.get(log_level) - - if value is not None: - return value - - raise ValueError( - f"Log level {log_level} is invalid. Possible values are " - f"'DEBUG','INFO', 'WARNING', 'ERROR', and 'CRITICAL'." - ) - - def run_python_script( - self, script_block: str, enable_logging=False, log_level="WARNING", progress_interval=2000 - ): - """Run a Python script block inside Mechanical. - - It returns the string value of the last executed statement. If the value cannot be - returned as a string, it will return an empty string. - - Parameters - ---------- - script_block : str - Script block (one or more lines) to run. - enable_logging: bool, optional - Whether to enable logging. The default is ``False``. - log_level: str - Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``, - ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. - progress_interval: int, optional - Frequency in milliseconds for getting log messages from the server. - The default is ``2000``. - - Returns - ------- - str - Script result. - - Examples - -------- - Return a value from a simple calculation. - - >>> mechanical.run_python_script('2+3') - '5' - - Return a string value from Project object. - - >>> mechanical.run_python_script('ExtAPI.DataModel.Project.ProductVersion') - '2024 R2' - - Return an empty string, when you try to return the Project object. - - >>> mechanical.run_python_script('ExtAPI.DataModel.Project') - '' - - Return an empty string for assignments. - - >>> mechanical.run_python_script('version = ExtAPI.DataModel.Project.ProductVersion') - '' - - Return value from the last executed statement from a variable. - - >>> script=''' - addition = 2 + 3 - multiplication = 3 * 4 - multiplication - ''' - >>> mechanical.run_python_script(script) - '12' - - Return value from last executed statement from a function call. - - >>> script=''' - import math - math.pow(2,3) - ''' - >>> mechanical.run_python_script(script) - '8' - - Handle an error scenario. - - >>> script = 'hello_world()' - >>> import grpc - >>> try: - mechanical.run_python_script(script) - except grpc.RpcError as error: - print(error.details()) - name 'hello_world' is not defined - - """ - self.verify_valid_connection() - result_as_string = self.__call_run_python_script( - script_block, enable_logging, log_level, progress_interval - ) - return result_as_string - - def run_python_script_from_file( - self, file_path, enable_logging=False, log_level="WARNING", progress_interval=2000 - ): - """Run the contents a python file inside Mechanical. - - It returns the string value of the last executed statement. If the value cannot be - returned as a string, it will return an empty string. - - Parameters - ---------- - file_path : - Path for the Python file. - enable_logging: bool, optional - Whether to enable logging. The default is ``False``. - log_level: str - Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``, - ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. - progress_interval: int, optional - Frequency in milliseconds for getting log messages from the server. - The default is ``2000``. - - Returns - ------- - str - Script result. - - Examples - -------- - Return a value from a simple calculation. - - Contents of **simple.py** file - - 2+3 - - >>> mechanical.run_python_script_from_file('simple.py') - '5' - - Return a value from a simple function call. - - Contents of **test.py** file - - import math - - math.pow(2,3) - - >>> mechanical.run_python_script_from_file('test.py') - '8' - - """ - self.verify_valid_connection() - self.log_debug(f"run_python_script_from_file started") - script_code = Mechanical.__readfile(file_path) - self.log_debug(f"run_python_script_from_file started") - return self.run_python_script(script_code, enable_logging, log_level, progress_interval) - - def exit(self, force=False): - """Exit Mechanical. - - Parameters - ---------- - force : bool, optional - Whether to force Mechanical to exit. The default is ``False``, in which case - only Mechanical in UI mode asks for confirmation. This parameter overrides - any environment variables that may inhibit exiting Mechanical. - - Examples - -------- - Exit Mechanical. - - >>> mechanical.Exit(force=True) - - """ - if not force: - if not get_start_instance(): - self.log_info("Ignoring exit due to PYMECHANICAL_START_INSTANCE=False") - return - - # or building the gallery - if pymechanical.BUILDING_GALLERY: - self._log.info("Ignoring exit due to BUILDING_GALLERY=True") - return - - if self._exited: - return - - self.verify_valid_connection() - - self._exiting = True - - self.log_debug("In shutdown.") - request = mechanical_pb2.ShutdownRequest(force_exit=force) - self.log_debug("Shutting down...") - - self._busy = True - try: - self._stub.Shutdown(request) - except grpc._channel._InactiveRpcError as error: - self.log_warning("Mechanical exit failed: {str(error}.") - finally: - self._busy = False - - self._exited = True - self._stub = None - - if self._remote_instance is not None: # pragma: no cover - self.log_debug("PyPIM delete has started.") - try: - self._remote_instance.delete() - except Exception as error: - self.log_warning("Remote instance delete failed: {str(error}.") - self.log_debug("PyPIM delete has finished.") - - self._remote_instance = None - self._channel = None - else: - self.log_debug("No PyPIM cleanup is needed.") - - local_ports = pymechanical.LOCAL_PORTS - if self._local and self._port in local_ports: - local_ports.remove(self._port) - - self.log_info("Shutdown has finished.") - - @protect_grpc - def upload( - self, - file_name, - file_location_destination=None, - chunk_size=DEFAULT_FILE_CHUNK_SIZE, - progress_bar=True, - ): - """Upload a file to the Mechanical instance. - - Parameters - ---------- - file_name : str - Local file to upload. Only the file name is needed if the file - is relative to the current working directory. Otherwise, the full path - is needed. - file_location_destination : str, optional - File location on the Mechanical server to upload the file to. The default is - ``None``, in which case the project directory is used. - chunk_size : int, optional - Chunk size in bytes. The default is ``1048576``. - progress_bar : bool, optional - Whether to show a progress bar using ``tqdm``. The default is ``True``. - A progress bar is helpful for viewing upload progress. - - Returns - ------- - str - Base name of the uploaded file. - - Examples - -------- - Upload the ``hsec.x_t`` file with the progress bar not shown. - - >>> mechanical.upload('hsec.x_t', progress_bar=False) - """ - self.verify_valid_connection() - - if not os.path.isfile(file_name): - raise FileNotFoundError(f"Unable to locate filename {file_name}.") - - self._log.debug(f"Uploading file '{file_name}' to the Mechanical instance.") - - if file_location_destination is None: - file_location_destination = self.project_directory - - self._busy = True - try: - chunks_generator = self.get_file_chunks( - file_location_destination, - file_name, - chunk_size=chunk_size, - progress_bar=progress_bar, - ) - response = self._stub.UploadFile(chunks_generator) - self.log_debug(f"upload_file response is {response.is_ok}.") - finally: - self._busy = False - - if not response.is_ok: # pragma: no cover - raise IOError("File failed to upload.") - return os.path.basename(file_name) - - def get_file_chunks(self, file_location, file_name, chunk_size, progress_bar): - """Construct the file upload request for the server. - - Parameters - ---------- - file_location_destination : str, optional - Directory where the file to upload to the server is located. - file_name : str - Name of the file to upload. - chunk_size : int - Chunk size in bytes. - progress_bar : bool - Whether to show a progress bar using ``tqdm``. - """ - pbar = None - if progress_bar: - if not _HAS_TQDM: # pragma: no cover - raise ModuleNotFoundError( - f"To use the keyword argument 'progress_bar', you must have " - f"installed the 'tqdm' package. To avoid this message, you can " - f"set 'progress_bar=False'." - ) - - n_bytes = os.path.getsize(file_name) - - base_name = os.path.basename(file_name) - pbar = tqdm( - total=n_bytes, - desc=f"Uploading {base_name} to {self._channel_str}:{file_location}.", - unit="B", - unit_scale=True, - unit_divisor=1024, - ) - - with open(file_name, "rb") as f: - while True: - piece = f.read(chunk_size) - length = len(piece) - if length == 0: - if pbar is not None: - pbar.close() - return - - if pbar is not None: - pbar.update(length) - - chunk = mechanical_pb2.Chunk(payload=piece, size=length) - yield mechanical_pb2.FileUploadRequest( - file_name=os.path.basename(file_name), file_location=file_location, chunk=chunk - ) - - @property - def project_directory(self): - """Get the project directory for the currently connected Mechanical instance. - - Examples - -------- - Get the project directory of the connected Mechanical instance. - - >>> mechanical.project_directory - '/tmp/ANSYS.username.1/AnsysMech3F97/Project_Mech_Files/' - - """ - return self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - - def list_files(self): - """List the files in the working directory of Mechanical. - - Returns - ------- - list - List of files in the working directory of Mechanical. - - Examples - -------- - List the files in the working directory. - - >>> files = mechanical.list_files() - >>> for file in files: print(file) - """ - result = self.run_python_script( - "import pymechanical_helpers\npymechanical_helpers.GetAllProjectFiles(ExtAPI)" - ) - - files_out = result.splitlines() - if not files_out: # pragma: no cover - self.log_warning("No files listed") - return files_out - - def _get_files(self, files, recursive=False): - self_files = self.list_files() # to avoid calling it too much - - if isinstance(files, str): - if self._local: # pragma: no cover - # in local mode - if os.path.exists(files): - if not os.path.isabs(files): - list_files = [os.path.join(os.getcwd(), files)] - else: - # file exist - list_files = [files] - elif "*" in files: - # using filter - list_files = glob.glob(files, recursive=recursive) - if not list_files: - raise ValueError( - f"The `'files'` parameter ({files}) didn't match any file using " - f"glob expressions in the local client." - ) - else: - raise ValueError( - f"The files parameter ('{files}') does not match any file or pattern." - ) - else: # Remote or looking into Mechanical working directory - if files in self_files: - list_files = [files] - elif "*" in files: - # try filter on the list_files - if recursive: - self.log_warning( - "Because the 'recursive' keyword argument does not work with " - "remote instances, it is ignored." - ) - list_files = fnmatch.filter(self_files, files) - if not list_files: - raise ValueError( - f"The `'files'` parameter ({files}) didn't match any file using " - f"glob expressions in the remote server." - ) - else: - raise ValueError( - f"The `'files'` parameter ('{files}') does not match any file or pattern." - ) - - elif isinstance(files, (list, tuple)): - if not all([isinstance(each, str) for each in files]): - raise ValueError( - "The parameter `'files'` can be a list or tuple, but it " - "should only contain strings." - ) - list_files = files - else: - raise ValueError( - f"The `file` parameter type ({type(files)}) is not supported." - "Only strings, tuple of strings, or list of strings are allowed." - ) - - return list_files - - def download( - self, - files, - target_dir=None, - chunk_size=DEFAULT_CHUNK_SIZE, - progress_bar=None, - recursive=False, - ): # pragma: no cover - """Download files from the working directory of the Mechanical instance. - - It downloads them from the working directory to the target directory. It returns the list - of local file paths for the downloaded files. - - Parameters - ---------- - files : str, list[str], tuple(str) - One or more files on the Mechanical server to download. The files must be - in the same directory as the Mechanical instance. You can use the - :func:`Mechanical.list_files ` - function to list current files. Alternatively, you can specify *glob expressions* to - match file names. For example, you could use ``file*`` to match every file whose - name starts with ``file``. - target_dir: str - Default directory to copy the downloaded files to. The default is ``None`` and - current working directory will be used as target directory. - chunk_size : int, optional - Chunk size in bytes. The default is ``262144``. The value must be less than 4 MB. - progress_bar : bool, optional - Whether to show a progress bar using ``tqdm``. The default is ``None``, in - which case a progress bar is shown. A progress bar is helpful for viewing download - progress. - recursive : bool, optional - Whether to use recursion when using a glob pattern search. The default is ``False``. - - Returns - ------- - List[str] - List of local file paths. - - Notes - ----- - There are some considerations to keep in mind when using the ``download()`` method: - - * The glob pattern search does not search recursively in remote instances. - * In a remote instance, it is not possible to list or download files in a - location other than the Mechanical working directory. - * If you are connected to a local instance and provide a file path, downloading files - from a different folder is allowed but is not recommended. - - Examples - -------- - Download a single file. - - >>> local_file_path_list = mechanical.download('file.out') - - Download all files starting with ``file``. - - >>> local_file_path_list = mechanical.download('file*') - - Download every file in the Mechanical working directory. - - >>> local_file_path_list = mechanical.download('*.*') - - Alternatively, the recommended method is to use the - :func:`download_project() ` - method to download all files. - - >>> local_file_path_list = mechanical.download_project() - - """ - self.verify_valid_connection() - - if chunk_size > 4 * 1024 * 1024: # 4MB - raise ValueError( - "Chunk sizes bigger than 4 MB can generate unstable behaviour in PyMechanical. " - "Decrease the ``chunk_size`` value." - ) - - list_files = self._get_files(files, recursive=recursive) - - if target_dir: - path = pathlib.Path(target_dir) - path.mkdir(parents=True, exist_ok=True) - else: - target_dir = os.getcwd() - - out_files = [] - - for each_file in list_files: - try: - file_name = os.path.basename(each_file) # Getting only the name of the file. - # We try to avoid that when the full path is supplied. It crashes when trying - # to do `os.path.join(target_dir"os.getcwd()", file_name "full filename path"` - # This produces the file structure to flat out, but it is fine, - # because recursive does not work in remote. - self._busy = True - out_file_path = self._download( - each_file, - out_file_name=os.path.join(target_dir, file_name), - chunk_size=chunk_size, - progress_bar=progress_bar, - ) - out_files.append(out_file_path) - except FileNotFoundError: - # So far the gRPC interface returns the size of the file equal - # zero, if the file does not exist, or if its size is zero, - # but they are two different things. - # In theory, since we are obtaining the files name from - # `mechanical.list_files()`, they do exist, so - # if there is any error, it means their size is zero. - pass # This is not the best. - finally: - self._busy = False - - return out_files - - @protect_grpc - def _download( - self, - target_name, - out_file_name, - chunk_size=DEFAULT_CHUNK_SIZE, - progress_bar=None, - ): - """Download a file from the Mechanical instance. - - Parameters - ---------- - target_name : str - Name of the target file on the server. The file must be in the same - directory as the Mechanical instance. You can use the - ``mechanical.list_files()`` function to list current files. - out_file_name : str - Name of the output file if the name is to differ from that for the target - file. - chunk_size : int, optional - Chunk size in bytes. The default is ``"DEFAULT_CHUNK_SIZE"``, in which case - 256 kB is used. The value must be less than 4 MB. - progress_bar : bool, optional - Whether to show a progress bar using ``tqdm``. The default is ``None``, in - which case a progress bar is shown. A progress bar is helpful for showing download - progress. - - Examples - -------- - Download the remote result file "file.rst" as "my_result.rst". - - >>> mechanical.download('file.rst', 'my_result.rst') - """ - self.verify_valid_connection() - - if not progress_bar and _HAS_TQDM: - progress_bar = True - - request = mechanical_pb2.FileDownloadRequest(file_path=target_name, chunk_size=chunk_size) - - responses = self._stub.DownloadFile(request) - - file_size = self.save_chunks_to_file( - responses, out_file_name, progress_bar=progress_bar, target_name=target_name - ) - - if not file_size: # pragma: no cover - raise FileNotFoundError(f'File "{out_file_name}" is empty or does not exist') - - self.log_info(f"{out_file_name} with size {file_size} has been written.") - - return out_file_name - - def save_chunks_to_file(self, responses, filename, progress_bar=False, target_name=""): - """Save chunks to a local file. - - Parameters - ---------- - responses : - filename : str - Name of the local file to save chunks to. - progress_bar : bool, optional - Whether to show a progress bar using ``tqdm``. The default is ``False``. - target_name : str, optional - Name of the target file on the server. The default is ``""``. The file - must be in the same directory as the Mechanical instance. You can use the - ``mechanical.list_files()`` function to list current files. - - Returns - ------- - file_size : int - File size saved in bytes. If ``0`` is returned, no file was written. - """ - pbar = None - if progress_bar: - if not _HAS_TQDM: # pragma: no cover - raise ModuleNotFoundError( - "To use the keyword argument 'progress_bar', you need to have installed " - "the 'tqdm' package.To avoid this message you can set 'progress_bar=False'." - ) - - file_size = 0 - with open(filename, "wb") as f: - for response in responses: - f.write(response.chunk.payload) - payload_size = len(response.chunk.payload) - file_size += payload_size - if pbar is None: - pbar = tqdm( - total=response.file_size, - desc=f"Downloading {self._channel_str}:{target_name} to {filename}", - unit="B", - unit_scale=True, - unit_divisor=1024, - ) - pbar.update(payload_size) - else: - pbar.update(payload_size) - - if pbar is not None: - pbar.close() - - return file_size - - def download_project(self, extensions=None, target_dir=None, progress_bar=False): - """Download all project files in the working directory of the Mechanical instance. - - It downloads them from the working directory to the target directory. It returns the list - of local file paths for the downloaded files. - - Parameters - ---------- - extensions : list[str], tuple[str], optional - List of extensions for filtering files before downloading them. The - default is ``None``. - target_dir : str, optional - Path for downloading the files to. The default is ``None``. - progress_bar : bool, optional - Whether to show a progress bar using ``tqdm``. The default is ``False``. - A progress bar is helpful for viewing download progress. - - Returns - ------- - List[str] - List of local file paths. - - Examples - -------- - Download all the files in the project. - - >>> local_file_path_list = mechanical.download_project() - """ - destination_directory = target_dir.rstrip("\\/") - - # let us create the directory, if it doesn't exist - if destination_directory: - path = pathlib.Path(destination_directory) - path.mkdir(parents=True, exist_ok=True) - else: - destination_directory = os.getcwd() - - # relative directory? - if os.path.isdir(destination_directory): - if not os.path.isabs(destination_directory): - # construct full path - destination_directory = os.path.join(os.getcwd(), destination_directory) - - project_directory = self.project_directory - # remove the trailing slash - server could be windows or linux - project_directory = project_directory.rstrip("\\/") - - # this is where .mechddb resides - parent_directory = os.path.dirname(project_directory) - - list_of_files = [] - - if not extensions: - files = self.list_files() - else: - files = [] - for each_extension in extensions: - # mechdb resides one level above project directory - if "mechdb" == each_extension.lower(): - file_temp = os.path.join(parent_directory, f"*.{each_extension}") - else: - file_temp = os.path.join(project_directory, "**", f"*.{each_extension}") - - if self._local: - list_files_expanded = self._get_files(file_temp, recursive=True) - - if "mechdb" == each_extension.lower(): - # if we have more than one .mechdb in the parent folder - # filter to have only the current mechdb - self_files = self.list_files() - filtered_files = [] - for temp_file in list_files_expanded: - if temp_file in self_files: - filtered_files.append(temp_file) - list_files = filtered_files - else: - list_files = list_files_expanded - else: - list_files = self._get_files(file_temp, recursive=False) - - files.extend(list_files) - - for file in files: - # create similar hierarchy locally - new_path = file.replace(parent_directory, destination_directory) - new_path_dir = os.path.dirname(new_path) - temp_files = self.download( - files=file, target_dir=new_path_dir, progress_bar=progress_bar - ) - list_of_files.extend(temp_files) - - return list_of_files - - def clear(self): - """Clear the database. - - Examples - -------- - Clear the database. - - >>> mechanical.clear() - - """ - self.run_python_script("ExtAPI.DataModel.Project.New()") - - def _make_dummy_call(self): - try: - self._disable_logging = True - self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - except grpc.RpcError: # pragma: no cover - raise - finally: - self._disable_logging = False - - @staticmethod - def __readfile(file_path): - """Get the contents of the file as a string.""" - # open text file in read mode - text_file = open(file_path, "r", encoding="utf-8") - # read whole file to a string - data = text_file.read() - # close file - text_file.close() - - return data - - def __call_run_python_script( - self, script_code: str, enable_logging, log_level, progress_interval - ): - """Run the Python script block on the server. - - Parameters - ---------- - script_block : str - Script block (one or more lines) to run. - enable_logging: bool - Whether to enable logging - log_level: str - Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, - and ``"ERROR"``. - timeout: int, optional - Frequency in milliseconds for getting log messages from the server. - - Returns - ------- - str - Script result. - - """ - log_level_server = self.convert_to_server_log_level(log_level) - request = mechanical_pb2.RunScriptRequest() - request.script_code = script_code - request.enable_logging = enable_logging - request.logger_severity = log_level_server - request.progress_interval = progress_interval - - result = "" - self._busy = True - - try: - for runscript_response in self._stub.RunPythonScript(request): - if runscript_response.log_info == "__done__": - result = runscript_response.script_result - break - else: - if enable_logging: - self.log_message(log_level, runscript_response.log_info) - except grpc.RpcError as error: - error_info = error.details() - error_info_lower = error_info.lower() - # For the given script, return value cannot be converted to string. - if ( - "the expected result" in error_info_lower - and "cannot be return via this API." in error_info - ): - if enable_logging: - self.log_debug(f"Ignoring the conversion error.{error_info}") - result = "" - else: - raise - finally: - self._busy = False - - self._log_mechanical_script(script_code) - - return result - - def log_message(self, log_level, message): - """Log the message using the given log level. - - Parameters - ---------- - log_level: str - Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, - and ``"ERROR"``. - message : str - Message to log. - - Examples - -------- - Log a debug message. - - >>> mechanical.log_message('DEBUG', 'debug message') - - Log an info message. - - >>> mechanical.log_message('INFO', 'info message') - - """ - if log_level == "DEBUG": - self.log_debug(message) - elif log_level == "INFO": - self.log_info(message) - elif log_level == "WARNING": - self.log_warning(message) - elif log_level == "ERROR": - self.log_error(message) - - def log_debug(self, message): - """Log the debug message.""" - if self._disable_logging: - return - self._log.debug(message) - - def log_info(self, message): - """Log the info message.""" - if self._disable_logging: - return - self._log.info(message) - - def log_warning(self, message): - """Log the warning message.""" - if self._disable_logging: - return - self._log.warning(message) - - def log_error(self, message): - """Log the error message.""" - if self._disable_logging: - return - self._log.error(message) - - def verify_valid_connection(self): - """Verify whether the connection to Mechanical is valid.""" - if self._exited: - raise MechanicalExitedError("Mechanical has already exited.") - - if self._stub is None: # pragma: no cover - raise ValueError( - "There is not a valid connection to Mechanical. Launch or connect to it first." - ) - - @property - def exited(self): - """Whether Mechanical already exited.""" - return self._exited - - def _log_mechanical_script(self, script_code): - if self._disable_logging: - return - - if self._log_file_mechanical: - try: - with open(self._log_file_mechanical, "a", encoding="utf-8") as file: - file.write(script_code) - file.write("\n") - except IOError as e: # pragma: no cover - self.log_warning(f"I/O error({e.errno}): {e.strerror}") - except Exception as e: # pragma: no cover - self.log_warning("Unexpected error:" + str(e)) - - -def get_start_instance(start_instance_default=True): - """Check if the ``PYMECHANICAL_START_INSTANCE`` environment variable exists and is valid. - - Parameters - ---------- - start_instance_default : bool, optional - Value to return when ``PYMECHANICAL_START_INSTANCE`` is unset. - - Returns - ------- - bool - ``True`` when the ``PYMECHANICAL_START_INSTANCE`` environment variable exists - and is valid, ``False`` when this environment variable does not exist or is not valid. - If it is unset, ``start_instance_default`` is returned. - - Raises - ------ - OSError - Raised when ``PYMECHANICAL_START_INSTANCE`` is not either ``True`` or ``False`` - (case independent). - - """ - if "PYMECHANICAL_START_INSTANCE" in os.environ: - if os.environ["PYMECHANICAL_START_INSTANCE"].lower() not in [ - "true", - "false", - ]: # pragma: no cover - val = os.environ["PYMECHANICAL_START_INSTANCE"] - raise OSError( - f'Invalid value "{val}" for PYMECHANICAL_START_INSTANCE\n' - 'PYMECHANICAL_START_INSTANCE should be either "TRUE" or "FALSE"' - ) - return os.environ["PYMECHANICAL_START_INSTANCE"].lower() == "true" - return start_instance_default - - -def launch_grpc( - exec_file="", - batch=True, - port=MECHANICAL_DEFAULT_PORT, - additional_switches=None, - additional_envs=None, - verbose=False, -) -> int: - """Start Mechanical locally in gRPC mode. - - Parameters - ---------- - exec_file : str, optional - Path for the Mechanical executable file. The default is ``None``, in which - case the cached location is used. - batch : bool, optional - Whether to launch Mechanical in batch mode. The default is ``True``. - When ``False``, Mechanical is launched in UI mode. - port : int, optional - Port to launch the Mechanical instance on. The default is - ``MECHANICAL_DEFAULT_PORT``. The final port is the first - port available after (or including) this port. - additional_switches : list, optional - List of additional arguments to pass. The default is ``None``. - additional_envs : dictionary, optional - Dictionary of additional environment variables to pass. The default - is ``None``. - verbose : bool, optional - Whether to print all output when launching and running Mechanical. The - default is ``False``. Printing all output is not recommended unless - you are debugging the startup of Mechanical. - - Returns - ------- - int - Port number that the Mechanical instance started on. - - Notes - ----- - If ``PYMECHANICAL_START_INSTANCE`` is set to FALSE, the ``launch_mechanical`` - method looks for an existing instance of Mechanical at ``PYMECHANICAL_IP`` on port - ``PYMECHANICAL_PORT``, with default to ``127.0.0.1`` and ``10000`` if unset. - This is typically used for automated documentation and testing. - - Examples - -------- - Launch Mechanical using the default configuration. - - >>> from ansys.mechanical.core import launch_mechanical - >>> mechanical = launch_mechanical() - - Launch Mechanical using a specified executable file. - - >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v242/aisol/bin/win64/AnsysWBU.exe' - >>> mechanical = launch_mechanical(exec_file_path) - - """ - # verify version - if atp.version_from_path("mechanical", exec_file) < 232: - raise VersionError("The Mechanical gRPC interface requires Mechanical 2023 R2 or later.") - - # get the next available port - local_ports = pymechanical.LOCAL_PORTS - if port is None: - if not local_ports: - port = MECHANICAL_DEFAULT_PORT - else: - port = max(local_ports) + 1 - - while port_in_use(port) or port in local_ports: - port += 1 - local_ports.append(port) - - mechanical_launcher = MechanicalLauncher( - batch, port, exec_file, additional_switches, additional_envs, verbose - ) - mechanical_launcher.launch() - - return port - - -def launch_remote_mechanical(version=None) -> (grpc.Channel, Instance): # pragma: no cover - """Start Mechanical remotely using the Product Instance Management (PIM) API. - - When calling this method, you must ensure that you are in an environment - where PyPIM is configured. You can use the - :func:`pypim.is_configured ` - method to verify that PyPIM is configured. - - Parameters - ---------- - version : str, optional - Mechanical version to run in the three-digit format. For example, ``"242"`` to - run 2024 R2. The default is ``None``, in which case the server runs the latest - installed version. - - Returns - ------- - Tuple containing channel, remote_instance. - """ - pim = pypim.connect() - instance = pim.create_instance(product_name="mechanical", product_version=version) - - LOG.info("PyPIM wait for ready has started.") - instance.wait_for_ready() - LOG.info("PyPIM wait for ready has finished.") - - channel = instance.build_grpc_channel( - options=[ - ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH), - ] - ) - - return channel, instance - - -def launch_mechanical( - allow_input=True, - exec_file=None, - batch=True, - loglevel="ERROR", - log_file=False, - log_mechanical=None, - additional_switches=None, - additional_envs=None, - start_timeout=120, - port=None, - ip=None, - start_instance=None, - verbose_mechanical=False, - clear_on_connect=False, - cleanup_on_exit=True, - version=None, - keep_connection_alive=True, -) -> Mechanical: - """Start Mechanical locally. - - Parameters - ---------- - allow_input: bool, optional - Whether to allow user input when discovering the path to the Mechanical - executable file. - exec_file : str, optional - Path for the Mechanical executable file. The default is ``None``, - in which case the cached location is used. If PyPIM is configured - and this parameter is set to ``None``, PyPIM launches Mechanical - using its ``version`` parameter. - batch : bool, optional - Whether to launch Mechanical in batch mode. The default is ``True``. - When ``False``, Mechanical launches in UI mode. - loglevel : str, optional - Level of messages to print to the console. - Options are: - - - ``"WARNING"``: Prints only Ansys warning messages. - - ``"ERROR"``: Prints only Ansys error messages. - - ``"INFO"``: Prints all Ansys messages. - - The default is ``WARNING``. - log_file : bool, optional - Whether to copy the messages to a file named ``logs.log``, which is - located where the Python script is executed. The default is ``False``. - log_mechanical : str, optional - Path to the output file on the local disk to write every script - command to. The default is ``None``. However, you might set - ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are - sent to Mechanical via PyMechanical to this file. You can then use these - commands to run a script within Mechanical without PyMechanical. - additional_switches : list, optional - Additional switches for Mechanical. The default is ``None``. - additional_envs : dictionary, optional - Dictionary of additional environment variables to pass. The default - is ``None``. - start_timeout : float, optional - Maximum allowable time in seconds to connect to the Mechanical server. - The default is ``120``. - port : int, optional - Port to launch the Mechanical gRPC server on. The default is ``None``, - in which case ``10000`` is used. The final port is the first - port available after (or including) this port. You can override the - default behavior of this parameter with the - ``PYMECHANICAL_PORT=`` environment variable. - ip : str, optional - IP address to use only when ``start_instance`` is ``False``. The - default is ``None``, in which case ``"127.0.0.1"`` is used. If you - provide an IP address, ``start_instance`` is set to ``False``. - A host name can be provided as an alternative to an IP address. - start_instance : bool, optional - Whether to launch and connect to a new Mechanical instance. The default - is ``None``, in which case an attempt is made to connect to an existing - Mechanical instance at the given ``ip`` and ``port`` parameters, which have - defaults of ``"127.0.0.1"`` and ``10000`` respectively. When ``True``, - a local instance of Mechanical is launched. You can override the default - behavior of this parameter with the ``PYMECHANICAL_START_INSTANCE=FALSE`` - environment variable. - verbose_mechanical : bool, optional - Whether to enable printing of all output when launching and running - a Mechanical instance. The default is ``False``. This parameter should be - set to ``True`` for debugging only as output can be tracked within - PyMechanical. - clear_on_connect : bool, optional - When ``start_instance`` is ``False``, whether to clear the environment - when connecting to Mechanical. The default is ``False``. When ``True``, - a fresh environment is provided when you connect to Mechanical. - cleanup_on_exit : bool, optional - Whether to exit Mechanical when Python exits. The default is ``True``. - When ``False``, Mechanical is not exited when the garbage for this Mechanical - instance is collected. - version : str, optional - Mechanical version to run in the three-digit format. For example, ``"242"`` - for 2024 R2. The default is ``None``, in which case the server runs the - latest installed version. If PyPIM is configured and ``exce_file=None``, - PyPIM launches Mechanical using its ``version`` parameter. - keep_connection_alive : bool, optional - Whether to keep the gRPC connection alive by running a background thread - and making dummy calls for remote connections. The default is ``True``. - - Returns - ------- - ansys.mechanical.core.mechanical.Mechanical - Instance of Mechanical. - - Notes - ----- - If the environment is configured to use `PyPIM `_ - and ``start_instance=True``, then starting the instance is delegated to PyPIM. - In this case, most of the preceding parameters are ignored because the server-side - configuration is used. - - Examples - -------- - Launch Mechanical. - - >>> from ansys.mechanical.core import launch_mechanical - >>> mech = launch_mechanical() - - Launch Mechanical using a specified executable file. - - >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v242/aisol/bin/win64/AnsysWBU.exe' - >>> mech = launch_mechanical(exec_file_path) - - Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port - ``50001``. - - >>> mech = launch_mechanical(start_instance=False, ip='192.168.1.30', port=50001) - """ - # Start Mechanical with PyPIM if the environment is configured for it - # and a directive on how to launch Mechanical was not passed. - if pypim.is_configured() and exec_file is None: # pragma: no cover - LOG.info("Starting Mechanical remotely. The startup configuration will be ignored.") - channel, remote_instance = launch_remote_mechanical(version=version) - return Mechanical( - channel=channel, - remote_instance=remote_instance, - loglevel=loglevel, - log_file=log_file, - log_mechanical=log_mechanical, - timeout=start_timeout, - cleanup_on_exit=cleanup_on_exit, - keep_connection_alive=keep_connection_alive, - ) - - if ip is None: - ip = os.environ.get("PYMECHANICAL_IP", LOCALHOST) - else: # pragma: no cover - start_instance = False - ip = socket.gethostbyname(ip) # Converting ip or host name to ip - - check_valid_ip(ip) # double check - - if port is None: - port = int(os.environ.get("PYMECHANICAL_PORT", MECHANICAL_DEFAULT_PORT)) - check_valid_port(port) - - # connect to an existing instance if enabled - if start_instance is None: - start_instance = check_valid_start_instance( - os.environ.get("PYMECHANICAL_START_INSTANCE", True) - ) - - # special handling when building the gallery outside of CI. This - # creates an instance of Mechanical the first time if PYMECHANICAL_START_INSTANCE - # is False. - # when you launch, treat it as local. - # when you connect, treat it as remote. We cannot differentiate between - # local vs container scenarios. In the container scenarios, we could be connecting - # to a container using local ip and port - if pymechanical.BUILDING_GALLERY: # pragma: no cover - # launch an instance of PyMechanical if it does not already exist and - # starting instances is allowed - if start_instance and GALLERY_INSTANCE[0] is None: - mechanical = launch_mechanical( - start_instance=True, - cleanup_on_exit=False, - loglevel=loglevel, - ) - GALLERY_INSTANCE[0] = {"ip": mechanical._ip, "port": mechanical._port} - return mechanical - - # otherwise, connect to the existing gallery instance if available - elif GALLERY_INSTANCE[0] is not None: - mechanical = Mechanical( - ip=GALLERY_INSTANCE[0]["ip"], - port=GALLERY_INSTANCE[0]["port"], - cleanup_on_exit=False, - loglevel=loglevel, - local=False, - ) - # we are connecting to the existing gallery instance, - # we need to clear Mechanical. - mechanical.clear() - - return mechanical - - # finally, if running on CI/CD, connect to the default instance - else: - mechanical = Mechanical( - ip=ip, port=port, cleanup_on_exit=False, loglevel=loglevel, local=False - ) - # we are connecting for gallery generation, - # we need to clear Mechanical. - mechanical.clear() - return mechanical - - if not start_instance: - mechanical = Mechanical( - ip=ip, - port=port, - loglevel=loglevel, - log_file=log_file, - log_mechanical=log_mechanical, - timeout=start_timeout, - cleanup_on_exit=cleanup_on_exit, - keep_connection_alive=keep_connection_alive, - local=False, - ) - if clear_on_connect: - mechanical.clear() - - # setting ip for the grpc server - if ip != LOCALHOST: # Default local ip is 127.0.0.1 - create_ip_file(ip, os.getcwd()) - - return mechanical - - # verify executable - if exec_file is None: - exec_file = get_mechanical_path(allow_input) - if exec_file is None: # pragma: no cover - raise FileNotFoundError( - "Path to the Mechanical executable file is invalid or cache cannot be loaded. " - "Enter a path manually by specifying a value for the " - "'exec_file' parameter." - ) - else: # verify ansys exists at this location - if not os.path.isfile(exec_file): - raise FileNotFoundError( - f'This path for the Mechanical executable is invalid: "{exec_file}"\n' - "Enter a path manually by specifying a value for the " - "'exec_file' parameter." - ) - - start_parm = { - "exec_file": exec_file, - "batch": batch, - "additional_switches": additional_switches, - "additional_envs": additional_envs, - } - - try: - port = launch_grpc(port=port, verbose=verbose_mechanical, **start_parm) - start_parm["local"] = True - mechanical = Mechanical( - ip=ip, - port=port, - loglevel=loglevel, - log_file=log_file, - log_mechanical=log_mechanical, - timeout=start_timeout, - cleanup_on_exit=cleanup_on_exit, - keep_connection_alive=keep_connection_alive, - **start_parm, - ) - except Exception as exception: # pragma: no cover - # pass - raise exception - - return mechanical - - -def connect_to_mechanical( - ip=None, - port=None, - loglevel="ERROR", - log_file=False, - log_mechanical=None, - connect_timeout=120, - clear_on_connect=False, - cleanup_on_exit=False, - keep_connection_alive=True, -) -> Mechanical: - """Connect to an existing Mechanical server instance. - - Parameters - ---------- - ip : str, optional - IP address for connecting to an existing Mechanical instance. The - IP address defaults to ``"127.0.0.1"``. - port : int, optional - Port to listen on for an existing Mechanical instance. The default is ``None``, - in which case ``10000`` is used. You can override the - default behavior of this parameter with the - ``PYMECHANICAL_PORT=`` environment variable. - loglevel : str, optional - Level of messages to print to the console. - Options are: - - - ``"WARNING"``: Prints only Ansys warning messages. - - ``"ERROR"``: Prints only Ansys error messages. - - ``"INFO"``: Prints all Ansys messages. - - The default is ``WARNING``. - log_file : bool, optional - Whether to copy the messages to a file named ``logs.log``, which is - located where the Python script is executed. The default is ``False``. - log_mechanical : str, optional - Path to the output file on the local disk to write every script - command to. The default is ``None``. However, you might set - ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are - sent to Mechanical via PyMechanical to this file. You can then use these - commands to run a script within Mechanical without PyMechanical. - connect_timeout : float, optional - Maximum allowable time in seconds to connect to the Mechanical server. - The default is ``120``. - clear_on_connect : bool, optional - Whether to clear the Mechanical instance when connecting. The default is ``False``. - When ``True``, a fresh environment is provided when you connect to Mechanical. - cleanup_on_exit : bool, optional - Whether to exit Mechanical when Python exits. The default is ``False``. - When ``False``, Mechanical is not exited when the garbage for this Mechanical - instance is collected. - keep_connection_alive : bool, optional - Whether to keep the gRPC connection alive by running a background thread - and making dummy calls for remote connections. The default is ``True``. - - Returns - ------- - ansys.mechanical.core.mechanical.Mechanical - Instance of Mechanical. - - Examples - -------- - Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port - ``50001``.. - - - >>> from ansys.mechanical.core import connect_to_mechanical - >>> pymech = connect_to_mechanical(ip='192.168.1.30', port=50001) - """ - return launch_mechanical( - start_instance=False, - loglevel=loglevel, - log_file=log_file, - log_mechanical=log_mechanical, - start_timeout=connect_timeout, - port=port, - ip=ip, - clear_on_connect=clear_on_connect, - cleanup_on_exit=cleanup_on_exit, - keep_connection_alive=keep_connection_alive, - ) +# 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. + +"""Connect to Mechanical gRPC server and issues commands.""" +import atexit +from contextlib import closing +import datetime +import fnmatch +from functools import wraps +import glob +import os +import pathlib +import socket +import threading +import time +import weakref + +import ansys.api.mechanical.v0.mechanical_pb2 as mechanical_pb2 +import ansys.api.mechanical.v0.mechanical_pb2_grpc as mechanical_pb2_grpc +import ansys.platform.instancemanagement as pypim +from ansys.platform.instancemanagement import Instance +import ansys.tools.path as atp +import grpc + +import ansys.mechanical.core as pymechanical +from ansys.mechanical.core import LOG +from ansys.mechanical.core.errors import ( + MechanicalExitedError, + MechanicalRuntimeError, + VersionError, + protect_grpc, +) +from ansys.mechanical.core.launcher import MechanicalLauncher +from ansys.mechanical.core.misc import ( + check_valid_ip, + check_valid_port, + check_valid_start_instance, + threaded, +) + +# Checking if tqdm is installed. +# If it is, the default value for progress_bar is true. +try: + from tqdm import tqdm + + _HAS_TQDM = True + """Whether or not tqdm is installed.""" +except ModuleNotFoundError: # pragma: no cover + _HAS_TQDM = False + +# Default 256 MB message length +MAX_MESSAGE_LENGTH = int(os.environ.get("PYMECHANICAL_MAX_MESSAGE_LENGTH", 256 * 1024**2)) +"""Default message length.""" + +# Chunk sizes for streaming and file streaming +DEFAULT_CHUNK_SIZE = 256 * 1024 # 256 kB +"""Default chunk size.""" +DEFAULT_FILE_CHUNK_SIZE = 1024 * 1024 # 1MB +"""Default file chunk size.""" + + +def setup_logger(loglevel="INFO", log_file=True, mechanical_instance=None): + """Initialize the logger for the given mechanical instance.""" + # Return existing log if this function has already been called + if hasattr(setup_logger, "log"): + return setup_logger.log + else: + setup_logger.log = LOG.add_instance_logger("Mechanical", mechanical_instance) + + setup_logger.log.setLevel(loglevel) + + if log_file: + if isinstance(log_file, str): + setup_logger.log.log_to_file(filename=log_file, level=loglevel) + + return setup_logger.log + + +def suppress_logging(func): + """Decorate a function to suppress the logging for a Mechanical instance.""" + + @wraps(func) + def wrapper(*args, **kwargs): + mechanical = args[0] + prior_log_level = mechanical.log.level + if prior_log_level != "CRITICAL": + mechanical.set_log_level("CRITICAL") + + out = func(*args, **kwargs) + + if prior_log_level != "CRITICAL": + mechanical.set_log_level(prior_log_level) + + return out + + return wrapper + + +LOCALHOST = "127.0.0.1" +"""Localhost address.""" + +MECHANICAL_DEFAULT_PORT = 10000 +"""Default Mechanical port.""" + +GALLERY_INSTANCE = [None] +"""List of gallery instances.""" + + +def _cleanup_gallery_instance(): # pragma: no cover + """Clean up any leftover instances of Mechanical from building the gallery.""" + if GALLERY_INSTANCE[0] is not None: + mechanical = Mechanical( + ip=GALLERY_INSTANCE[0]["ip"], + port=GALLERY_INSTANCE[0]["port"], + ) + mechanical.exit(force=True) + + +atexit.register(_cleanup_gallery_instance) + + +def port_in_use(port, host=LOCALHOST): + """Check whether a port is in use at the given host. + + You must actually *bind* the address. Just checking if you can create + a socket is insufficient because it is possible to run into permission + errors like:: + + An attempt was made to access a socket in a way forbidden by its + access permissions. + """ + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + if sock.connect_ex((host, port)) == 0: + return True + else: + return False + + +def check_ports(port_range, ip="localhost"): + """Check the state of ports in a port range.""" + ports = {} + for port in port_range: + ports[port] = port_in_use(port, ip) + return ports + + +def close_all_local_instances(port_range=None, use_thread=True): + """Close all Mechanical instances within a port range. + + You can use this method when cleaning up from a failed pool or + batch run. + + Parameters + ---------- + port_range : list, optional + List of a range of ports to use when cleaning up Mechanical. The + default is ``None``, in which case the ports managed by + PyMechanical are used. + + use_thread : bool, optional + Whether to use threads to close the Mechanical instances. + The default is ``True``. So this call will return immediately. + + Examples + -------- + Close all Mechanical instances connected on local ports. + + >>> import ansys.mechanical.core as pymechanical + >>> pymechanical.close_all_local_instances() + + """ + if port_range is None: + port_range = pymechanical.LOCAL_PORTS + + @threaded + def close_mechanical_threaded(port, name="Closing Mechanical instance in a thread"): + close_mechanical(port, name) + + def close_mechanical(port, name="Closing Mechanical instance"): + try: + mechanical = Mechanical(port=port) + LOG.debug(f"{name}: {mechanical.name}.") + mechanical.exit(force=True) + except OSError: # pragma: no cover + pass + + ports = check_ports(port_range) + for port_temp, state in ports.items(): + if state: + if use_thread: + close_mechanical_threaded(port_temp) + else: + close_mechanical(port_temp) + + +def create_ip_file(ip, path): + """Create the ``mylocal.ip`` file needed to change the IP address of the gRPC server.""" + file_name = os.path.join(path, "mylocal.ip") + with open(file_name, "w", encoding="utf-8") as f: + f.write(ip) + + +def get_mechanical_path(allow_input=True): + """Get path. + + Deprecated - use `ansys.tools.path.get_mechanical_path` instead + """ + return atp.get_mechanical_path(allow_input) + + +def check_valid_mechanical(): + """Change to see if the default Mechanical path is valid. + + Example (windows) + ----------------- + + >>> from ansys.mechanical.core import mechanical + >>> from ansys.tools.path import change_default_mechanical_path + >>> mechanical_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe' + >>> change_default_mechanical_path(mechanical_pth) + >>> mechanical.check_valid_mechanical() + True + + + """ + mechanical_path = atp.get_mechanical_path(False) + if mechanical_path is None: + return False + mechanical_version = atp.version_from_path("mechanical", mechanical_path) + return not (mechanical_version < 232 and os.name != "posix") + + +def change_default_mechanical_path(exe_loc): + """Change default path. + + Deprecated - use `ansys.tools.path.change_default_mechanical_path` instead. + """ + return atp.change_default_mechanical_path(exe_loc) + + +def save_mechanical_path(exe_loc=None): # pragma: no cover + """Save path. + + Deprecated - use `ansys.tools.path.save_mechanical_path` instead. + """ + return atp.save_mechanical_path(exe_loc) + + +client_to_server_loglevel = { + "DEBUG": 1, + "INFO": 2, + "WARN": 3, + "WARNING": 3, + "ERROR": 4, + "CRITICAL": 5, +} + + +class Mechanical(object): + """Connects to a gRPC Mechanical server and allows commands to be passed.""" + + # Required by `_name` method to be defined before __init__ be + _ip = None + _port = None + + def __init__( + self, + ip=None, + port=None, + timeout=60.0, + loglevel="WARNING", + log_file=False, + log_mechanical=None, + cleanup_on_exit=False, + channel=None, + remote_instance=None, + keep_connection_alive=True, + **kwargs, + ): + """Initialize the member variable based on the arguments. + + Parameters + ---------- + ip : str, optional + IP address to connect to the server. The default is ``None`` + in which case ``localhost`` is used. + port : int, optional + Port to connect to the Mecahnical server. The default is ``None``, + in which case ``10000`` is used. + timeout : float, optional + Maximum allowable time for connecting to the Mechanical server. + The default is ``60.0``. + loglevel : str, optional + Level of messages to print to the console. The default is ``WARNING``. + + - ``ERROR`` prints only error messages. + - ``WARNING`` prints warning and error messages. + - ``INFO`` prints info, warning and error messages. + - ``DEBUG`` prints debug, info, warning and error messages. + + log_file : bool, optional + Whether to copy the messages to a file named ``logs.log``, which is + located where the Python script is executed. The default is ``False``. + log_mechanical : str, optional + Path to the output file on the local disk for writing every script + command to. The default is ``None``. However, you might set + ``"log_mechanical='pymechanical_log.txt"`` to write all commands that are + sent to Mechanical via PyMechanical in this file so that you can use them + to run a script within Mechanical without PyMechanical. + cleanup_on_exit : bool, optional + Whether to exit Mechanical when Python exits. The default is ``False``, + in which case Mechanical is not exited when the garbage for this Mechanical + instance is collected. + channel : grpc.Channel, optional + gRPC channel to use for the connection. The default is ``None``. + You can use this parameter as an alternative to the ``ip`` and ``port`` + parameters. + remote_instance : ansys.platform.instancemanagement.Instance + Corresponding remote instance when Mechanical is launched + through PyPIM. The default is ``None``. If a remote instance + is specified, this instance is deleted when the + :func:`mecahnical.exit ` + function is called. + keep_connection_alive : bool, optional + Whether to keep the gRPC connection alive by running a background thread + and making dummy calls for remote connections. The default is ``True``. + + Examples + -------- + Connect to a Mechanical instance already running on locally on the + default port (``10000``). + + >>> from ansys.mechanical import core as pymechanical + >>> mechanical = pymechanical.Mechanical() + + Connect to a Mechanical instance running on the LAN on a default port. + + >>> mechanical = pymechanical.Mechanical('192.168.1.101') + + Connect to a Mechanical instance running on the LAN on a non-default port. + + >>> mechanical = pymechanical.Mechanical('192.168.1.101', port=60001) + + If you want to customize the channel, you can connect directly to gRPC channels. + For example, if you want to create an insecure channel with a maximum message + length of 8 MB, you would run: + + >>> import grpc + >>> channel_temp = grpc.insecure_channel( + ... '127.0.0.1:10000', + ... options=[ + ... ("grpc.max_receive_message_length", 8*1024**2), + ... ], + ... ) + >>> mechanical = pymechanical.Mechanical(channel=channel_temp) + """ + self._remote_instance = remote_instance + self._channel = channel + self._keep_connection_alive = keep_connection_alive + + self._locked = False # being used within MechanicalPool + + # ip could be a machine name. Convert it to an IP address. + ip_temp = ip + if channel is not None: + if ip is not None or port is not None: + raise ValueError( + "If `channel` is specified, neither `port` nor `ip` can be specified." + ) + elif ip is None: + ip_temp = "127.0.0.1" + else: + ip_temp = socket.gethostbyname(ip) # Converting ip or host name to ip + + self._ip = ip_temp + self._port = port + + self._start_parm = kwargs + + self._cleanup_on_exit = cleanup_on_exit + self._busy = False # used to check if running a command on the server + + self._local = ip_temp in ["127.0.0.1", "127.0.1.1", "localhost"] + if "local" in kwargs: # pragma: no cover # allow this to be overridden + self._local = kwargs["local"] + + self._health_response_queue = None + self._exiting = False + self._exited = None + + self._version = None + + if port is None: + port = MECHANICAL_DEFAULT_PORT + self._port = port + + self._stub = None + self._timeout = timeout + + if channel is None: + self._channel = self._create_channel(ip_temp, port) + else: + self._channel = channel + + self._logLevel = loglevel + self._log_file = log_file + self._log_mechanical = log_mechanical + + self._log = LOG.add_instance_logger(self.name, self, level=loglevel) # instance logger + # adding a file handler to the logger + if log_file: + if not isinstance(log_file, str): + log_file = "instance.log" + self._log.log_to_file(filename=log_file, level=loglevel) + + self._log_file_mechanical = log_mechanical + if log_mechanical: + if not isinstance(log_mechanical, str): + self._log_file_mechanical = "pymechanical_log.txt" + else: + self._log_file_mechanical = log_mechanical + + # temporarily disable logging + # useful when we run some dummy calls + self._disable_logging = False + + if self._local: + self.log_info("Mechanical connection is treated as local.") + else: + self.log_info("Mechanical connection is treated as remote.") + + # connect and validate to the channel + self._multi_connect(timeout=timeout) + + self.log_info("Mechanical is ready to accept grpc calls.") + + def __del__(self): # pragma: no cover + """Clean up on exit.""" + if self._cleanup_on_exit: + try: + self.exit(force=True) + except grpc.RpcError as e: + self.log_error(f"exit: {e}") + + # def _set_log_level(self, level): + # """Set an alias for the log level.""" + # self.set_log_level(level) + + @property + def log(self): + """Log associated with the current Mechanical instance.""" + return self._log + + @property + def version(self) -> str: + """Get the Mechanical version based on the instance. + + Examples + -------- + Get the version of the connected Mechanical instance. + + >>> mechanical.version + '251' + """ + if self._version is None: + try: + self._disable_logging = True + script = ( + 'clr.AddReference("Ans.Utilities")\n' + "import Ansys\n" + "config = Ansys.Utilities.ApplicationConfiguration.DefaultConfiguration\n" + "config.VersionInfo.VersionString" + ) + self._version = self.run_python_script(script) + except grpc.RpcError: # pragma: no cover + raise + finally: + self._disable_logging = False + return self._version + + @property + def name(self): + """Name (unique identifier) of the Mechanical instance.""" + try: + if self._channel is not None: + if self._remote_instance is not None: # pragma: no cover + return f"GRPC_{self._channel._channel._channel.target().decode()}" + else: + return f"GRPC_{self._channel._channel.target().decode()}" + except Exception as e: # pragma: no cover + LOG.error(f"Error getting the Mechanical instance name: {str(e)}") + + return f"GRPC_instance_{id(self)}" # pragma: no cover + + @property + def busy(self): + """Return True when the Mechanical gRPC server is executing a command.""" + return self._busy + + @property + def locked(self): + """Instance is in use within a pool.""" + return self._locked + + @locked.setter + def locked(self, new_value): + """Instance is in use within a pool.""" + self._locked = new_value + + def _multi_connect(self, n_attempts=5, timeout=60): + """Try to connect over a series of attempts to the channel. + + Parameters + ---------- + n_attempts : int, optional + Number of connection attempts. The default is ``5``. + timeout : float, optional + Maximum allowable time in seconds for establishing a connection. + The default is ``60``. + """ + # This prevents a single failed connection from blocking other attempts + connected = False + attempt_timeout = timeout / n_attempts + self.log_debug( + f"timetout:{timeout} n_attempts:{n_attempts} attempt_timeout={attempt_timeout}" + ) + + max_time = time.time() + timeout + i = 1 + while time.time() < max_time and i <= n_attempts: + self.log_debug(f"Connection attempt {i} with attempt timeout {attempt_timeout}s") + connected = self._connect(timeout=attempt_timeout) + + if connected: + self.log_debug(f"Connection attempt {i} succeeded.") + break + + i += 1 + else: # pragma: no cover + self.log_debug( + f"Reached either maximum amount of connection attempts " + f"({n_attempts}) or timeout ({timeout} s)." + ) + + if not connected: # pragma: no cover + raise IOError(f"Unable to connect to Mechanical instance at {self._channel_str}.") + + @property + def _channel_str(self): + """Target string, generally in the form of ``ip:port``, such as ``127.0.0.1:10000``.""" + if self._channel is not None: + if self._remote_instance is not None: + return self._channel._channel._channel.target().decode() # pragma: no cover + else: + return self._channel._channel.target().decode() + return "" # pragma: no cover + + def _connect(self, timeout=12, enable_health_check=False): + """Connect a gRPC channel to a remote or local Mechanical instance. + + Parameters + ---------- + timeout : float + Maximum allowable time in seconds for establishing a connection. The + default is ``12``. + enable_health_check : bool, optional + Whether to enable a check to see if the connection is healthy. + The default is ``False``. + """ + self._state = grpc.channel_ready_future(self._channel) + self._stub = mechanical_pb2_grpc.MechanicalServiceStub(self._channel) + + # verify connection + time_start = time.time() + while ((time.time() - time_start) < timeout) and not self._state._matured: + time.sleep(0.01) + + if not self._state._matured: # pragma: no cover + return False + + self.log_debug("Established a connection to the Mechanical gRPC server.") + + self.wait_till_mechanical_is_ready(timeout) + + # keeps Mechanical session alive + self._timer = None + if not self._local and self._keep_connection_alive: # pragma: no cover + self._initialised = threading.Event() + self._t_trigger = time.time() + self._t_delay = 30 + self._timer = threading.Thread( + target=Mechanical._threaded_heartbeat, args=(weakref.proxy(self),) + ) + self._timer.daemon = True + self._timer.start() + + # enable health check + if enable_health_check: # pragma: no cover + self._enable_health_check() + + self.__server_version = None + + return True + + def _enable_health_check(self): # pragma: no cover + """Place the status of the health check in the health response queue.""" + # lazy imports here to speed up module load + from grpc_health.v1 import health_pb2, health_pb2_grpc + + def _consume_responses(response_iterator, response_queue): + try: + for response in response_iterator: + response_queue.put(response) + # NOTE: We're doing absolutely nothing with this as + # this point since the server-side health check + # doesn't change state. + except Exception: + if self._exiting: + return + self._exited = True + raise MechanicalExitedError( + "Lost connection with the Mechanical gRPC server." + ) from None + + # enable health check + from queue import Queue + + request = health_pb2.HealthCheckRequest() + self._health_stub = health_pb2_grpc.HealthStub(self._channel) + rendezvous = self._health_stub.Watch(request) + + # health check feature implemented after 2023 R1 + try: + status = rendezvous.next() + except Exception as err: + if err.code().name != "UNIMPLEMENTED": + raise err + return + + if status.status != health_pb2.HealthCheckResponse.SERVING: + raise MechanicalRuntimeError( + "Cannot enable health check and/or connect to the Mechanical server." + ) + + self._health_response_queue = Queue() + + # allow main process to exit by setting daemon to true + thread = threading.Thread( + target=_consume_responses, + args=(rendezvous, self._health_response_queue), + daemon=True, + ) + thread.start() + + def _threaded_heartbeat(self): # pragma: no cover + """To call from a thread to verify that a Mechanical instance is alive.""" + self._initialised.set() + while True: + if self._exited: + break + try: + time.sleep(self._t_delay) + if not self.is_alive: + break + except ReferenceError: + break + # except Exception: + # continue + + def _create_channel(self, ip, port): + """Create an unsecured gRPC channel.""" + check_valid_ip(ip) + + # open the channel + channel_str = f"{ip}:{port}" + LOG.debug(f"Opening insecure channel at {channel_str}.") + return grpc.insecure_channel( + channel_str, + options=[ + ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH), + ], + ) + + @property + def is_alive(self) -> bool: + """Whether there is an active connection to the Mechanical gRPC server.""" + if self._exited: + return False + + if self._busy: # pragma: no cover + return True + + try: # pragma: no cover + self._make_dummy_call() + return True + except grpc.RpcError: + return False + + @staticmethod + def set_log_level(loglevel): + """Set the log level. + + Parameters + ---------- + loglevel : str, int + Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"`` + and ``"ERROR"``. + + Examples + -------- + Set the log level to the ``"DEBUG"`` level. + + # >>> mechanical.set_log_level('DEBUG') + # + # Set the log level to info + # + # >>> mechanical.set_log_level('INFO') + # + # Set the log level to warning + # + # >>> mechanical.set_log_level('WARNING') + # + # Set the log level to error + # + # >>> mechanical.set_log_level('ERROR') + """ + if isinstance(loglevel, str): + loglevel = loglevel.upper() + setup_logger(loglevel=loglevel) + + def get_product_info(self): + """Get product information by running a script on the Mechanical gRPC server.""" + + def _get_jscript_product_info_command(): + return ( + 'ExtAPI.Application.ScriptByName("jscript").ExecuteCommand' + '("var productInfo = DS.Script.getProductInfo();returnFromScript(productInfo);")' + ) + + def _get_python_product_info_command(): + return ( + 'clr.AddReference("Ansys.Mechanical.Application")\n' + "Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString" + ) + + try: + self._disable_logging = True + if int(self.version) >= 232: + script = _get_python_product_info_command() + else: + script = _get_jscript_product_info_command() + return self.run_python_script(script) + except grpc.RpcError: + raise + finally: + self._disable_logging = False + + @suppress_logging + def __repr__(self): + """Get the user-readable string form of the Mechanical instance.""" + try: + if self._exited: + return "Mechanical exited." + return self.get_product_info() + except grpc.RpcError: + return "Error getting product info." + + def launch(self, cleanup_on_exit=True): + """Launch Mechanical in batch or UI mode. + + Parameters + ---------- + cleanup_on_exit : bool, optional + Whether to exit Mechanical when Python exits. The default is ``True``. + When ``False``, Mechanical is not exited when the garbage for this + Mechanical instance is collected. + """ + if not self._local: + raise RuntimeError("Can only launch with a local instance of Mechanical.") + + # let us respect the current cleanup behavior + if self._cleanup_on_exit: + self.exit() + + exec_file = self._start_parm.get("exec_file", get_mechanical_path(allow_input=False)) + batch = self._start_parm.get("batch", True) + additional_switches = self._start_parm.get("additional_switches", None) + additional_envs = self._start_parm.get("additional_envs", None) + port = launch_grpc( + exec_file=exec_file, + batch=batch, + additional_switches=additional_switches, + additional_envs=additional_envs, + verbose=True, + ) + # update the new cleanup behavior + self._cleanup_on_exit = cleanup_on_exit + self._port = port + self._channel = self._create_channel(self._ip, port) + self._connect(port) + + self.log_info("Mechanical is ready to accept gRPC calls.") + + def wait_till_mechanical_is_ready(self, wait_time=-1): + """Wait until Mechanical is ready. + + Parameters + ---------- + wait_time : float, optional + Maximum allowable time in seconds for connecting to the Mechanical gRPC server. + """ + time_1 = datetime.datetime.now() + + sleep_time = 0.5 + if wait_time == -1: # pragma: no cover + self.log_info("Waiting for Mechanical to be ready...") + else: + self.log_info(f"Waiting for Mechanical to be ready. Maximum wait time: {wait_time}s") + + while not self.__isMechanicalReady(): + time_2 = datetime.datetime.now() + time_interval = time_2 - time_1 + time_interval_seconds = int(time_interval.total_seconds()) + + self.log_debug( + f"Mechanical is not ready. You've been waiting for {time_interval_seconds}." + ) + if self._timeout != -1: + if time_interval_seconds > wait_time: + self.log_debug( + f"Allowed wait time {wait_time}s. " + f"Waited so for {time_interval_seconds}s, " + f"before throwing the error." + ) + raise RuntimeError( + f"Couldn't connect to Mechanical. " f"Waited for {time_interval_seconds}s." + ) + + time.sleep(sleep_time) + + time_2 = datetime.datetime.now() + time_interval = time_2 - time_1 + time_interval_seconds = int(time_interval.total_seconds()) + + self.log_info(f"Mechanical is ready. It took {time_interval_seconds} seconds to verify.") + + def __isMechanicalReady(self): + """Whether the Mechanical gRPC server is ready. + + Returns + ------- + bool + ``True`` if Mechanical is ready, ``False`` otherwise. + """ + try: + script = "ExtAPI.DataModel.Project.ProductVersion" + self.run_python_script(script) + except grpc.RpcError as error: + self.log_debug(f"Mechanical is not ready. Error:{error}.") + return False + + return True + + @staticmethod + def convert_to_server_log_level(log_level): + """Convert the log level to the server log level. + + Parameters + ---------- + log_level : str + Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, + ``"ERROR"``, and ``"CRITICAL"``. + + Returns + ------- + Converted log level for the server. + """ + value = client_to_server_loglevel.get(log_level) + + if value is not None: + return value + + raise ValueError( + f"Log level {log_level} is invalid. Possible values are " + f"'DEBUG','INFO', 'WARNING', 'ERROR', and 'CRITICAL'." + ) + + def run_python_script( + self, script_block: str, enable_logging=False, log_level="WARNING", progress_interval=2000 + ): + """Run a Python script block inside Mechanical. + + It returns the string value of the last executed statement. If the value cannot be + returned as a string, it will return an empty string. + + Parameters + ---------- + script_block : str + Script block (one or more lines) to run. + enable_logging: bool, optional + Whether to enable logging. The default is ``False``. + log_level: str + Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``, + ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. + progress_interval: int, optional + Frequency in milliseconds for getting log messages from the server. + The default is ``2000``. + + Returns + ------- + str + Script result. + + Examples + -------- + Return a value from a simple calculation. + + >>> mechanical.run_python_script('2+3') + '5' + + Return a string value from Project object. + + >>> mechanical.run_python_script('ExtAPI.DataModel.Project.ProductVersion') + '2025 R1' + + Return an empty string, when you try to return the Project object. + + >>> mechanical.run_python_script('ExtAPI.DataModel.Project') + '' + + Return an empty string for assignments. + + >>> mechanical.run_python_script('version = ExtAPI.DataModel.Project.ProductVersion') + '' + + Return value from the last executed statement from a variable. + + >>> script=''' + addition = 2 + 3 + multiplication = 3 * 4 + multiplication + ''' + >>> mechanical.run_python_script(script) + '12' + + Return value from last executed statement from a function call. + + >>> script=''' + import math + math.pow(2,3) + ''' + >>> mechanical.run_python_script(script) + '8' + + Handle an error scenario. + + >>> script = 'hello_world()' + >>> import grpc + >>> try: + mechanical.run_python_script(script) + except grpc.RpcError as error: + print(error.details()) + name 'hello_world' is not defined + + """ + self.verify_valid_connection() + result_as_string = self.__call_run_python_script( + script_block, enable_logging, log_level, progress_interval + ) + return result_as_string + + def run_python_script_from_file( + self, file_path, enable_logging=False, log_level="WARNING", progress_interval=2000 + ): + """Run the contents a python file inside Mechanical. + + It returns the string value of the last executed statement. If the value cannot be + returned as a string, it will return an empty string. + + Parameters + ---------- + file_path : + Path for the Python file. + enable_logging: bool, optional + Whether to enable logging. The default is ``False``. + log_level: str + Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``, + ``"INFO"``, ``"WARNING"``, and ``"ERROR"``. + progress_interval: int, optional + Frequency in milliseconds for getting log messages from the server. + The default is ``2000``. + + Returns + ------- + str + Script result. + + Examples + -------- + Return a value from a simple calculation. + + Contents of **simple.py** file + + 2+3 + + >>> mechanical.run_python_script_from_file('simple.py') + '5' + + Return a value from a simple function call. + + Contents of **test.py** file + + import math + + math.pow(2,3) + + >>> mechanical.run_python_script_from_file('test.py') + '8' + + """ + self.verify_valid_connection() + self.log_debug(f"run_python_script_from_file started") + script_code = Mechanical.__readfile(file_path) + self.log_debug(f"run_python_script_from_file started") + return self.run_python_script(script_code, enable_logging, log_level, progress_interval) + + def exit(self, force=False): + """Exit Mechanical. + + Parameters + ---------- + force : bool, optional + Whether to force Mechanical to exit. The default is ``False``, in which case + only Mechanical in UI mode asks for confirmation. This parameter overrides + any environment variables that may inhibit exiting Mechanical. + + Examples + -------- + Exit Mechanical. + + >>> mechanical.Exit(force=True) + + """ + if not force: + if not get_start_instance(): + self.log_info("Ignoring exit due to PYMECHANICAL_START_INSTANCE=False") + return + + # or building the gallery + if pymechanical.BUILDING_GALLERY: + self._log.info("Ignoring exit due to BUILDING_GALLERY=True") + return + + if self._exited: + return + + self.verify_valid_connection() + + self._exiting = True + + self.log_debug("In shutdown.") + request = mechanical_pb2.ShutdownRequest(force_exit=force) + self.log_debug("Shutting down...") + + self._busy = True + try: + self._stub.Shutdown(request) + except grpc._channel._InactiveRpcError as error: + self.log_warning("Mechanical exit failed: {str(error}.") + finally: + self._busy = False + + self._exited = True + self._stub = None + + if self._remote_instance is not None: # pragma: no cover + self.log_debug("PyPIM delete has started.") + try: + self._remote_instance.delete() + except Exception as error: + self.log_warning("Remote instance delete failed: {str(error}.") + self.log_debug("PyPIM delete has finished.") + + self._remote_instance = None + self._channel = None + else: + self.log_debug("No PyPIM cleanup is needed.") + + local_ports = pymechanical.LOCAL_PORTS + if self._local and self._port in local_ports: + local_ports.remove(self._port) + + self.log_info("Shutdown has finished.") + + @protect_grpc + def upload( + self, + file_name, + file_location_destination=None, + chunk_size=DEFAULT_FILE_CHUNK_SIZE, + progress_bar=True, + ): + """Upload a file to the Mechanical instance. + + Parameters + ---------- + file_name : str + Local file to upload. Only the file name is needed if the file + is relative to the current working directory. Otherwise, the full path + is needed. + file_location_destination : str, optional + File location on the Mechanical server to upload the file to. The default is + ``None``, in which case the project directory is used. + chunk_size : int, optional + Chunk size in bytes. The default is ``1048576``. + progress_bar : bool, optional + Whether to show a progress bar using ``tqdm``. The default is ``True``. + A progress bar is helpful for viewing upload progress. + + Returns + ------- + str + Base name of the uploaded file. + + Examples + -------- + Upload the ``hsec.x_t`` file with the progress bar not shown. + + >>> mechanical.upload('hsec.x_t', progress_bar=False) + """ + self.verify_valid_connection() + + if not os.path.isfile(file_name): + raise FileNotFoundError(f"Unable to locate filename {file_name}.") + + self._log.debug(f"Uploading file '{file_name}' to the Mechanical instance.") + + if file_location_destination is None: + file_location_destination = self.project_directory + + self._busy = True + try: + chunks_generator = self.get_file_chunks( + file_location_destination, + file_name, + chunk_size=chunk_size, + progress_bar=progress_bar, + ) + response = self._stub.UploadFile(chunks_generator) + self.log_debug(f"upload_file response is {response.is_ok}.") + finally: + self._busy = False + + if not response.is_ok: # pragma: no cover + raise IOError("File failed to upload.") + return os.path.basename(file_name) + + def get_file_chunks(self, file_location, file_name, chunk_size, progress_bar): + """Construct the file upload request for the server. + + Parameters + ---------- + file_location_destination : str, optional + Directory where the file to upload to the server is located. + file_name : str + Name of the file to upload. + chunk_size : int + Chunk size in bytes. + progress_bar : bool + Whether to show a progress bar using ``tqdm``. + """ + pbar = None + if progress_bar: + if not _HAS_TQDM: # pragma: no cover + raise ModuleNotFoundError( + f"To use the keyword argument 'progress_bar', you must have " + f"installed the 'tqdm' package. To avoid this message, you can " + f"set 'progress_bar=False'." + ) + + n_bytes = os.path.getsize(file_name) + + base_name = os.path.basename(file_name) + pbar = tqdm( + total=n_bytes, + desc=f"Uploading {base_name} to {self._channel_str}:{file_location}.", + unit="B", + unit_scale=True, + unit_divisor=1024, + ) + + with open(file_name, "rb") as f: + while True: + piece = f.read(chunk_size) + length = len(piece) + if length == 0: + if pbar is not None: + pbar.close() + return + + if pbar is not None: + pbar.update(length) + + chunk = mechanical_pb2.Chunk(payload=piece, size=length) + yield mechanical_pb2.FileUploadRequest( + file_name=os.path.basename(file_name), file_location=file_location, chunk=chunk + ) + + @property + def project_directory(self): + """Get the project directory for the currently connected Mechanical instance. + + Examples + -------- + Get the project directory of the connected Mechanical instance. + + >>> mechanical.project_directory + '/tmp/ANSYS.username.1/AnsysMech3F97/Project_Mech_Files/' + + """ + return self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + + def list_files(self): + """List the files in the working directory of Mechanical. + + Returns + ------- + list + List of files in the working directory of Mechanical. + + Examples + -------- + List the files in the working directory. + + >>> files = mechanical.list_files() + >>> for file in files: print(file) + """ + result = self.run_python_script( + "import pymechanical_helpers\npymechanical_helpers.GetAllProjectFiles(ExtAPI)" + ) + + files_out = result.splitlines() + if not files_out: # pragma: no cover + self.log_warning("No files listed") + return files_out + + def _get_files(self, files, recursive=False): + self_files = self.list_files() # to avoid calling it too much + + if isinstance(files, str): + if self._local: # pragma: no cover + # in local mode + if os.path.exists(files): + if not os.path.isabs(files): + list_files = [os.path.join(os.getcwd(), files)] + else: + # file exist + list_files = [files] + elif "*" in files: + # using filter + list_files = glob.glob(files, recursive=recursive) + if not list_files: + raise ValueError( + f"The `'files'` parameter ({files}) didn't match any file using " + f"glob expressions in the local client." + ) + else: + raise ValueError( + f"The files parameter ('{files}') does not match any file or pattern." + ) + else: # Remote or looking into Mechanical working directory + if files in self_files: + list_files = [files] + elif "*" in files: + # try filter on the list_files + if recursive: + self.log_warning( + "Because the 'recursive' keyword argument does not work with " + "remote instances, it is ignored." + ) + list_files = fnmatch.filter(self_files, files) + if not list_files: + raise ValueError( + f"The `'files'` parameter ({files}) didn't match any file using " + f"glob expressions in the remote server." + ) + else: + raise ValueError( + f"The `'files'` parameter ('{files}') does not match any file or pattern." + ) + + elif isinstance(files, (list, tuple)): + if not all([isinstance(each, str) for each in files]): + raise ValueError( + "The parameter `'files'` can be a list or tuple, but it " + "should only contain strings." + ) + list_files = files + else: + raise ValueError( + f"The `file` parameter type ({type(files)}) is not supported." + "Only strings, tuple of strings, or list of strings are allowed." + ) + + return list_files + + def download( + self, + files, + target_dir=None, + chunk_size=DEFAULT_CHUNK_SIZE, + progress_bar=None, + recursive=False, + ): # pragma: no cover + """Download files from the working directory of the Mechanical instance. + + It downloads them from the working directory to the target directory. It returns the list + of local file paths for the downloaded files. + + Parameters + ---------- + files : str, list[str], tuple(str) + One or more files on the Mechanical server to download. The files must be + in the same directory as the Mechanical instance. You can use the + :func:`Mechanical.list_files ` + function to list current files. Alternatively, you can specify *glob expressions* to + match file names. For example, you could use ``file*`` to match every file whose + name starts with ``file``. + target_dir: str + Default directory to copy the downloaded files to. The default is ``None`` and + current working directory will be used as target directory. + chunk_size : int, optional + Chunk size in bytes. The default is ``262144``. The value must be less than 4 MB. + progress_bar : bool, optional + Whether to show a progress bar using ``tqdm``. The default is ``None``, in + which case a progress bar is shown. A progress bar is helpful for viewing download + progress. + recursive : bool, optional + Whether to use recursion when using a glob pattern search. The default is ``False``. + + Returns + ------- + List[str] + List of local file paths. + + Notes + ----- + There are some considerations to keep in mind when using the ``download()`` method: + + * The glob pattern search does not search recursively in remote instances. + * In a remote instance, it is not possible to list or download files in a + location other than the Mechanical working directory. + * If you are connected to a local instance and provide a file path, downloading files + from a different folder is allowed but is not recommended. + + Examples + -------- + Download a single file. + + >>> local_file_path_list = mechanical.download('file.out') + + Download all files starting with ``file``. + + >>> local_file_path_list = mechanical.download('file*') + + Download every file in the Mechanical working directory. + + >>> local_file_path_list = mechanical.download('*.*') + + Alternatively, the recommended method is to use the + :func:`download_project() ` + method to download all files. + + >>> local_file_path_list = mechanical.download_project() + + """ + self.verify_valid_connection() + + if chunk_size > 4 * 1024 * 1024: # 4MB + raise ValueError( + "Chunk sizes bigger than 4 MB can generate unstable behaviour in PyMechanical. " + "Decrease the ``chunk_size`` value." + ) + + list_files = self._get_files(files, recursive=recursive) + + if target_dir: + path = pathlib.Path(target_dir) + path.mkdir(parents=True, exist_ok=True) + else: + target_dir = os.getcwd() + + out_files = [] + + for each_file in list_files: + try: + file_name = os.path.basename(each_file) # Getting only the name of the file. + # We try to avoid that when the full path is supplied. It crashes when trying + # to do `os.path.join(target_dir"os.getcwd()", file_name "full filename path"` + # This produces the file structure to flat out, but it is fine, + # because recursive does not work in remote. + self._busy = True + out_file_path = self._download( + each_file, + out_file_name=os.path.join(target_dir, file_name), + chunk_size=chunk_size, + progress_bar=progress_bar, + ) + out_files.append(out_file_path) + except FileNotFoundError: + # So far the gRPC interface returns the size of the file equal + # zero, if the file does not exist, or if its size is zero, + # but they are two different things. + # In theory, since we are obtaining the files name from + # `mechanical.list_files()`, they do exist, so + # if there is any error, it means their size is zero. + pass # This is not the best. + finally: + self._busy = False + + return out_files + + @protect_grpc + def _download( + self, + target_name, + out_file_name, + chunk_size=DEFAULT_CHUNK_SIZE, + progress_bar=None, + ): + """Download a file from the Mechanical instance. + + Parameters + ---------- + target_name : str + Name of the target file on the server. The file must be in the same + directory as the Mechanical instance. You can use the + ``mechanical.list_files()`` function to list current files. + out_file_name : str + Name of the output file if the name is to differ from that for the target + file. + chunk_size : int, optional + Chunk size in bytes. The default is ``"DEFAULT_CHUNK_SIZE"``, in which case + 256 kB is used. The value must be less than 4 MB. + progress_bar : bool, optional + Whether to show a progress bar using ``tqdm``. The default is ``None``, in + which case a progress bar is shown. A progress bar is helpful for showing download + progress. + + Examples + -------- + Download the remote result file "file.rst" as "my_result.rst". + + >>> mechanical.download('file.rst', 'my_result.rst') + """ + self.verify_valid_connection() + + if not progress_bar and _HAS_TQDM: + progress_bar = True + + request = mechanical_pb2.FileDownloadRequest(file_path=target_name, chunk_size=chunk_size) + + responses = self._stub.DownloadFile(request) + + file_size = self.save_chunks_to_file( + responses, out_file_name, progress_bar=progress_bar, target_name=target_name + ) + + if not file_size: # pragma: no cover + raise FileNotFoundError(f'File "{out_file_name}" is empty or does not exist') + + self.log_info(f"{out_file_name} with size {file_size} has been written.") + + return out_file_name + + def save_chunks_to_file(self, responses, filename, progress_bar=False, target_name=""): + """Save chunks to a local file. + + Parameters + ---------- + responses : + filename : str + Name of the local file to save chunks to. + progress_bar : bool, optional + Whether to show a progress bar using ``tqdm``. The default is ``False``. + target_name : str, optional + Name of the target file on the server. The default is ``""``. The file + must be in the same directory as the Mechanical instance. You can use the + ``mechanical.list_files()`` function to list current files. + + Returns + ------- + file_size : int + File size saved in bytes. If ``0`` is returned, no file was written. + """ + pbar = None + if progress_bar: + if not _HAS_TQDM: # pragma: no cover + raise ModuleNotFoundError( + "To use the keyword argument 'progress_bar', you need to have installed " + "the 'tqdm' package.To avoid this message you can set 'progress_bar=False'." + ) + + file_size = 0 + with open(filename, "wb") as f: + for response in responses: + f.write(response.chunk.payload) + payload_size = len(response.chunk.payload) + file_size += payload_size + if pbar is None: + pbar = tqdm( + total=response.file_size, + desc=f"Downloading {self._channel_str}:{target_name} to {filename}", + unit="B", + unit_scale=True, + unit_divisor=1024, + ) + pbar.update(payload_size) + else: + pbar.update(payload_size) + + if pbar is not None: + pbar.close() + + return file_size + + def download_project(self, extensions=None, target_dir=None, progress_bar=False): + """Download all project files in the working directory of the Mechanical instance. + + It downloads them from the working directory to the target directory. It returns the list + of local file paths for the downloaded files. + + Parameters + ---------- + extensions : list[str], tuple[str], optional + List of extensions for filtering files before downloading them. The + default is ``None``. + target_dir : str, optional + Path for downloading the files to. The default is ``None``. + progress_bar : bool, optional + Whether to show a progress bar using ``tqdm``. The default is ``False``. + A progress bar is helpful for viewing download progress. + + Returns + ------- + List[str] + List of local file paths. + + Examples + -------- + Download all the files in the project. + + >>> local_file_path_list = mechanical.download_project() + """ + destination_directory = target_dir.rstrip("\\/") + + # let us create the directory, if it doesn't exist + if destination_directory: + path = pathlib.Path(destination_directory) + path.mkdir(parents=True, exist_ok=True) + else: + destination_directory = os.getcwd() + + # relative directory? + if os.path.isdir(destination_directory): + if not os.path.isabs(destination_directory): + # construct full path + destination_directory = os.path.join(os.getcwd(), destination_directory) + + project_directory = self.project_directory + # remove the trailing slash - server could be windows or linux + project_directory = project_directory.rstrip("\\/") + + # this is where .mechddb resides + parent_directory = os.path.dirname(project_directory) + + list_of_files = [] + + if not extensions: + files = self.list_files() + else: + files = [] + for each_extension in extensions: + # mechdb resides one level above project directory + if "mechdb" == each_extension.lower(): + file_temp = os.path.join(parent_directory, f"*.{each_extension}") + else: + file_temp = os.path.join(project_directory, "**", f"*.{each_extension}") + + if self._local: + list_files_expanded = self._get_files(file_temp, recursive=True) + + if "mechdb" == each_extension.lower(): + # if we have more than one .mechdb in the parent folder + # filter to have only the current mechdb + self_files = self.list_files() + filtered_files = [] + for temp_file in list_files_expanded: + if temp_file in self_files: + filtered_files.append(temp_file) + list_files = filtered_files + else: + list_files = list_files_expanded + else: + list_files = self._get_files(file_temp, recursive=False) + + files.extend(list_files) + + for file in files: + # create similar hierarchy locally + new_path = file.replace(parent_directory, destination_directory) + new_path_dir = os.path.dirname(new_path) + temp_files = self.download( + files=file, target_dir=new_path_dir, progress_bar=progress_bar + ) + list_of_files.extend(temp_files) + + return list_of_files + + def clear(self): + """Clear the database. + + Examples + -------- + Clear the database. + + >>> mechanical.clear() + + """ + self.run_python_script("ExtAPI.DataModel.Project.New()") + + def _make_dummy_call(self): + try: + self._disable_logging = True + self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + except grpc.RpcError: # pragma: no cover + raise + finally: + self._disable_logging = False + + @staticmethod + def __readfile(file_path): + """Get the contents of the file as a string.""" + # open text file in read mode + text_file = open(file_path, "r", encoding="utf-8") + # read whole file to a string + data = text_file.read() + # close file + text_file.close() + + return data + + def __call_run_python_script( + self, script_code: str, enable_logging, log_level, progress_interval + ): + """Run the Python script block on the server. + + Parameters + ---------- + script_block : str + Script block (one or more lines) to run. + enable_logging: bool + Whether to enable logging + log_level: str + Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, + and ``"ERROR"``. + timeout: int, optional + Frequency in milliseconds for getting log messages from the server. + + Returns + ------- + str + Script result. + + """ + log_level_server = self.convert_to_server_log_level(log_level) + request = mechanical_pb2.RunScriptRequest() + request.script_code = script_code + request.enable_logging = enable_logging + request.logger_severity = log_level_server + request.progress_interval = progress_interval + + result = "" + self._busy = True + + try: + for runscript_response in self._stub.RunPythonScript(request): + if runscript_response.log_info == "__done__": + result = runscript_response.script_result + break + else: + if enable_logging: + self.log_message(log_level, runscript_response.log_info) + except grpc.RpcError as error: + error_info = error.details() + error_info_lower = error_info.lower() + # For the given script, return value cannot be converted to string. + if ( + "the expected result" in error_info_lower + and "cannot be return via this API." in error_info + ): + if enable_logging: + self.log_debug(f"Ignoring the conversion error.{error_info}") + result = "" + else: + raise + finally: + self._busy = False + + self._log_mechanical_script(script_code) + + return result + + def log_message(self, log_level, message): + """Log the message using the given log level. + + Parameters + ---------- + log_level: str + Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, + and ``"ERROR"``. + message : str + Message to log. + + Examples + -------- + Log a debug message. + + >>> mechanical.log_message('DEBUG', 'debug message') + + Log an info message. + + >>> mechanical.log_message('INFO', 'info message') + + """ + if log_level == "DEBUG": + self.log_debug(message) + elif log_level == "INFO": + self.log_info(message) + elif log_level == "WARNING": + self.log_warning(message) + elif log_level == "ERROR": + self.log_error(message) + + def log_debug(self, message): + """Log the debug message.""" + if self._disable_logging: + return + self._log.debug(message) + + def log_info(self, message): + """Log the info message.""" + if self._disable_logging: + return + self._log.info(message) + + def log_warning(self, message): + """Log the warning message.""" + if self._disable_logging: + return + self._log.warning(message) + + def log_error(self, message): + """Log the error message.""" + if self._disable_logging: + return + self._log.error(message) + + def verify_valid_connection(self): + """Verify whether the connection to Mechanical is valid.""" + if self._exited: + raise MechanicalExitedError("Mechanical has already exited.") + + if self._stub is None: # pragma: no cover + raise ValueError( + "There is not a valid connection to Mechanical. Launch or connect to it first." + ) + + @property + def exited(self): + """Whether Mechanical already exited.""" + return self._exited + + def _log_mechanical_script(self, script_code): + if self._disable_logging: + return + + if self._log_file_mechanical: + try: + with open(self._log_file_mechanical, "a", encoding="utf-8") as file: + file.write(script_code) + file.write("\n") + except IOError as e: # pragma: no cover + self.log_warning(f"I/O error({e.errno}): {e.strerror}") + except Exception as e: # pragma: no cover + self.log_warning("Unexpected error:" + str(e)) + + +def get_start_instance(start_instance_default=True): + """Check if the ``PYMECHANICAL_START_INSTANCE`` environment variable exists and is valid. + + Parameters + ---------- + start_instance_default : bool, optional + Value to return when ``PYMECHANICAL_START_INSTANCE`` is unset. + + Returns + ------- + bool + ``True`` when the ``PYMECHANICAL_START_INSTANCE`` environment variable exists + and is valid, ``False`` when this environment variable does not exist or is not valid. + If it is unset, ``start_instance_default`` is returned. + + Raises + ------ + OSError + Raised when ``PYMECHANICAL_START_INSTANCE`` is not either ``True`` or ``False`` + (case independent). + + """ + if "PYMECHANICAL_START_INSTANCE" in os.environ: + if os.environ["PYMECHANICAL_START_INSTANCE"].lower() not in [ + "true", + "false", + ]: # pragma: no cover + val = os.environ["PYMECHANICAL_START_INSTANCE"] + raise OSError( + f'Invalid value "{val}" for PYMECHANICAL_START_INSTANCE\n' + 'PYMECHANICAL_START_INSTANCE should be either "TRUE" or "FALSE"' + ) + return os.environ["PYMECHANICAL_START_INSTANCE"].lower() == "true" + return start_instance_default + + +def launch_grpc( + exec_file="", + batch=True, + port=MECHANICAL_DEFAULT_PORT, + additional_switches=None, + additional_envs=None, + verbose=False, +) -> int: + """Start Mechanical locally in gRPC mode. + + Parameters + ---------- + exec_file : str, optional + Path for the Mechanical executable file. The default is ``None``, in which + case the cached location is used. + batch : bool, optional + Whether to launch Mechanical in batch mode. The default is ``True``. + When ``False``, Mechanical is launched in UI mode. + port : int, optional + Port to launch the Mechanical instance on. The default is + ``MECHANICAL_DEFAULT_PORT``. The final port is the first + port available after (or including) this port. + additional_switches : list, optional + List of additional arguments to pass. The default is ``None``. + additional_envs : dictionary, optional + Dictionary of additional environment variables to pass. The default + is ``None``. + verbose : bool, optional + Whether to print all output when launching and running Mechanical. The + default is ``False``. Printing all output is not recommended unless + you are debugging the startup of Mechanical. + + Returns + ------- + int + Port number that the Mechanical instance started on. + + Notes + ----- + If ``PYMECHANICAL_START_INSTANCE`` is set to FALSE, the ``launch_mechanical`` + method looks for an existing instance of Mechanical at ``PYMECHANICAL_IP`` on port + ``PYMECHANICAL_PORT``, with default to ``127.0.0.1`` and ``10000`` if unset. + This is typically used for automated documentation and testing. + + Examples + -------- + Launch Mechanical using the default configuration. + + >>> from ansys.mechanical.core import launch_mechanical + >>> mechanical = launch_mechanical() + + Launch Mechanical using a specified executable file. + + >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe' + >>> mechanical = launch_mechanical(exec_file_path) + + """ + # verify version + if atp.version_from_path("mechanical", exec_file) < 232: + raise VersionError("The Mechanical gRPC interface requires Mechanical 2023 R2 or later.") + + # get the next available port + local_ports = pymechanical.LOCAL_PORTS + if port is None: + if not local_ports: + port = MECHANICAL_DEFAULT_PORT + else: + port = max(local_ports) + 1 + + while port_in_use(port) or port in local_ports: + port += 1 + local_ports.append(port) + + mechanical_launcher = MechanicalLauncher( + batch, port, exec_file, additional_switches, additional_envs, verbose + ) + mechanical_launcher.launch() + + return port + + +def launch_remote_mechanical(version=None) -> (grpc.Channel, Instance): # pragma: no cover + """Start Mechanical remotely using the Product Instance Management (PIM) API. + + When calling this method, you must ensure that you are in an environment + where PyPIM is configured. You can use the + :func:`pypim.is_configured ` + method to verify that PyPIM is configured. + + Parameters + ---------- + version : str, optional + Mechanical version to run in the three-digit format. For example, ``"251"`` to + run 2025 R1. The default is ``None``, in which case the server runs the latest + installed version. + + Returns + ------- + Tuple containing channel, remote_instance. + """ + pim = pypim.connect() + instance = pim.create_instance(product_name="mechanical", product_version=version) + + LOG.info("PyPIM wait for ready has started.") + instance.wait_for_ready() + LOG.info("PyPIM wait for ready has finished.") + + channel = instance.build_grpc_channel( + options=[ + ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH), + ] + ) + + return channel, instance + + +def launch_mechanical( + allow_input=True, + exec_file=None, + batch=True, + loglevel="ERROR", + log_file=False, + log_mechanical=None, + additional_switches=None, + additional_envs=None, + start_timeout=120, + port=None, + ip=None, + start_instance=None, + verbose_mechanical=False, + clear_on_connect=False, + cleanup_on_exit=True, + version=None, + keep_connection_alive=True, +) -> Mechanical: + """Start Mechanical locally. + + Parameters + ---------- + allow_input: bool, optional + Whether to allow user input when discovering the path to the Mechanical + executable file. + exec_file : str, optional + Path for the Mechanical executable file. The default is ``None``, + in which case the cached location is used. If PyPIM is configured + and this parameter is set to ``None``, PyPIM launches Mechanical + using its ``version`` parameter. + batch : bool, optional + Whether to launch Mechanical in batch mode. The default is ``True``. + When ``False``, Mechanical launches in UI mode. + loglevel : str, optional + Level of messages to print to the console. + Options are: + + - ``"WARNING"``: Prints only Ansys warning messages. + - ``"ERROR"``: Prints only Ansys error messages. + - ``"INFO"``: Prints all Ansys messages. + + The default is ``WARNING``. + log_file : bool, optional + Whether to copy the messages to a file named ``logs.log``, which is + located where the Python script is executed. The default is ``False``. + log_mechanical : str, optional + Path to the output file on the local disk to write every script + command to. The default is ``None``. However, you might set + ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are + sent to Mechanical via PyMechanical to this file. You can then use these + commands to run a script within Mechanical without PyMechanical. + additional_switches : list, optional + Additional switches for Mechanical. The default is ``None``. + additional_envs : dictionary, optional + Dictionary of additional environment variables to pass. The default + is ``None``. + start_timeout : float, optional + Maximum allowable time in seconds to connect to the Mechanical server. + The default is ``120``. + port : int, optional + Port to launch the Mechanical gRPC server on. The default is ``None``, + in which case ``10000`` is used. The final port is the first + port available after (or including) this port. You can override the + default behavior of this parameter with the + ``PYMECHANICAL_PORT=`` environment variable. + ip : str, optional + IP address to use only when ``start_instance`` is ``False``. The + default is ``None``, in which case ``"127.0.0.1"`` is used. If you + provide an IP address, ``start_instance`` is set to ``False``. + A host name can be provided as an alternative to an IP address. + start_instance : bool, optional + Whether to launch and connect to a new Mechanical instance. The default + is ``None``, in which case an attempt is made to connect to an existing + Mechanical instance at the given ``ip`` and ``port`` parameters, which have + defaults of ``"127.0.0.1"`` and ``10000`` respectively. When ``True``, + a local instance of Mechanical is launched. You can override the default + behavior of this parameter with the ``PYMECHANICAL_START_INSTANCE=FALSE`` + environment variable. + verbose_mechanical : bool, optional + Whether to enable printing of all output when launching and running + a Mechanical instance. The default is ``False``. This parameter should be + set to ``True`` for debugging only as output can be tracked within + PyMechanical. + clear_on_connect : bool, optional + When ``start_instance`` is ``False``, whether to clear the environment + when connecting to Mechanical. The default is ``False``. When ``True``, + a fresh environment is provided when you connect to Mechanical. + cleanup_on_exit : bool, optional + Whether to exit Mechanical when Python exits. The default is ``True``. + When ``False``, Mechanical is not exited when the garbage for this Mechanical + instance is collected. + version : str, optional + Mechanical version to run in the three-digit format. For example, ``"251"`` + for 2025 R1. The default is ``None``, in which case the server runs the + latest installed version. If PyPIM is configured and ``exce_file=None``, + PyPIM launches Mechanical using its ``version`` parameter. + keep_connection_alive : bool, optional + Whether to keep the gRPC connection alive by running a background thread + and making dummy calls for remote connections. The default is ``True``. + + Returns + ------- + ansys.mechanical.core.mechanical.Mechanical + Instance of Mechanical. + + Notes + ----- + If the environment is configured to use `PyPIM `_ + and ``start_instance=True``, then starting the instance is delegated to PyPIM. + In this case, most of the preceding parameters are ignored because the server-side + configuration is used. + + Examples + -------- + Launch Mechanical. + + >>> from ansys.mechanical.core import launch_mechanical + >>> mech = launch_mechanical() + + Launch Mechanical using a specified executable file. + + >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe' + >>> mech = launch_mechanical(exec_file_path) + + Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port + ``50001``. + + >>> mech = launch_mechanical(start_instance=False, ip='192.168.1.30', port=50001) + """ + # Start Mechanical with PyPIM if the environment is configured for it + # and a directive on how to launch Mechanical was not passed. + if pypim.is_configured() and exec_file is None: # pragma: no cover + LOG.info("Starting Mechanical remotely. The startup configuration will be ignored.") + channel, remote_instance = launch_remote_mechanical(version=version) + return Mechanical( + channel=channel, + remote_instance=remote_instance, + loglevel=loglevel, + log_file=log_file, + log_mechanical=log_mechanical, + timeout=start_timeout, + cleanup_on_exit=cleanup_on_exit, + keep_connection_alive=keep_connection_alive, + ) + + if ip is None: + ip = os.environ.get("PYMECHANICAL_IP", LOCALHOST) + else: # pragma: no cover + start_instance = False + ip = socket.gethostbyname(ip) # Converting ip or host name to ip + + check_valid_ip(ip) # double check + + if port is None: + port = int(os.environ.get("PYMECHANICAL_PORT", MECHANICAL_DEFAULT_PORT)) + check_valid_port(port) + + # connect to an existing instance if enabled + if start_instance is None: + start_instance = check_valid_start_instance( + os.environ.get("PYMECHANICAL_START_INSTANCE", True) + ) + + # special handling when building the gallery outside of CI. This + # creates an instance of Mechanical the first time if PYMECHANICAL_START_INSTANCE + # is False. + # when you launch, treat it as local. + # when you connect, treat it as remote. We cannot differentiate between + # local vs container scenarios. In the container scenarios, we could be connecting + # to a container using local ip and port + if pymechanical.BUILDING_GALLERY: # pragma: no cover + # launch an instance of PyMechanical if it does not already exist and + # starting instances is allowed + if start_instance and GALLERY_INSTANCE[0] is None: + mechanical = launch_mechanical( + start_instance=True, + cleanup_on_exit=False, + loglevel=loglevel, + ) + GALLERY_INSTANCE[0] = {"ip": mechanical._ip, "port": mechanical._port} + return mechanical + + # otherwise, connect to the existing gallery instance if available + elif GALLERY_INSTANCE[0] is not None: + mechanical = Mechanical( + ip=GALLERY_INSTANCE[0]["ip"], + port=GALLERY_INSTANCE[0]["port"], + cleanup_on_exit=False, + loglevel=loglevel, + local=False, + ) + # we are connecting to the existing gallery instance, + # we need to clear Mechanical. + mechanical.clear() + + return mechanical + + # finally, if running on CI/CD, connect to the default instance + else: + mechanical = Mechanical( + ip=ip, port=port, cleanup_on_exit=False, loglevel=loglevel, local=False + ) + # we are connecting for gallery generation, + # we need to clear Mechanical. + mechanical.clear() + return mechanical + + if not start_instance: + mechanical = Mechanical( + ip=ip, + port=port, + loglevel=loglevel, + log_file=log_file, + log_mechanical=log_mechanical, + timeout=start_timeout, + cleanup_on_exit=cleanup_on_exit, + keep_connection_alive=keep_connection_alive, + local=False, + ) + if clear_on_connect: + mechanical.clear() + + # setting ip for the grpc server + if ip != LOCALHOST: # Default local ip is 127.0.0.1 + create_ip_file(ip, os.getcwd()) + + return mechanical + + # verify executable + if exec_file is None: + exec_file = get_mechanical_path(allow_input) + if exec_file is None: # pragma: no cover + raise FileNotFoundError( + "Path to the Mechanical executable file is invalid or cache cannot be loaded. " + "Enter a path manually by specifying a value for the " + "'exec_file' parameter." + ) + else: # verify ansys exists at this location + if not os.path.isfile(exec_file): + raise FileNotFoundError( + f'This path for the Mechanical executable is invalid: "{exec_file}"\n' + "Enter a path manually by specifying a value for the " + "'exec_file' parameter." + ) + + start_parm = { + "exec_file": exec_file, + "batch": batch, + "additional_switches": additional_switches, + "additional_envs": additional_envs, + } + + try: + port = launch_grpc(port=port, verbose=verbose_mechanical, **start_parm) + start_parm["local"] = True + mechanical = Mechanical( + ip=ip, + port=port, + loglevel=loglevel, + log_file=log_file, + log_mechanical=log_mechanical, + timeout=start_timeout, + cleanup_on_exit=cleanup_on_exit, + keep_connection_alive=keep_connection_alive, + **start_parm, + ) + except Exception as exception: # pragma: no cover + # pass + raise exception + + return mechanical + + +def connect_to_mechanical( + ip=None, + port=None, + loglevel="ERROR", + log_file=False, + log_mechanical=None, + connect_timeout=120, + clear_on_connect=False, + cleanup_on_exit=False, + keep_connection_alive=True, +) -> Mechanical: + """Connect to an existing Mechanical server instance. + + Parameters + ---------- + ip : str, optional + IP address for connecting to an existing Mechanical instance. The + IP address defaults to ``"127.0.0.1"``. + port : int, optional + Port to listen on for an existing Mechanical instance. The default is ``None``, + in which case ``10000`` is used. You can override the + default behavior of this parameter with the + ``PYMECHANICAL_PORT=`` environment variable. + loglevel : str, optional + Level of messages to print to the console. + Options are: + + - ``"WARNING"``: Prints only Ansys warning messages. + - ``"ERROR"``: Prints only Ansys error messages. + - ``"INFO"``: Prints all Ansys messages. + + The default is ``WARNING``. + log_file : bool, optional + Whether to copy the messages to a file named ``logs.log``, which is + located where the Python script is executed. The default is ``False``. + log_mechanical : str, optional + Path to the output file on the local disk to write every script + command to. The default is ``None``. However, you might set + ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are + sent to Mechanical via PyMechanical to this file. You can then use these + commands to run a script within Mechanical without PyMechanical. + connect_timeout : float, optional + Maximum allowable time in seconds to connect to the Mechanical server. + The default is ``120``. + clear_on_connect : bool, optional + Whether to clear the Mechanical instance when connecting. The default is ``False``. + When ``True``, a fresh environment is provided when you connect to Mechanical. + cleanup_on_exit : bool, optional + Whether to exit Mechanical when Python exits. The default is ``False``. + When ``False``, Mechanical is not exited when the garbage for this Mechanical + instance is collected. + keep_connection_alive : bool, optional + Whether to keep the gRPC connection alive by running a background thread + and making dummy calls for remote connections. The default is ``True``. + + Returns + ------- + ansys.mechanical.core.mechanical.Mechanical + Instance of Mechanical. + + Examples + -------- + Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port + ``50001``.. + + + >>> from ansys.mechanical.core import connect_to_mechanical + >>> pymech = connect_to_mechanical(ip='192.168.1.30', port=50001) + """ + return launch_mechanical( + start_instance=False, + loglevel=loglevel, + log_file=log_file, + log_mechanical=log_mechanical, + start_timeout=connect_timeout, + port=port, + ip=ip, + clear_on_connect=clear_on_connect, + cleanup_on_exit=cleanup_on_exit, + keep_connection_alive=keep_connection_alive, + ) diff --git a/src/ansys/mechanical/core/misc.py b/src/ansys/mechanical/core/misc.py index a388d11d9..37f18e284 100644 --- a/src/ansys/mechanical/core/misc.py +++ b/src/ansys/mechanical/core/misc.py @@ -1,176 +1,176 @@ -# 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. - -"""Contain miscellaneous functions and methods at the module level.""" - -from functools import wraps -import os -import socket -from threading import Thread - - -def is_windows(): - """Check if the host machine is on Windows. - - Returns - ------- - ``True`` if the host machine is on Windows, ``False`` otherwise. - """ - if os.name == "nt": # pragma: no cover - return True - - return False - - -def get_mechanical_bin(release_version): - """Get the path for the Mechanical executable file based on the release version. - - Parameters - ---------- - release_version: str - Mechanical version using the three-digit format. For example, ``"242"`` for - 2024 R2. - """ - if is_windows(): # pragma: no cover - program_files = os.getenv("PROGRAMFILES", os.path.join("c:\\", "Program Files")) - ans_root = os.getenv( - f"AWP_ROOT{release_version}", - os.path.join(program_files, "ANSYS Inc", f"v{release_version}"), - ) - mechanical_bin = os.path.join(ans_root, "aisol", "bin", "winx64", f"AnsysWBU.exe") - else: - ans_root = os.getenv(f"AWP_ROOT{release_version}", os.path.join("/", "usr", "ansys_inc")) - mechanical_bin = os.path.join(*ans_root, f"v{release_version}", "aisol", f".workbench") - - return mechanical_bin - - -def threaded(func): - """Decorate a function with this decorator to call it using a thread.""" - - @wraps(func) - def wrapper(*args, **kwargs): - name = kwargs.get("name", f"Threaded `{func.__name__}` function") - thread = Thread(target=func, name=name, args=args, kwargs=kwargs) - thread.start() - return thread - - return wrapper - - -def threaded_daemon(func): - """Decorate a function with this decorator to call it using a daemon thread.""" - - @wraps(func) - def wrapper(*args, **kwargs): - name = kwargs.get("name", f"Threaded (with Daemon) `{func.__name__}` function") - thread = Thread(target=func, name=name, args=args, kwargs=kwargs) - thread.daemon = True - thread.start() - return thread - - return wrapper - - -def check_valid_ip(ip): - """Check if the IP address is valid. - - Parameters - ---------- - ip : str - IP address to check. - - """ - if ip.lower() != "localhost": - ip = ip.replace('"', "").replace("'", "") - socket.inet_aton(ip) - - -def check_valid_port(port, lower_bound=1000, high_bound=60000): - """Check if the port is valid. - - Parameters - ---------- - port : int - Port to check. - lower_bound : int, optional - Lowest possible value for the port. The default is ``1000``. - high_bound : int, optional - Highest possible value for the port. The default is ``60000``. - """ - if not isinstance(port, int): - raise ValueError("The 'port' parameter must be an integer.") - - if lower_bound < port < high_bound: - return - else: - raise ValueError(f"'port' values must be between {lower_bound} and {high_bound}.") - - -def check_valid_start_instance(start_instance): - """ - Check if the value obtained from the environmental variable is valid. - - Parameters - ---------- - start_instance : str, bool - Value obtained from the corresponding environment variable. - - Returns - ------- - bool - ``True`` if ``start_instance`` is ``True`` or ``"True"``, - ``False`` otherwise. - - """ - if not isinstance(start_instance, (str, bool)): - raise ValueError("The value for 'start_instance' should be a string or a boolean.") - - if isinstance(start_instance, bool): - return start_instance - - if start_instance.lower() not in ["true", "false"]: - raise ValueError( - "The value for 'start_instance' should be 'True' or 'False' (case insensitive)." - ) - - return start_instance.lower() == "true" - - -def is_float(input_string): - """Check if a string can be converted to a float. - - Parameters - ---------- - input_string : str - String to check. - - Returns - ------- - bool - ``True`` when conversion is possible, ``False`` otherwise. - """ - try: - float(input_string) - return True - except ValueError: - return False +# 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. + +"""Contain miscellaneous functions and methods at the module level.""" + +from functools import wraps +import os +import socket +from threading import Thread + + +def is_windows(): + """Check if the host machine is on Windows. + + Returns + ------- + ``True`` if the host machine is on Windows, ``False`` otherwise. + """ + if os.name == "nt": # pragma: no cover + return True + + return False + + +def get_mechanical_bin(release_version): + """Get the path for the Mechanical executable file based on the release version. + + Parameters + ---------- + release_version: str + Mechanical version using the three-digit format. For example, ``"251"`` for + 2025 R1. + """ + if is_windows(): # pragma: no cover + program_files = os.getenv("PROGRAMFILES", os.path.join("c:\\", "Program Files")) + ans_root = os.getenv( + f"AWP_ROOT{release_version}", + os.path.join(program_files, "ANSYS Inc", f"v{release_version}"), + ) + mechanical_bin = os.path.join(ans_root, "aisol", "bin", "winx64", f"AnsysWBU.exe") + else: + ans_root = os.getenv(f"AWP_ROOT{release_version}", os.path.join("/", "usr", "ansys_inc")) + mechanical_bin = os.path.join(*ans_root, f"v{release_version}", "aisol", f".workbench") + + return mechanical_bin + + +def threaded(func): + """Decorate a function with this decorator to call it using a thread.""" + + @wraps(func) + def wrapper(*args, **kwargs): + name = kwargs.get("name", f"Threaded `{func.__name__}` function") + thread = Thread(target=func, name=name, args=args, kwargs=kwargs) + thread.start() + return thread + + return wrapper + + +def threaded_daemon(func): + """Decorate a function with this decorator to call it using a daemon thread.""" + + @wraps(func) + def wrapper(*args, **kwargs): + name = kwargs.get("name", f"Threaded (with Daemon) `{func.__name__}` function") + thread = Thread(target=func, name=name, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + + return wrapper + + +def check_valid_ip(ip): + """Check if the IP address is valid. + + Parameters + ---------- + ip : str + IP address to check. + + """ + if ip.lower() != "localhost": + ip = ip.replace('"', "").replace("'", "") + socket.inet_aton(ip) + + +def check_valid_port(port, lower_bound=1000, high_bound=60000): + """Check if the port is valid. + + Parameters + ---------- + port : int + Port to check. + lower_bound : int, optional + Lowest possible value for the port. The default is ``1000``. + high_bound : int, optional + Highest possible value for the port. The default is ``60000``. + """ + if not isinstance(port, int): + raise ValueError("The 'port' parameter must be an integer.") + + if lower_bound < port < high_bound: + return + else: + raise ValueError(f"'port' values must be between {lower_bound} and {high_bound}.") + + +def check_valid_start_instance(start_instance): + """ + Check if the value obtained from the environmental variable is valid. + + Parameters + ---------- + start_instance : str, bool + Value obtained from the corresponding environment variable. + + Returns + ------- + bool + ``True`` if ``start_instance`` is ``True`` or ``"True"``, + ``False`` otherwise. + + """ + if not isinstance(start_instance, (str, bool)): + raise ValueError("The value for 'start_instance' should be a string or a boolean.") + + if isinstance(start_instance, bool): + return start_instance + + if start_instance.lower() not in ["true", "false"]: + raise ValueError( + "The value for 'start_instance' should be 'True' or 'False' (case insensitive)." + ) + + return start_instance.lower() == "true" + + +def is_float(input_string): + """Check if a string can be converted to a float. + + Parameters + ---------- + input_string : str + String to check. + + Returns + ------- + bool + ``True`` when conversion is possible, ``False`` otherwise. + """ + try: + float(input_string) + return True + except ValueError: + return False diff --git a/src/ansys/mechanical/core/pool.py b/src/ansys/mechanical/core/pool.py index e4d480889..fe0e09930 100644 --- a/src/ansys/mechanical/core/pool.py +++ b/src/ansys/mechanical/core/pool.py @@ -1,712 +1,712 @@ -# 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. - -"""This module is for threaded implementations of the Mechanical interface.""" - -import os -import time -import warnings - -import ansys.platform.instancemanagement as pypim -from ansys.tools.path import version_from_path - -from ansys.mechanical.core.errors import VersionError -from ansys.mechanical.core.mechanical import ( - _HAS_TQDM, - LOG, - MECHANICAL_DEFAULT_PORT, - get_mechanical_path, - launch_mechanical, - port_in_use, -) -from ansys.mechanical.core.misc import threaded, threaded_daemon - -if _HAS_TQDM: - from tqdm import tqdm - - -def available_ports(n_ports, starting_port=MECHANICAL_DEFAULT_PORT): - """Get a list of a given number of available ports starting from a specified port number. - - Parameters - ---------- - n_ports : int - Number of available ports to return. - starting_port: int, option - Number of the port to start the search from. The default is - ``MECHANICAL_DEFAULT_PORT``. - """ - port = starting_port - ports = [] - while port < 65536 and len(ports) < n_ports: - if not port_in_use(port): - ports.append(port) - port += 1 - - if len(ports) < n_ports: - raise RuntimeError( - f"There are not {n_ports} available ports between {starting_port} and 65536." - ) - - return ports - - -class LocalMechanicalPool: - """Create a pool of Mechanical instances. - - Parameters - ---------- - n_instance : int - Number of Mechanical instances to create in the pool. - wait : bool, optional - Whether to wait for the pool to be initialized. The default is - ``True``. When ``False``, the pool starts in the background, in - which case all resources might not be immediately available. - starting_port : int, optional - Starting port for the instances. The default is ``10000``. - progress_bar : bool, optional - Whether to show a progress bar when starting the pool. The default - is ``True``, but the progress bar is not shown when ``wait=False``. - restart_failed : bool, optional - Whether to restart any failed instances in the pool. The default is - ``True``. - **kwargs : dict, optional - Additional keyword arguments. For a list of all keyword - arguments, use the :func:`ansys.mechanical.core.launch_mechanical` - function. If the ``exec_file`` keyword argument is found, it is used to - start instances. PyPIM is used to create instances if the following - conditions are met: - - - PyPIM is configured. - - ``version`` is specified. - - ``exec_file`` is not specified. - - - Examples - -------- - Create a pool of 10 Mechanical instances. - - >>> from ansys.mechanical.core import LocalMechanicalPool - >>> pool = LocalMechanicalPool(10) - Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] - - On Windows, create a pool while specifying the Mechanical executable file. - - >>> exec_file = 'C:/Program Files/ANSYS Inc/v242/aisol/bin/winx64/AnsysWBU.exe' - >>> pool = LocalMechanicalPool(10, exec_file=exec_file) - Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] - - On Linux, create a pool while specifying the Mechanical executable file. - - >>> exec_file = '/ansys_inc/v242/aisol/.workbench' - >>> pool = LocalMechanicalPool(10, exec_file=exec_file) - Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] - - In the PyPIM environment, create a pool. - - >>> pool = LocalMechanicalPool(10, version="242") - Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] - - """ - - def __init__( - self, - n_instances, - wait=True, - port=MECHANICAL_DEFAULT_PORT, - progress_bar=True, - restart_failed=True, - **kwargs, - ): - """Initialize several Mechanical instances. - - Parameters - ---------- - n_instance : int - Number of Mechanical instances to initialize. - wait : bool, optional - Whether to wait for the instances to be initialized. The default is - ``True``. When ``False``, the instances start in the background, in - which case all resources might not be immediately available. - port : int, optional - Port for the first Mechanical instance. The default is - ``MECHANICAL_DEFAULT_PORT``. - progress_bar : bool, optional - Whether to display a progress bar when starting the instances. The default - is ``True``, but the progress bar is not shown when ``wait=False``. - restart_failed : bool, optional - Whether to restart any failed instances. The default is ``True``. - **kwargs : dict, optional - Additional keyword arguments. For a list of all additional keyword - arguments, see the :func:`ansys.mechanical.core.launch_mechanical` - function. If the ``exec_file`` keyword argument is found, it is used to - start instances. Instances are created using PyPIM if the following - conditions are met: - - - PyPIM is configured. - - Version is specified/ - - ``exec_file`` is not specified. - """ - self._instances = [] - self._spawn_kwargs = kwargs - self._remote = False - - # verify that mechanical is 2023R2 or newer - exec_file = None - if "exec_file" in kwargs: - exec_file = kwargs["exec_file"] - else: - if pypim.is_configured(): # pragma: no cover - if "version" in kwargs: - version = kwargs["version"] - self._remote = True - else: - raise ValueError("Pypim is configured, but version is not passed.") - else: # get default executable - exec_file = get_mechanical_path() - if exec_file is None: # pragma: no cover - raise FileNotFoundError( - "Path to Mechanical executable file is invalid or cache cannot be loaded. " - "Enter a path manually by specifying a value for the " - "'exec_file' parameter." - ) - - if not self._remote: # pragma: no cover - if version_from_path("mechanical", exec_file) < 232: - raise VersionError("A local Mechanical pool requires Mechanical 2023 R2 or later.") - - ports = None - - if not self._remote: - # grab available ports - ports = available_ports(n_instances, port) - - self._instances = [] - self._active = True # used by pool monitor - - n_instances = int(n_instances) - if n_instances < 2: - raise ValueError("You must request at least two instances to create a pool.") - - pbar = None - if wait and progress_bar: - if not _HAS_TQDM: # pragma: no cover - raise ModuleNotFoundError( - f"To use the keyword argument 'progress_bar', you must have installed " - f"the 'tqdm' package. To avoid this message, you can set 'progress_bar=False'." - ) - - pbar = tqdm(total=n_instances, desc="Creating Pool") - - # initialize a list of dummy instances - self._instances = [None for _ in range(n_instances)] - - if self._remote: # pragma: no cover - threads = [ - self._spawn_mechanical_remote(i, pbar, name=f"Instance {i}") - for i in range(n_instances) - ] - else: - # threaded spawn - threads = [ - self._spawn_mechanical(i, ports[i], pbar, name=f"Instance {i}") - for i in range(n_instances) - ] - if wait: - [thread.join() for thread in threads] - - # check if all clients connected have connected - if len(self) != n_instances: # pragma: no cover - n_connected = len(self) - warnings.warn( - f"Only {n_connected} clients connected out of {n_instances} requested" - ) - if pbar is not None: - pbar.close() - - # monitor pool if requested - if restart_failed: - self._pool_monitor_thread = self._monitor_pool(name="Monitoring_Thread started") - - if not self._remote: - self._verify_unique_ports() - - def _verify_unique_ports(self): - if self._remote: # pragma: no cover - raise RuntimeError("PyPIM is used. Port information is not available.") - - if len(self.ports) != len(self): # pragma: no cover - raise RuntimeError("Mechanical pool has overlapping ports.") - - def map( - self, - func, - iterable=None, - clear_at_start=True, - progress_bar=True, - close_when_finished=False, - timeout=None, - wait=True, - ): - """Run a user-defined function on each Mechanical instance in the pool. - - Parameters - ---------- - func : function - Function with ``mechanical`` as the first argument. The subsequent - arguments should match the number of items in each iterable (if any). - iterable : list, tuple, optional - An iterable containing a set of arguments for the function. - The default is ``None``, in which case the function runs - once on each instance of Mechanical. - clear_at_start : bool, optional - Clear Mechanical at the start of execution. The default is - ``True``. Setting this to ``False`` might lead to instability. - progress_bar : bool, optional - Whether to show a progress bar when running the batch of input - files. The default is ``True``, but the progress bar is not shown - when ``wait=False``. - close_when_finished : bool, optional - Whether to close the instances when the function finishes running - on all instances in the pool. The default is ``False``. - timeout : float, optional - Maximum runtime in seconds for each iteration. The default is - ``None``, in which case there is no timeout. If you specify a - value, each iteration is allowed to run only this number of - seconds. Once this value is exceeded, the batch process is - stopped and treated as a failure. - wait : bool, optional - Whether block execution must wait until the batch process is - complete. The default is ``True``. - - Returns - ------- - list - A list containing the return values for the function. - Failed runs do not return an output. Because return values - are not necessarily in the same order as the iterable, - you might want to add some sort of tracker to the return - of your function. - - Examples - -------- - Run several input files while storing the final routine. Note - how the function to map must use ``mechanical`` as the first argument. - The function can have any number of additional arguments. - - >>> from ansys.mechanical.core import LocalMechanicalPool - >>> pool = LocalMechanicalPool(10) - >>> completed_indices = [] - >>> def function(mechanical, name, script): - # name, script = args - mechanical.clear() - output = mechanical.run_python_script(script) - return name, output - >>> inputs = [("first","2+3"), ("second", "3+4")] - >>> output = pool.map(function, inputs, progress_bar=False, wait=True) - [('first', '5'), ('second', '7')] - """ - # check if any instances are available - if not len(self): # pragma: no cover - # instances could still be spawning... - if not all(v is None for v in self._instances): - raise RuntimeError("No Mechanical instances available.") - - results = [] - - if iterable is not None: - jobs_count = len(iterable) - else: - jobs_count = len(self) - - pbar = None - if progress_bar: - if not _HAS_TQDM: # pragma: no cover - raise ModuleNotFoundError( - "To use the keyword argument 'progress_bar', you must have installed " - "the 'tqdm' package. To avoid this message, you can set 'progress_bar=False'." - ) - - pbar = tqdm(total=jobs_count, desc="Mechanical Running") - - @threaded_daemon - def func_wrapper(obj, func, clear_at_start, timeout, args=None, name=""): - """Expect obj to be an instance of Mechanical.""" - LOG.debug(name) - complete = [False] - - @threaded_daemon - def run(name_local=""): - LOG.debug(name_local) - - if clear_at_start: - obj.clear() - - if args is not None: - if isinstance(args, (tuple, list)): - results.append(func(obj, *args)) - else: - results.append(func(obj, args)) - else: - results.append(func(obj)) - - complete[0] = True - - run_thread = run(name_local=name) - - if timeout: # pragma: no cover - time_start = time.time() - while not complete[0]: - time.sleep(0.01) - if (time.time() - time_start) > timeout: - break - - if not complete[0]: - LOG.error(f"Stopped instance due to a timeout of {timeout} seconds.") - obj.exit() - else: - run_thread.join() - if not complete[0]: # pragma: no cover - LOG.error("Stopped instance because running failed.") - try: - obj.exit() - except Exception as e: - LOG.error(f"Unexpected error while exiting: {e}") - - obj.locked = False - if pbar: - pbar.update(1) - - threads = [] - if iterable is not None: - for args in iterable: - # grab the next available instance of mechanical - instance, i = self.next_available(return_index=True) - instance.locked = True - - threads.append( - func_wrapper( - instance, func, clear_at_start, timeout, args, name=f"Map_Thread{i}" - ) - ) - else: # simply apply to all - for instance in self._instances: - if instance: - threads.append( - func_wrapper(instance, func, clear_at_start, timeout, name=f"Map_Thread") - ) - - if close_when_finished: # pragma: no cover - # start closing any instances that are not in execution - while not all(v is None for v in self._instances): - # grab the next available instance of mechanical and close it - instance, i = self.next_available(return_index=True) - self._instances[i] = None - - try: - instance.exit() - except Exception as error: # pragma: no cover - LOG.error(f"Failed to close instance : str{error}.") - else: - # wait for all threads to complete - if wait: - [thread.join() for thread in threads] - - return results - - def run_batch( - self, - files, - clear_at_start=True, - progress_bar=True, - close_when_finished=False, - timeout=None, - wait=True, - ): - """Run a batch of input files on the Mechanical instances in the pool. - - Parameters - ---------- - files : list - List of input files. - clear_at_start : bool, optional - Whether to clear Mechanical when execution starts. The default is - ``True``. Setting this parameter to ``False`` might lead to - instability. - progress_bar : bool, optional - Whether to show a progress bar when running the batch of input - files. The default is ``True``, but the progress bar is not shown - when ``wait=False``. - close_when_finished : bool, optional - Whether to close the instances when running the batch - of input files is finished. The default is ``False``. - timeout : float, optional - Maximum runtime in seconds for each iteration. The default is - ``None``, in which case there is no timeout. If you specify a - value, each iteration is allowed to run only this number of - seconds. Once this value is exceeded, the batch process is stopped - and treated as a failure. - wait : bool, optional - Whether block execution must wait until the batch process is complete. - The default is ``True``. - - Returns - ------- - list - List of text outputs from Mechanical for each batch run. The outputs - are not necessarily listed in the order of the inputs. Failed runs do - not return an output. Because the return outputs are not - necessarily in the same order as ``iterable``, you might - want to add some sort of tracker or note within the input files. - - Examples - -------- - Run 20 verification files on the pool. - - >>> files = [f"test{index}.py" for index in range(1, 21)] - >>> outputs = pool.run_batch(files) - >>> len(outputs) - 20 - """ - # check all files exist before running - for filename in files: - if not os.path.isfile(filename): - raise FileNotFoundError("Unable to locate file %s" % filename) - - def run_file(mechanical, input_file): - if clear_at_start: - mechanical.clear() - return mechanical.run_python_script_from_file(input_file) - - return self.map( - run_file, - files, - progress_bar=progress_bar, - close_when_finished=close_when_finished, - timeout=timeout, - wait=wait, - ) - - def next_available(self, return_index=False): - """Wait until a Mechanical instance is available and return this instance. - - Parameters - ---------- - return_index : bool, optional - Whether to return the index along with the instance. The default - is ``False``. - - Returns - ------- - pymechanical.Mechanical - Instance of Mechanical. - - int - Index within the pool of Mechanical instances. This index - is not returned by default. - - Examples - -------- - >>> mechanical = pool.next_available() - >>> mechanical - Ansys Mechanical [Ansys Mechanical Enterprise] - Product Version:242 - Software build date: 06/03/2024 14:47:58 - """ - # loop until the next instance is available - while True: - for i, instance in enumerate(self._instances): - # if encounter placeholder - if not instance: # pragma: no cover - continue - - if not instance.locked and not instance._exited: - # any instance that is not running or exited - # should be available - if not instance.busy: - # double check that this instance is alive: - try: - instance._make_dummy_call() - except: # pragma: no cover - instance.exit() - continue - - if return_index: - return instance, i - else: - return instance - # review - not needed - # else: - # instance._exited = True - - def __del__(self): - """Clean up when complete.""" - print("pool:Automatic clean up.") - self.exit() - - def exit(self, block=False): - """Exit all Mechanical instances in the pool. - - Parameters - ---------- - block : bool, optional - Whether to wait until all processes close before exiting - all instances in the pool. The default is ``False``. - - Examples - -------- - >>> pool.exit() - """ - self._active = False # Stops any active instance restart - - @threaded - def threaded_exit(index, instance_local): - if instance_local: - try: - instance_local.exit() - except Exception as e: # pragma: no cover - LOG.error(f"Error while exiting instance {str(instance_local)}: {str(e)}") - self._instances[index] = None - LOG.debug(f"Exited instance: {str(instance_local)}") - - threads = [] - for i, instance in enumerate(self): - threads.append(threaded_exit(i, instance)) - - if block: - [thread.join() for thread in threads] - - def __len__(self): - """Get the number of instances in the pool.""" - count = 0 - for instance in self._instances: - if instance: - if not instance._exited: - count += 1 - return count - - def __getitem__(self, key): - """Get an instance by an index.""" - return self._instances[key] - - def __iter__(self): - """Iterate through active instances.""" - for instance in self._instances: - if instance: - yield instance - - @threaded_daemon - def _spawn_mechanical(self, index, port=None, pbar=None, name=""): - """Spawn a Mechanical instance at an index. - - Parameters - ---------- - index : int - Index to spawn the instance on. - port : int, optional - Port for the instance. The default is ``None``. - pbar : - The default is ``None``. - name : str, optional - Name for the instance. The default is ``""``. - """ - LOG.debug(name) - self._instances[index] = launch_mechanical(port=port, **self._spawn_kwargs) - # LOG.debug("Spawned instance %d. Name '%s'", index, name) - if pbar is not None: - pbar.update(1) - - @threaded_daemon - def _spawn_mechanical_remote(self, index, pbar=None, name=""): # pragma: no cover - """Spawn a Mechanical instance at an index. - - Parameters - ---------- - index : int - Index to spawn the instance on. - pbar : - The default is ``None``. - name : str, optional - Name for the instance. The default is ``""``. - - """ - LOG.debug(name) - self._instances[index] = launch_mechanical(**self._spawn_kwargs) - # LOG.debug("Spawned instance %d. Name '%s'", index, name) - if pbar is not None: - pbar.update(1) - - @threaded_daemon - def _monitor_pool(self, refresh=1.0, name=""): - """Check for instances within a pool that have exited (failed) and restart them. - - Parameters - ---------- - refresh : float, optional - The default is ``1.0``. - name : str, optional - Name for the instance. The default is ``""``. - """ - LOG.debug(name) - while self._active: - for index, instance in enumerate(self._instances): - # encountered placeholder - if not instance: # pragma: no cover - continue - if instance._exited: # pragma: no cover - try: - if self._remote: - LOG.debug( - f"Restarting a Mechanical remote instance for index : {index}." - ) - self._spawn_mechanical_remote(index, name=f"Instance {index}").join() - else: - # use the next port after the current available port - port = max(self.ports) + 1 - LOG.debug( - f"Restarting a Mechanical instance for index : " - f"{index} on port: {port}." - ) - self._spawn_mechanical( - index, port=port, name=f"Instance {index}" - ).join() - except Exception as e: - LOG.error(e, exc_info=True) - time.sleep(refresh) - - @property - def ports(self): - """Get a list of the ports that are used. - - Examples - -------- - Get the list of ports used by the pool of Mechanical instances. - - >>> pool.ports - [10001, 10002] - - """ - return [inst._port for inst in self if inst is not None] - - def __str__(self): - """Get the string representation of this object.""" - return "Mechanical pool with %d active instances" % len(self) +# 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. + +"""This module is for threaded implementations of the Mechanical interface.""" + +import os +import time +import warnings + +import ansys.platform.instancemanagement as pypim +from ansys.tools.path import version_from_path + +from ansys.mechanical.core.errors import VersionError +from ansys.mechanical.core.mechanical import ( + _HAS_TQDM, + LOG, + MECHANICAL_DEFAULT_PORT, + get_mechanical_path, + launch_mechanical, + port_in_use, +) +from ansys.mechanical.core.misc import threaded, threaded_daemon + +if _HAS_TQDM: + from tqdm import tqdm + + +def available_ports(n_ports, starting_port=MECHANICAL_DEFAULT_PORT): + """Get a list of a given number of available ports starting from a specified port number. + + Parameters + ---------- + n_ports : int + Number of available ports to return. + starting_port: int, option + Number of the port to start the search from. The default is + ``MECHANICAL_DEFAULT_PORT``. + """ + port = starting_port + ports = [] + while port < 65536 and len(ports) < n_ports: + if not port_in_use(port): + ports.append(port) + port += 1 + + if len(ports) < n_ports: + raise RuntimeError( + f"There are not {n_ports} available ports between {starting_port} and 65536." + ) + + return ports + + +class LocalMechanicalPool: + """Create a pool of Mechanical instances. + + Parameters + ---------- + n_instance : int + Number of Mechanical instances to create in the pool. + wait : bool, optional + Whether to wait for the pool to be initialized. The default is + ``True``. When ``False``, the pool starts in the background, in + which case all resources might not be immediately available. + starting_port : int, optional + Starting port for the instances. The default is ``10000``. + progress_bar : bool, optional + Whether to show a progress bar when starting the pool. The default + is ``True``, but the progress bar is not shown when ``wait=False``. + restart_failed : bool, optional + Whether to restart any failed instances in the pool. The default is + ``True``. + **kwargs : dict, optional + Additional keyword arguments. For a list of all keyword + arguments, use the :func:`ansys.mechanical.core.launch_mechanical` + function. If the ``exec_file`` keyword argument is found, it is used to + start instances. PyPIM is used to create instances if the following + conditions are met: + + - PyPIM is configured. + - ``version`` is specified. + - ``exec_file`` is not specified. + + + Examples + -------- + Create a pool of 10 Mechanical instances. + + >>> from ansys.mechanical.core import LocalMechanicalPool + >>> pool = LocalMechanicalPool(10) + Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] + + On Windows, create a pool while specifying the Mechanical executable file. + + >>> exec_file = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe' + >>> pool = LocalMechanicalPool(10, exec_file=exec_file) + Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] + + On Linux, create a pool while specifying the Mechanical executable file. + + >>> exec_file = '/ansys_inc/v251/aisol/.workbench' + >>> pool = LocalMechanicalPool(10, exec_file=exec_file) + Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] + + In the PyPIM environment, create a pool. + + >>> pool = LocalMechanicalPool(10, version="251") + Creating Pool: 100%|########| 10/10 [00:01<00:00, 1.43it/s] + + """ + + def __init__( + self, + n_instances, + wait=True, + port=MECHANICAL_DEFAULT_PORT, + progress_bar=True, + restart_failed=True, + **kwargs, + ): + """Initialize several Mechanical instances. + + Parameters + ---------- + n_instance : int + Number of Mechanical instances to initialize. + wait : bool, optional + Whether to wait for the instances to be initialized. The default is + ``True``. When ``False``, the instances start in the background, in + which case all resources might not be immediately available. + port : int, optional + Port for the first Mechanical instance. The default is + ``MECHANICAL_DEFAULT_PORT``. + progress_bar : bool, optional + Whether to display a progress bar when starting the instances. The default + is ``True``, but the progress bar is not shown when ``wait=False``. + restart_failed : bool, optional + Whether to restart any failed instances. The default is ``True``. + **kwargs : dict, optional + Additional keyword arguments. For a list of all additional keyword + arguments, see the :func:`ansys.mechanical.core.launch_mechanical` + function. If the ``exec_file`` keyword argument is found, it is used to + start instances. Instances are created using PyPIM if the following + conditions are met: + + - PyPIM is configured. + - Version is specified/ + - ``exec_file`` is not specified. + """ + self._instances = [] + self._spawn_kwargs = kwargs + self._remote = False + + # Verify that Mechanical is 2023R2 or newer + exec_file = None + if "exec_file" in kwargs: + exec_file = kwargs["exec_file"] + else: + if pypim.is_configured(): # pragma: no cover + if "version" in kwargs: + version = kwargs["version"] + self._remote = True + else: + raise ValueError("Pypim is configured, but version is not passed.") + else: # get default executable + exec_file = get_mechanical_path() + if exec_file is None: # pragma: no cover + raise FileNotFoundError( + "Path to Mechanical executable file is invalid or cache cannot be loaded. " + "Enter a path manually by specifying a value for the " + "'exec_file' parameter." + ) + + if not self._remote: # pragma: no cover + if version_from_path("mechanical", exec_file) < 232: + raise VersionError("A local Mechanical pool requires Mechanical 2023 R2 or later.") + + ports = None + + if not self._remote: + # grab available ports + ports = available_ports(n_instances, port) + + self._instances = [] + self._active = True # used by pool monitor + + n_instances = int(n_instances) + if n_instances < 2: + raise ValueError("You must request at least two instances to create a pool.") + + pbar = None + if wait and progress_bar: + if not _HAS_TQDM: # pragma: no cover + raise ModuleNotFoundError( + f"To use the keyword argument 'progress_bar', you must have installed " + f"the 'tqdm' package. To avoid this message, you can set 'progress_bar=False'." + ) + + pbar = tqdm(total=n_instances, desc="Creating Pool") + + # initialize a list of dummy instances + self._instances = [None for _ in range(n_instances)] + + if self._remote: # pragma: no cover + threads = [ + self._spawn_mechanical_remote(i, pbar, name=f"Instance {i}") + for i in range(n_instances) + ] + else: + # threaded spawn + threads = [ + self._spawn_mechanical(i, ports[i], pbar, name=f"Instance {i}") + for i in range(n_instances) + ] + if wait: + [thread.join() for thread in threads] + + # check if all clients connected have connected + if len(self) != n_instances: # pragma: no cover + n_connected = len(self) + warnings.warn( + f"Only {n_connected} clients connected out of {n_instances} requested" + ) + if pbar is not None: + pbar.close() + + # monitor pool if requested + if restart_failed: + self._pool_monitor_thread = self._monitor_pool(name="Monitoring_Thread started") + + if not self._remote: + self._verify_unique_ports() + + def _verify_unique_ports(self): + if self._remote: # pragma: no cover + raise RuntimeError("PyPIM is used. Port information is not available.") + + if len(self.ports) != len(self): # pragma: no cover + raise RuntimeError("Mechanical pool has overlapping ports.") + + def map( + self, + func, + iterable=None, + clear_at_start=True, + progress_bar=True, + close_when_finished=False, + timeout=None, + wait=True, + ): + """Run a user-defined function on each Mechanical instance in the pool. + + Parameters + ---------- + func : function + Function with ``mechanical`` as the first argument. The subsequent + arguments should match the number of items in each iterable (if any). + iterable : list, tuple, optional + An iterable containing a set of arguments for the function. + The default is ``None``, in which case the function runs + once on each instance of Mechanical. + clear_at_start : bool, optional + Clear Mechanical at the start of execution. The default is + ``True``. Setting this to ``False`` might lead to instability. + progress_bar : bool, optional + Whether to show a progress bar when running the batch of input + files. The default is ``True``, but the progress bar is not shown + when ``wait=False``. + close_when_finished : bool, optional + Whether to close the instances when the function finishes running + on all instances in the pool. The default is ``False``. + timeout : float, optional + Maximum runtime in seconds for each iteration. The default is + ``None``, in which case there is no timeout. If you specify a + value, each iteration is allowed to run only this number of + seconds. Once this value is exceeded, the batch process is + stopped and treated as a failure. + wait : bool, optional + Whether block execution must wait until the batch process is + complete. The default is ``True``. + + Returns + ------- + list + A list containing the return values for the function. + Failed runs do not return an output. Because return values + are not necessarily in the same order as the iterable, + you might want to add some sort of tracker to the return + of your function. + + Examples + -------- + Run several input files while storing the final routine. Note + how the function to map must use ``mechanical`` as the first argument. + The function can have any number of additional arguments. + + >>> from ansys.mechanical.core import LocalMechanicalPool + >>> pool = LocalMechanicalPool(10) + >>> completed_indices = [] + >>> def function(mechanical, name, script): + # name, script = args + mechanical.clear() + output = mechanical.run_python_script(script) + return name, output + >>> inputs = [("first","2+3"), ("second", "3+4")] + >>> output = pool.map(function, inputs, progress_bar=False, wait=True) + [('first', '5'), ('second', '7')] + """ + # check if any instances are available + if not len(self): # pragma: no cover + # instances could still be spawning... + if not all(v is None for v in self._instances): + raise RuntimeError("No Mechanical instances available.") + + results = [] + + if iterable is not None: + jobs_count = len(iterable) + else: + jobs_count = len(self) + + pbar = None + if progress_bar: + if not _HAS_TQDM: # pragma: no cover + raise ModuleNotFoundError( + "To use the keyword argument 'progress_bar', you must have installed " + "the 'tqdm' package. To avoid this message, you can set 'progress_bar=False'." + ) + + pbar = tqdm(total=jobs_count, desc="Mechanical Running") + + @threaded_daemon + def func_wrapper(obj, func, clear_at_start, timeout, args=None, name=""): + """Expect obj to be an instance of Mechanical.""" + LOG.debug(name) + complete = [False] + + @threaded_daemon + def run(name_local=""): + LOG.debug(name_local) + + if clear_at_start: + obj.clear() + + if args is not None: + if isinstance(args, (tuple, list)): + results.append(func(obj, *args)) + else: + results.append(func(obj, args)) + else: + results.append(func(obj)) + + complete[0] = True + + run_thread = run(name_local=name) + + if timeout: # pragma: no cover + time_start = time.time() + while not complete[0]: + time.sleep(0.01) + if (time.time() - time_start) > timeout: + break + + if not complete[0]: + LOG.error(f"Stopped instance due to a timeout of {timeout} seconds.") + obj.exit() + else: + run_thread.join() + if not complete[0]: # pragma: no cover + LOG.error("Stopped instance because running failed.") + try: + obj.exit() + except Exception as e: + LOG.error(f"Unexpected error while exiting: {e}") + + obj.locked = False + if pbar: + pbar.update(1) + + threads = [] + if iterable is not None: + for args in iterable: + # grab the next available instance of mechanical + instance, i = self.next_available(return_index=True) + instance.locked = True + + threads.append( + func_wrapper( + instance, func, clear_at_start, timeout, args, name=f"Map_Thread{i}" + ) + ) + else: # simply apply to all + for instance in self._instances: + if instance: + threads.append( + func_wrapper(instance, func, clear_at_start, timeout, name=f"Map_Thread") + ) + + if close_when_finished: # pragma: no cover + # start closing any instances that are not in execution + while not all(v is None for v in self._instances): + # grab the next available instance of mechanical and close it + instance, i = self.next_available(return_index=True) + self._instances[i] = None + + try: + instance.exit() + except Exception as error: # pragma: no cover + LOG.error(f"Failed to close instance : str{error}.") + else: + # wait for all threads to complete + if wait: + [thread.join() for thread in threads] + + return results + + def run_batch( + self, + files, + clear_at_start=True, + progress_bar=True, + close_when_finished=False, + timeout=None, + wait=True, + ): + """Run a batch of input files on the Mechanical instances in the pool. + + Parameters + ---------- + files : list + List of input files. + clear_at_start : bool, optional + Whether to clear Mechanical when execution starts. The default is + ``True``. Setting this parameter to ``False`` might lead to + instability. + progress_bar : bool, optional + Whether to show a progress bar when running the batch of input + files. The default is ``True``, but the progress bar is not shown + when ``wait=False``. + close_when_finished : bool, optional + Whether to close the instances when running the batch + of input files is finished. The default is ``False``. + timeout : float, optional + Maximum runtime in seconds for each iteration. The default is + ``None``, in which case there is no timeout. If you specify a + value, each iteration is allowed to run only this number of + seconds. Once this value is exceeded, the batch process is stopped + and treated as a failure. + wait : bool, optional + Whether block execution must wait until the batch process is complete. + The default is ``True``. + + Returns + ------- + list + List of text outputs from Mechanical for each batch run. The outputs + are not necessarily listed in the order of the inputs. Failed runs do + not return an output. Because the return outputs are not + necessarily in the same order as ``iterable``, you might + want to add some sort of tracker or note within the input files. + + Examples + -------- + Run 20 verification files on the pool. + + >>> files = [f"test{index}.py" for index in range(1, 21)] + >>> outputs = pool.run_batch(files) + >>> len(outputs) + 20 + """ + # check all files exist before running + for filename in files: + if not os.path.isfile(filename): + raise FileNotFoundError("Unable to locate file %s" % filename) + + def run_file(mechanical, input_file): + if clear_at_start: + mechanical.clear() + return mechanical.run_python_script_from_file(input_file) + + return self.map( + run_file, + files, + progress_bar=progress_bar, + close_when_finished=close_when_finished, + timeout=timeout, + wait=wait, + ) + + def next_available(self, return_index=False): + """Wait until a Mechanical instance is available and return this instance. + + Parameters + ---------- + return_index : bool, optional + Whether to return the index along with the instance. The default + is ``False``. + + Returns + ------- + pymechanical.Mechanical + Instance of Mechanical. + + int + Index within the pool of Mechanical instances. This index + is not returned by default. + + Examples + -------- + >>> mechanical = pool.next_available() + >>> mechanical + Ansys Mechanical [Ansys Mechanical Enterprise] + Product Version:251 + Software build date: 11/27/2024 09:34:44 + """ + # loop until the next instance is available + while True: + for i, instance in enumerate(self._instances): + # if encounter placeholder + if not instance: # pragma: no cover + continue + + if not instance.locked and not instance._exited: + # any instance that is not running or exited + # should be available + if not instance.busy: + # double check that this instance is alive: + try: + instance._make_dummy_call() + except: # pragma: no cover + instance.exit() + continue + + if return_index: + return instance, i + else: + return instance + # review - not needed + # else: + # instance._exited = True + + def __del__(self): + """Clean up when complete.""" + print("pool:Automatic clean up.") + self.exit() + + def exit(self, block=False): + """Exit all Mechanical instances in the pool. + + Parameters + ---------- + block : bool, optional + Whether to wait until all processes close before exiting + all instances in the pool. The default is ``False``. + + Examples + -------- + >>> pool.exit() + """ + self._active = False # Stops any active instance restart + + @threaded + def threaded_exit(index, instance_local): + if instance_local: + try: + instance_local.exit() + except Exception as e: # pragma: no cover + LOG.error(f"Error while exiting instance {str(instance_local)}: {str(e)}") + self._instances[index] = None + LOG.debug(f"Exited instance: {str(instance_local)}") + + threads = [] + for i, instance in enumerate(self): + threads.append(threaded_exit(i, instance)) + + if block: + [thread.join() for thread in threads] + + def __len__(self): + """Get the number of instances in the pool.""" + count = 0 + for instance in self._instances: + if instance: + if not instance._exited: + count += 1 + return count + + def __getitem__(self, key): + """Get an instance by an index.""" + return self._instances[key] + + def __iter__(self): + """Iterate through active instances.""" + for instance in self._instances: + if instance: + yield instance + + @threaded_daemon + def _spawn_mechanical(self, index, port=None, pbar=None, name=""): + """Spawn a Mechanical instance at an index. + + Parameters + ---------- + index : int + Index to spawn the instance on. + port : int, optional + Port for the instance. The default is ``None``. + pbar : + The default is ``None``. + name : str, optional + Name for the instance. The default is ``""``. + """ + LOG.debug(name) + self._instances[index] = launch_mechanical(port=port, **self._spawn_kwargs) + # LOG.debug("Spawned instance %d. Name '%s'", index, name) + if pbar is not None: + pbar.update(1) + + @threaded_daemon + def _spawn_mechanical_remote(self, index, pbar=None, name=""): # pragma: no cover + """Spawn a Mechanical instance at an index. + + Parameters + ---------- + index : int + Index to spawn the instance on. + pbar : + The default is ``None``. + name : str, optional + Name for the instance. The default is ``""``. + + """ + LOG.debug(name) + self._instances[index] = launch_mechanical(**self._spawn_kwargs) + # LOG.debug("Spawned instance %d. Name '%s'", index, name) + if pbar is not None: + pbar.update(1) + + @threaded_daemon + def _monitor_pool(self, refresh=1.0, name=""): + """Check for instances within a pool that have exited (failed) and restart them. + + Parameters + ---------- + refresh : float, optional + The default is ``1.0``. + name : str, optional + Name for the instance. The default is ``""``. + """ + LOG.debug(name) + while self._active: + for index, instance in enumerate(self._instances): + # encountered placeholder + if not instance: # pragma: no cover + continue + if instance._exited: # pragma: no cover + try: + if self._remote: + LOG.debug( + f"Restarting a Mechanical remote instance for index : {index}." + ) + self._spawn_mechanical_remote(index, name=f"Instance {index}").join() + else: + # use the next port after the current available port + port = max(self.ports) + 1 + LOG.debug( + f"Restarting a Mechanical instance for index : " + f"{index} on port: {port}." + ) + self._spawn_mechanical( + index, port=port, name=f"Instance {index}" + ).join() + except Exception as e: + LOG.error(e, exc_info=True) + time.sleep(refresh) + + @property + def ports(self): + """Get a list of the ports that are used. + + Examples + -------- + Get the list of ports used by the pool of Mechanical instances. + + >>> pool.ports + [10001, 10002] + + """ + return [inst._port for inst in self if inst is not None] + + def __str__(self): + """Get the string representation of this object.""" + return "Mechanical pool with %d active instances" % len(self) diff --git a/src/ansys/mechanical/core/run.py b/src/ansys/mechanical/core/run.py index ced846726..70bb3569a 100644 --- a/src/ansys/mechanical/core/run.py +++ b/src/ansys/mechanical/core/run.py @@ -1,321 +1,321 @@ -# 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. - -"""Convenience CLI to run mechanical.""" - -import asyncio -from asyncio.subprocess import PIPE -import os -import sys -import typing -import warnings - -import ansys.tools.path as atp -import click - -from ansys.mechanical.core.embedding.appdata import UniqueUserProfile -from ansys.mechanical.core.feature_flags import get_command_line_arguments, get_feature_flag_names - -DRY_RUN = False -"""Dry run constant.""" - -# TODO - add logging options (reuse env var based logging initialization) -# TODO - add timeout - - -async def _read_and_display(cmd, env, do_display: bool): - """Read command's stdout and stderr and display them as they are processed.""" - # start process - process = await asyncio.create_subprocess_exec(*cmd, stdout=PIPE, stderr=PIPE, env=env) - # read child's stdout/stderr concurrently - stdout, stderr = [], [] # stderr, stdout buffers - tasks = { - asyncio.Task(process.stdout.readline()): (stdout, process.stdout, sys.stdout.buffer), - asyncio.Task(process.stderr.readline()): (stderr, process.stderr, sys.stderr.buffer), - } - while tasks: - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - if not done: - raise RuntimeError("Subprocess read failed: No tasks completed.") - for future in done: - buf, stream, display = tasks.pop(future) - line = future.result() - if line: # not EOF - buf.append(line) # save for later - if do_display: - display.write(line) # display in terminal - # schedule to read the next line - tasks[asyncio.Task(stream.readline())] = buf, stream, display - - # wait for the process to exit - rc = await process.wait() - return rc, process, b"".join(stdout), b"".join(stderr) - - -def _run(args, env, check=False, display=False): - if os.name == "nt": - loop = asyncio.ProactorEventLoop() # for subprocess' pipes on Windows - asyncio.set_event_loop(loop) - else: - loop = asyncio.get_event_loop() - try: - rc, process, *output = loop.run_until_complete(_read_and_display(args, env, display)) - if rc and check: - sys.exit("child failed with '{}' exit code".format(rc)) - finally: - if os.name == "nt": - loop.close() - return process, output - - -def _cli_impl( - project_file: str = None, - port: int = 0, - debug: bool = False, - input_script: str = None, - script_args: str = None, - exe: str = None, - version: int = None, - graphical: bool = False, - show_welcome_screen: bool = False, - private_appdata: bool = False, - exit: bool = False, - features: str = None, -): - if project_file and input_script: - raise Exception("Cannot open a project file *and* run a script.") - - if (not graphical) and project_file: - raise Exception("Cannot open a project file in batch mode.") - - if port: - if project_file: - raise Exception("Cannot open in server mode with a project file.") - if input_script: - raise Exception("Cannot open in server mode with an input script.") - - if not input_script and script_args: - raise Exception("Cannot add script arguments without an input script.") - - if script_args: - if '"' in script_args: - raise Exception( - "Cannot have double quotes around individual arguments in the --script-args string." - ) - - # If the input_script and port are missing in batch mode, raise an exception - if (not graphical) and (input_script is None) and (not port): - raise Exception("An input script, -i, or port, --port, are required in batch mode.") - - args = [exe, "-DSApplet"] - if (not graphical) or (not show_welcome_screen): - args.append("-AppModeMech") - - if version < 232: - args.append("-nosplash") - args.append("-notabctrl") - - if not graphical: - args.append("-b") - - env: typing.Dict[str, str] = os.environ.copy() - if debug: - env["WBDEBUG_STOP"] = "1" - - if port: - args.append("-grpc") - args.append(str(port)) - - if project_file: - args.append("-file") - args.append(project_file) - - if input_script: - args.append("-script") - args.append(input_script) - - if script_args: - args.append("-ScriptArgs") - args.append(f'"{script_args}"') - - if (not graphical) and input_script: - exit = True - if version < 241: - warnings.warn( - "Please ensure ExtAPI.Application.Close() is at the end of your script. " - "Without this command, Batch mode will not terminate.", - stacklevel=2, - ) - - if exit and input_script and version >= 241: - args.append("-x") - - profile: UniqueUserProfile = None - if private_appdata: - new_profile_name = f"Mechanical-{os.getpid()}" - profile = UniqueUserProfile(new_profile_name, dry_run=DRY_RUN) - profile.update_environment(env) - - if not DRY_RUN: - version_name = atp.SUPPORTED_ANSYS_VERSIONS[version] - if graphical: - mode = "Graphical" - else: - mode = "Batch" - print(f"Starting Ansys Mechanical version {version_name} in {mode} mode...") - if port: - # TODO - Mechanical doesn't write anything to the stdout in grpc mode - # when logging is off.. Ideally we let Mechanical write it, so - # the user only sees the message when the server is ready. - print(f"Serving on port {port}") - - if features is not None: - args.extend(get_command_line_arguments(features.split(";"))) - - if DRY_RUN: - return args, env - else: - _run(args, env, False, True) - - if private_appdata: - profile.cleanup() - - -@click.command() -@click.help_option("--help", "-h") -@click.option( - "-p", - "--project-file", - default=None, - help="Opens Mechanical project file (.mechdb). Cannot be mixed with -i", -) -@click.option( - "--private-appdata", - default=None, - is_flag=True, - help="Make the appdata folder private.\ - This enables you to run parallel instances of Mechanical.", -) -@click.option( - "--port", - type=int, - help="Start mechanical in server mode with the given port number", -) -@click.option( - "--features", - type=str, - default=None, - help=f"Beta feature flags to set, as a semicolon delimited list.\ - Options: {get_feature_flag_names()}", -) -@click.option( - "-i", - "--input-script", - default=None, - help="Name of the input Python script. Cannot be mixed with -p", -) -@click.option( - "--script-args", - default=None, - help='Arguments to pass into the --input-script, -i. \ -Write the arguments as a string, with each argument \ -separated by a comma. For example, --script-args "arg1,arg2" \ -This can only be used with the --input-script argument.', -) -@click.option( - "--exit", - is_flag=True, - default=None, - help="Exit the application after running an input script. \ -You can only use this command with --input-script argument (-i). \ -The command defaults to true you are not running the application in graphical mode. \ -The ``exit`` command is only supported in version 2024 R1 or later.", -) -@click.option( - "-s", - "--show-welcome-screen", - is_flag=True, - default=False, - help="Show the welcome screen, where you can select the file to open.\ - Only affects graphical mode", -) -@click.option( - "--debug", - is_flag=True, - default=False, - help="Show a debug dialog right when the process starts.", -) -@click.option( - "-r", - "--revision", - default=None, - type=int, - help='Ansys Revision number, e.g. "242" or "241". If none is specified\ -, uses the default from ansys-tools-path', -) -@click.option( - "-g", - "--graphical", - is_flag=True, - default=False, - help="Graphical mode", -) -def cli( - project_file: str, - port: int, - debug: bool, - input_script: str, - script_args: str, - revision: int, - graphical: bool, - show_welcome_screen: bool, - private_appdata: bool, - exit: bool, - features: str, -): - """CLI tool to run mechanical. - - USAGE: - - The following example demonstrates the main use of this tool: - - $ ansys-mechanical -r 242 -g - - Starting Ansys Mechanical version 2024R2 in graphical mode... - """ - exe = atp.get_mechanical_path(allow_input=False, version=revision) - version = atp.version_from_path("mechanical", exe) - - return _cli_impl( - project_file, - port, - debug, - input_script, - script_args, - exe, - version, - graphical, - show_welcome_screen, - private_appdata, - exit, - features, - ) +# 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. + +"""Convenience CLI to run mechanical.""" + +import asyncio +from asyncio.subprocess import PIPE +import os +import sys +import typing +import warnings + +import ansys.tools.path as atp +import click + +from ansys.mechanical.core.embedding.appdata import UniqueUserProfile +from ansys.mechanical.core.feature_flags import get_command_line_arguments, get_feature_flag_names + +DRY_RUN = False +"""Dry run constant.""" + +# TODO - add logging options (reuse env var based logging initialization) +# TODO - add timeout + + +async def _read_and_display(cmd, env, do_display: bool): + """Read command's stdout and stderr and display them as they are processed.""" + # start process + process = await asyncio.create_subprocess_exec(*cmd, stdout=PIPE, stderr=PIPE, env=env) + # read child's stdout/stderr concurrently + stdout, stderr = [], [] # stderr, stdout buffers + tasks = { + asyncio.Task(process.stdout.readline()): (stdout, process.stdout, sys.stdout.buffer), + asyncio.Task(process.stderr.readline()): (stderr, process.stderr, sys.stderr.buffer), + } + while tasks: + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if not done: + raise RuntimeError("Subprocess read failed: No tasks completed.") + for future in done: + buf, stream, display = tasks.pop(future) + line = future.result() + if line: # not EOF + buf.append(line) # save for later + if do_display: + display.write(line) # display in terminal + # schedule to read the next line + tasks[asyncio.Task(stream.readline())] = buf, stream, display + + # wait for the process to exit + rc = await process.wait() + return rc, process, b"".join(stdout), b"".join(stderr) + + +def _run(args, env, check=False, display=False): + if os.name == "nt": + loop = asyncio.ProactorEventLoop() # for subprocess' pipes on Windows + asyncio.set_event_loop(loop) + else: + loop = asyncio.get_event_loop() + try: + rc, process, *output = loop.run_until_complete(_read_and_display(args, env, display)) + if rc and check: + sys.exit("child failed with '{}' exit code".format(rc)) + finally: + if os.name == "nt": + loop.close() + return process, output + + +def _cli_impl( + project_file: str = None, + port: int = 0, + debug: bool = False, + input_script: str = None, + script_args: str = None, + exe: str = None, + version: int = None, + graphical: bool = False, + show_welcome_screen: bool = False, + private_appdata: bool = False, + exit: bool = False, + features: str = None, +): + if project_file and input_script: + raise Exception("Cannot open a project file *and* run a script.") + + if (not graphical) and project_file: + raise Exception("Cannot open a project file in batch mode.") + + if port: + if project_file: + raise Exception("Cannot open in server mode with a project file.") + if input_script: + raise Exception("Cannot open in server mode with an input script.") + + if not input_script and script_args: + raise Exception("Cannot add script arguments without an input script.") + + if script_args: + if '"' in script_args: + raise Exception( + "Cannot have double quotes around individual arguments in the --script-args string." + ) + + # If the input_script and port are missing in batch mode, raise an exception + if (not graphical) and (input_script is None) and (not port): + raise Exception("An input script, -i, or port, --port, are required in batch mode.") + + args = [exe, "-DSApplet"] + if (not graphical) or (not show_welcome_screen): + args.append("-AppModeMech") + + if version < 232: + args.append("-nosplash") + args.append("-notabctrl") + + if not graphical: + args.append("-b") + + env: typing.Dict[str, str] = os.environ.copy() + if debug: + env["WBDEBUG_STOP"] = "1" + + if port: + args.append("-grpc") + args.append(str(port)) + + if project_file: + args.append("-file") + args.append(project_file) + + if input_script: + args.append("-script") + args.append(input_script) + + if script_args: + args.append("-ScriptArgs") + args.append(f'"{script_args}"') + + if (not graphical) and input_script: + exit = True + if version < 241: + warnings.warn( + "Please ensure ExtAPI.Application.Close() is at the end of your script. " + "Without this command, Batch mode will not terminate.", + stacklevel=2, + ) + + if exit and input_script and version >= 241: + args.append("-x") + + profile: UniqueUserProfile = None + if private_appdata: + new_profile_name = f"Mechanical-{os.getpid()}" + profile = UniqueUserProfile(new_profile_name, dry_run=DRY_RUN) + profile.update_environment(env) + + if not DRY_RUN: + version_name = atp.SUPPORTED_ANSYS_VERSIONS[version] + if graphical: + mode = "Graphical" + else: + mode = "Batch" + print(f"Starting Ansys Mechanical version {version_name} in {mode} mode...") + if port: + # TODO - Mechanical doesn't write anything to the stdout in grpc mode + # when logging is off.. Ideally we let Mechanical write it, so + # the user only sees the message when the server is ready. + print(f"Serving on port {port}") + + if features is not None: + args.extend(get_command_line_arguments(features.split(";"))) + + if DRY_RUN: + return args, env + else: + _run(args, env, False, True) + + if private_appdata: + profile.cleanup() + + +@click.command() +@click.help_option("--help", "-h") +@click.option( + "-p", + "--project-file", + default=None, + help="Opens Mechanical project file (.mechdb). Cannot be mixed with -i", +) +@click.option( + "--private-appdata", + default=None, + is_flag=True, + help="Make the appdata folder private.\ + This enables you to run parallel instances of Mechanical.", +) +@click.option( + "--port", + type=int, + help="Start mechanical in server mode with the given port number", +) +@click.option( + "--features", + type=str, + default=None, + help=f"Beta feature flags to set, as a semicolon delimited list.\ + Options: {get_feature_flag_names()}", +) +@click.option( + "-i", + "--input-script", + default=None, + help="Name of the input Python script. Cannot be mixed with -p", +) +@click.option( + "--script-args", + default=None, + help='Arguments to pass into the --input-script, -i. \ +Write the arguments as a string, with each argument \ +separated by a comma. For example, --script-args "arg1,arg2" \ +This can only be used with the --input-script argument.', +) +@click.option( + "--exit", + is_flag=True, + default=None, + help="Exit the application after running an input script. \ +You can only use this command with --input-script argument (-i). \ +The command defaults to true you are not running the application in graphical mode. \ +The ``exit`` command is only supported in version 2024 R1 or later.", +) +@click.option( + "-s", + "--show-welcome-screen", + is_flag=True, + default=False, + help="Show the welcome screen, where you can select the file to open.\ + Only affects graphical mode", +) +@click.option( + "--debug", + is_flag=True, + default=False, + help="Show a debug dialog right when the process starts.", +) +@click.option( + "-r", + "--revision", + default=None, + type=int, + help='Ansys Revision number, e.g. "251" or "242". If none is specified\ +, uses the default from ansys-tools-path', +) +@click.option( + "-g", + "--graphical", + is_flag=True, + default=False, + help="Graphical mode", +) +def cli( + project_file: str, + port: int, + debug: bool, + input_script: str, + script_args: str, + revision: int, + graphical: bool, + show_welcome_screen: bool, + private_appdata: bool, + exit: bool, + features: str, +): + """CLI tool to run mechanical. + + USAGE: + + The following example demonstrates the main use of this tool: + + $ ansys-mechanical -r 251 -g + + Starting Ansys Mechanical version 2025R1 in graphical mode... + """ + exe = atp.get_mechanical_path(allow_input=False, version=revision) + version = atp.version_from_path("mechanical", exe) + + return _cli_impl( + project_file, + port, + debug, + input_script, + script_args, + exe, + version, + graphical, + show_welcome_screen, + private_appdata, + exit, + features, + ) diff --git a/tests/conftest.py b/tests/conftest.py index cd4565b9a..657615392 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -378,7 +378,7 @@ def pytest_addoption(parser): mechanical_path = atp.get_mechanical_path(False) if mechanical_path is None: - parser.addoption("--ansys-version", default="242") + parser.addoption("--ansys-version", default="251") else: mechanical_version = atp.version_from_path("mechanical", mechanical_path) parser.addoption("--ansys-version", default=str(mechanical_version)) @@ -400,7 +400,7 @@ def pytest_collection_modifyitems(config, items): item.add_marker(skip_versions) # Skip tests that are outside of the provided version range. For example, - # @pytest.mark.version_range(241,242) + # @pytest.mark.version_range(241,251) if "version_range" in item.keywords: revns = [mark.args for mark in item.iter_markers(name="version_range")][0] ansys_version = int(config.getoption("--ansys-version")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9ce1f7000..71dd0a5f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,421 +1,440 @@ -# 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. - -import os -from pathlib import Path -import subprocess -import sys - -import pytest - -from ansys.mechanical.core.ide_config import _cli_impl as ideconfig_cli_impl -from ansys.mechanical.core.ide_config import get_stubs_location, get_stubs_versions -from ansys.mechanical.core.run import _cli_impl - -STUBS_LOC = get_stubs_location() -STUBS_REVNS = get_stubs_versions(STUBS_LOC) -MIN_STUBS_REVN = min(STUBS_REVNS) -MAX_STUBS_REVN = max(STUBS_REVNS) - - -@pytest.mark.cli -def test_cli_default(disable_cli): - args, env = _cli_impl(exe="AnsysWBU.exe", version=241, port=11) - assert os.environ == env - assert "-AppModeMech" in args - assert "-b" in args - assert "-DSApplet" in args - assert "AnsysWBU.exe" in args - - -@pytest.mark.cli -def test_cli_debug(disable_cli): - _, env = _cli_impl(exe="AnsysWBU.exe", version=241, debug=True, port=11) - assert "WBDEBUG_STOP" in env - - -@pytest.mark.cli -def test_cli_graphical(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, graphical=True) - assert "-b" not in args - - -@pytest.mark.cli -def test_cli_appdata(disable_cli): - _, env = _cli_impl(exe="AnsysWBU.exe", version=241, private_appdata=True, port=11) - var_to_compare = "TEMP" if os.name == "nt" else "HOME" - assert os.environ[var_to_compare] != env[var_to_compare] - - -@pytest.mark.cli -def test_cli_errors(disable_cli): - # can't mix project file and input script - with pytest.raises(Exception): - _cli_impl( - exe="AnsysWBU.exe", - version=241, - project_file="foo.mechdb", - input_script="foo.py", - graphical=True, - ) - # project file only works in graphical mode - with pytest.raises(Exception): - _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb") - # can't mix port and project file - with pytest.raises(Exception): - _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb", port=11) - # can't mix port and input script - with pytest.raises(Exception): - _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", port=11) - - -@pytest.mark.cli -def test_cli_appmode(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, show_welcome_screen=True, graphical=True) - assert "-AppModeMech" not in args - - -@pytest.mark.cli -def test_cli_231(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=231, port=11) - assert "-nosplash" in args - assert "-notabctrl" in args - - -@pytest.mark.cli -def test_cli_port(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, port=11) - assert "-grpc" in args - assert "11" in args - - -@pytest.mark.cli -def test_cli_project(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, project_file="foo.mechdb", graphical=True) - assert "-file" in args - assert "foo.mechdb" in args - - -@pytest.mark.cli -def test_cli_script(disable_cli): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", graphical=True) - assert "-script" in args - assert "foo.py" in args - - -@pytest.mark.cli -def test_cli_scriptargs(disable_cli): - args, _ = _cli_impl( - exe="AnsysWBU.exe", - version=241, - input_script="foo.py", - script_args="arg1,arg2,arg3", - graphical=True, - ) - assert "-ScriptArgs" in args - assert '"arg1,arg2,arg3"' in args - assert "-script" in args - assert "foo.py" in args - - -@pytest.mark.cli -def test_cli_scriptargs_no_script(disable_cli): - with pytest.raises(Exception): - _cli_impl( - exe="AnsysWBU.exe", - version=241, - script_args="arg1,arg2,arg3", - graphical=True, - ) - - -@pytest.mark.cli -def test_cli_scriptargs_singlequote(disable_cli): - args, _ = _cli_impl( - exe="AnsysWBU.exe", - version=241, - input_script="foo.py", - script_args="arg1,arg2,'arg3'", - graphical=True, - ) - assert "-ScriptArgs" in args - assert "\"arg1,arg2,'arg3'\"" in args - assert "-script" in args - assert "foo.py" in args - - -@pytest.mark.cli -def test_cli_scriptargs_doublequote(disable_cli): - with pytest.raises(Exception): - _cli_impl( - exe="AnsysWBU.exe", - version=241, - input_script="foo.py", - script_args='arg1,"arg2",arg3', - graphical=True, - ) - - -@pytest.mark.cli -def test_cli_features(disable_cli): - with pytest.warns(UserWarning): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, features="a;b;c", port=11) - assert "-featureflags" in args - assert "a;b;c" in args - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, features="MultistageHarmonic", port=11) - assert "Mechanical.MultistageHarmonic" in args - - -@pytest.mark.cli -def test_cli_exit(disable_cli): - # Regardless of version, `exit` does nothing on its own - args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True, port=11) - assert "-x" not in args - - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=True, port=11) - assert "-x" not in args - - # On versions earlier than 2024R1, `exit` throws a warning but does nothing - with pytest.warns(UserWarning): - args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True, input_script="foo.py") - assert "-x" not in args - - # In UI mode, exit must be manually specified - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py", graphical=True) - assert "-x" not in args - - # In batch mode, exit is implied - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, input_script="foo.py") - assert "-x" in args - - # In batch mode, exit can be explicitly passed - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=True, input_script="foo.py") - assert "-x" in args - - # In batch mode, exit can not be disabled - args, _ = _cli_impl(exe="AnsysWBU.exe", version=241, exit=False, input_script="foo.py") - assert "-x" in args - - -@pytest.mark.cli -def test_cli_batch_required_args(disable_cli): - # ansys-mechanical -r 241 => exception - with pytest.raises(Exception): - _cli_impl(exe="AnsysWBU.exe", version=241) - - # ansys-mechanical -r 241 -g => no exception - try: - _cli_impl(exe="AnsysWBU.exe", version=241, graphical=True) - except Exception as e: - assert False, f"cli raised an exception: {e}" - - # ansys-mechanical -r 241 -i input.py => no exception - try: - _cli_impl(exe="AnsysWBU.exe", version=241, input_script="input.py") - except Exception as e: - assert False, f"cli raised an exception: {e}" - - # ansys-mechanical -r 241 -port 11 => no exception - try: - _cli_impl(exe="AnsysWBU.exe", version=241, port=11) - except Exception as e: - assert False, f"cli raised an exception: {e}" - - -def get_settings_location() -> str: - """Get the location of settings.json for user settings. - - Returns - ------- - str - The path to the settings.json file for users on Windows and Linux. - """ - if "win" in sys.platform: - settings_json = Path(os.environ.get("APPDATA")) / "Code" / "User" / "settings.json" - elif "lin" in sys.platform: - settings_json = Path(os.environ.get("HOME")) / ".config" / "Code" / "User" / "settings.json" - - return settings_json - - -@pytest.mark.cli -def test_ideconfig_cli_ide_exception(capfd, pytestconfig): - """Test IDE configuration raises an exception for anything but vscode.""" - revision = int(pytestconfig.getoption("ansys_version")) - with pytest.raises(Exception): - ideconfig_cli_impl( - ide="pycharm", - target="user", - revision=revision, - ) - - -def test_ideconfig_cli_version_exception(pytestconfig): - """Test the IDE configuration raises an exception when the version is out of bounds.""" - revision = int(pytestconfig.getoption("ansys_version")) - stubs_location = get_stubs_location() - stubs_revns = get_stubs_versions(stubs_location) - - # If revision number is greater than the maximum stubs revision number - # assert an exception is raised - if revision > max(stubs_revns): - with pytest.raises(Exception): - ideconfig_cli_impl( - ide="vscode", - target="user", - revision=revision, - ) - - -@pytest.mark.cli -@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) -def test_ideconfig_cli_user_settings(capfd, pytestconfig): - """Test the IDE configuration prints correct information for user settings.""" - # Get the revision number - revision = int(pytestconfig.getoption("ansys_version")) - stubs_location = get_stubs_location() - - # Run the IDE configuration command for the user settings type - ideconfig_cli_impl( - ide="vscode", - target="user", - revision=revision, - ) - - # Get output of the IDE configuration command - out, err = capfd.readouterr() - out = out.replace("\\\\", "\\") - - # Get the path to the settings.json file based on operating system env vars - settings_json = get_settings_location() - - assert f"Update {settings_json} with the following information" in out - assert str(stubs_location) in out - - -@pytest.mark.cli -@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) -def test_ideconfig_cli_workspace_settings(capfd, pytestconfig): - """Test the IDE configuration prints correct information for workplace settings.""" - # Set the revision number - revision = int(pytestconfig.getoption("ansys_version")) - stubs_location = get_stubs_location() - - # Run the IDE configuration command - ideconfig_cli_impl( - ide="vscode", - target="workspace", - revision=revision, - ) - - # Get output of the IDE configuration command - out, err = capfd.readouterr() - out = out.replace("\\\\", "\\") - - # Get the path to the settings.json file based on the current directory & .vscode folder - settings_json = Path.cwd() / ".vscode" / "settings.json" - - # Assert the correct settings.json file and stubs location is in the output - assert f"Update {settings_json} with the following information" in out - assert str(stubs_location) in out - assert "Please ensure the .vscode folder is in the root of your project or repository" in out - - -@pytest.mark.cli -@pytest.mark.python_env -@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) -def test_ideconfig_cli_venv(test_env, run_subprocess, rootdir, pytestconfig): - """Test the IDE configuration location when a virtual environment is active.""" - # Set the revision number - revision = pytestconfig.getoption("ansys_version") - - # Install pymechanical - subprocess.check_call( - [test_env.python, "-m", "pip", "install", "-e", "."], - cwd=rootdir, - env=test_env.env, - ) - - # Get the virtual environment location - subprocess_output = run_subprocess( - [test_env.python, "-c", "'import sys; print(sys.prefix)'"], - env=test_env.env, - ) - # Decode stdout and fix extra backslashes in paths - venv_loc = subprocess_output[1].decode().replace("\\\\", "\\") - - # Run ansys-mechanical-ideconfig in the test virtual environment - subprocess_output_ideconfig = run_subprocess( - [ - "ansys-mechanical-ideconfig", - "--ide", - "vscode", - "--target", - "user", - "--revision", - str(revision), - ], - env=test_env.env, - ) - # Decode stdout and fix extra backslashes in paths - stdout = subprocess_output_ideconfig[1].decode().replace("\\\\", "\\") - - # Assert virtual environment is in the stdout - assert venv_loc in stdout - - -@pytest.mark.cli -@pytest.mark.python_env -@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) -def test_ideconfig_cli_default(test_env, run_subprocess, rootdir, pytestconfig): - """Test the IDE configuration location when no arguments are supplied.""" - # Get the revision number - revision = pytestconfig.getoption("ansys_version") - # Set part of the settings.json path - settings_json_fragment = Path("Code") / "User" / "settings.json" - - # Install pymechanical - subprocess.check_call( - [test_env.python, "-m", "pip", "install", "-e", "."], - cwd=rootdir, - env=test_env.env, - ) - - # Get the virtual environment location - subprocess_output = run_subprocess( - [test_env.python, "-c", "'import sys; print(sys.prefix)'"], - env=test_env.env, - ) - # Decode stdout and fix extra backslashes in paths - venv_loc = subprocess_output[1].decode().replace("\\\\", "\\") - - # Run ansys-mechanical-ideconfig in the test virtual environment - subprocess_output_ideconfig = run_subprocess( - ["ansys-mechanical-ideconfig"], - env=test_env.env, - ) - # Decode stdout and fix extra backslashes in paths - stdout = subprocess_output_ideconfig[1].decode().replace("\\\\", "\\") - - assert revision in stdout - assert str(settings_json_fragment) in stdout - assert venv_loc in stdout +# 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. + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from ansys.mechanical.core.ide_config import _cli_impl as ideconfig_cli_impl +from ansys.mechanical.core.ide_config import get_stubs_location, get_stubs_versions +from ansys.mechanical.core.run import _cli_impl + +STUBS_LOC = get_stubs_location() +STUBS_REVNS = get_stubs_versions(STUBS_LOC) +MIN_STUBS_REVN = min(STUBS_REVNS) +MAX_STUBS_REVN = max(STUBS_REVNS) + + +@pytest.mark.cli +def test_cli_default(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, env = _cli_impl(exe="AnsysWBU.exe", version=version, port=11) + assert os.environ == env + assert "-AppModeMech" in args + assert "-b" in args + assert "-DSApplet" in args + assert "AnsysWBU.exe" in args + + +@pytest.mark.cli +def test_cli_debug(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + _, env = _cli_impl(exe="AnsysWBU.exe", version=version, debug=True, port=11) + assert "WBDEBUG_STOP" in env + + +@pytest.mark.cli +def test_cli_graphical(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, graphical=True) + assert "-b" not in args + + +@pytest.mark.cli +def test_cli_appdata(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + _, env = _cli_impl(exe="AnsysWBU.exe", version=version, private_appdata=True, port=11) + var_to_compare = "TEMP" if os.name == "nt" else "HOME" + assert os.environ[var_to_compare] != env[var_to_compare] + + +@pytest.mark.cli +def test_cli_errors(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + # can't mix project file and input script + with pytest.raises(Exception): + _cli_impl( + exe="AnsysWBU.exe", + version=version, + project_file="foo.mechdb", + input_script="foo.py", + graphical=True, + ) + # project file only works in graphical mode + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=version, project_file="foo.mechdb") + # can't mix port and project file + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=version, project_file="foo.mechdb", port=11) + # can't mix port and input script + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=version, input_script="foo.py", port=11) + + +@pytest.mark.cli +def test_cli_appmode(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl( + exe="AnsysWBU.exe", version=version, show_welcome_screen=True, graphical=True + ) + assert "-AppModeMech" not in args + + +@pytest.mark.cli +def test_cli_231(disable_cli): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=231, port=11) + assert "-nosplash" in args + assert "-notabctrl" in args + + +@pytest.mark.cli +def test_cli_port(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, port=11) + assert "-grpc" in args + assert "11" in args + + +@pytest.mark.cli +def test_cli_project(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl( + exe="AnsysWBU.exe", version=version, project_file="foo.mechdb", graphical=True + ) + assert "-file" in args + assert "foo.mechdb" in args + + +@pytest.mark.cli +def test_cli_script(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, input_script="foo.py", graphical=True) + assert "-script" in args + assert "foo.py" in args + + +@pytest.mark.cli +def test_cli_scriptargs(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl( + exe="AnsysWBU.exe", + version=version, + input_script="foo.py", + script_args="arg1,arg2,arg3", + graphical=True, + ) + assert "-ScriptArgs" in args + assert '"arg1,arg2,arg3"' in args + assert "-script" in args + assert "foo.py" in args + + +@pytest.mark.cli +def test_cli_scriptargs_no_script(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + with pytest.raises(Exception): + _cli_impl( + exe="AnsysWBU.exe", + version=version, + script_args="arg1,arg2,arg3", + graphical=True, + ) + + +@pytest.mark.cli +def test_cli_scriptargs_singlequote(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + args, _ = _cli_impl( + exe="AnsysWBU.exe", + version=version, + input_script="foo.py", + script_args="arg1,arg2,'arg3'", + graphical=True, + ) + assert "-ScriptArgs" in args + assert "\"arg1,arg2,'arg3'\"" in args + assert "-script" in args + assert "foo.py" in args + + +@pytest.mark.cli +def test_cli_scriptargs_doublequote(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + with pytest.raises(Exception): + _cli_impl( + exe="AnsysWBU.exe", + version=version, + input_script="foo.py", + script_args='arg1,"arg2",arg3', + graphical=True, + ) + + +@pytest.mark.cli +def test_cli_features(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + with pytest.warns(UserWarning): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, features="a;b;c", port=11) + assert "-featureflags" in args + assert "a;b;c" in args + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, features="MultistageHarmonic", port=11) + assert "Mechanical.MultistageHarmonic" in args + + +@pytest.mark.cli +def test_cli_exit(disable_cli, pytestconfig): + version = int(pytestconfig.getoption("ansys_version")) + # Regardless of version, `exit` does nothing on its own + args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True, port=11) + assert "-x" not in args + + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, exit=True, port=11) + assert "-x" not in args + + # On versions earlier than 2024R1, `exit` throws a warning but does nothing + with pytest.warns(UserWarning): + args, _ = _cli_impl(exe="AnsysWBU.exe", version=232, exit=True, input_script="foo.py") + assert "-x" not in args + + # In UI mode, exit must be manually specified + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, input_script="foo.py", graphical=True) + assert "-x" not in args + + # In batch mode, exit is implied + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, input_script="foo.py") + assert "-x" in args + + # In batch mode, exit can be explicitly passed + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, exit=True, input_script="foo.py") + assert "-x" in args + + # In batch mode, exit can not be disabled + args, _ = _cli_impl(exe="AnsysWBU.exe", version=version, exit=False, input_script="foo.py") + assert "-x" in args + + +@pytest.mark.cli +def test_cli_batch_required_args(disable_cli): + # ansys-mechanical -r 241 => exception + with pytest.raises(Exception): + _cli_impl(exe="AnsysWBU.exe", version=241) + + # ansys-mechanical -r 241 -g => no exception + try: + _cli_impl(exe="AnsysWBU.exe", version=241, graphical=True) + except Exception as e: + assert False, f"cli raised an exception: {e}" + + # ansys-mechanical -r 241 -i input.py => no exception + try: + _cli_impl(exe="AnsysWBU.exe", version=241, input_script="input.py") + except Exception as e: + assert False, f"cli raised an exception: {e}" + + # ansys-mechanical -r 241 -port 11 => no exception + try: + _cli_impl(exe="AnsysWBU.exe", version=241, port=11) + except Exception as e: + assert False, f"cli raised an exception: {e}" + + +def get_settings_location() -> str: + """Get the location of settings.json for user settings. + + Returns + ------- + str + The path to the settings.json file for users on Windows and Linux. + """ + if "win" in sys.platform: + settings_json = Path(os.environ.get("APPDATA")) / "Code" / "User" / "settings.json" + elif "lin" in sys.platform: + settings_json = Path(os.environ.get("HOME")) / ".config" / "Code" / "User" / "settings.json" + + return settings_json + + +@pytest.mark.cli +def test_ideconfig_cli_ide_exception(capfd, pytestconfig): + """Test IDE configuration raises an exception for anything but vscode.""" + revision = int(pytestconfig.getoption("ansys_version")) + with pytest.raises(Exception): + ideconfig_cli_impl( + ide="pycharm", + target="user", + revision=revision, + ) + + +def test_ideconfig_cli_version_exception(pytestconfig): + """Test the IDE configuration raises an exception when the version is out of bounds.""" + revision = int(pytestconfig.getoption("ansys_version")) + stubs_location = get_stubs_location() + stubs_revns = get_stubs_versions(stubs_location) + + # If revision number is greater than the maximum stubs revision number + # assert an exception is raised + if revision > max(stubs_revns): + with pytest.raises(Exception): + ideconfig_cli_impl( + ide="vscode", + target="user", + revision=revision, + ) + + +@pytest.mark.cli +@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) +def test_ideconfig_cli_user_settings(capfd, pytestconfig): + """Test the IDE configuration prints correct information for user settings.""" + # Get the revision number + revision = int(pytestconfig.getoption("ansys_version")) + stubs_location = get_stubs_location() + + # Run the IDE configuration command for the user settings type + ideconfig_cli_impl( + ide="vscode", + target="user", + revision=revision, + ) + + # Get output of the IDE configuration command + out, err = capfd.readouterr() + out = out.replace("\\\\", "\\") + + # Get the path to the settings.json file based on operating system env vars + settings_json = get_settings_location() + + assert f"Update {settings_json} with the following information" in out + assert str(stubs_location) in out + + +@pytest.mark.cli +@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) +def test_ideconfig_cli_workspace_settings(capfd, pytestconfig): + """Test the IDE configuration prints correct information for workplace settings.""" + # Set the revision number + revision = int(pytestconfig.getoption("ansys_version")) + stubs_location = get_stubs_location() + + # Run the IDE configuration command + ideconfig_cli_impl( + ide="vscode", + target="workspace", + revision=revision, + ) + + # Get output of the IDE configuration command + out, err = capfd.readouterr() + out = out.replace("\\\\", "\\") + + # Get the path to the settings.json file based on the current directory & .vscode folder + settings_json = Path.cwd() / ".vscode" / "settings.json" + + # Assert the correct settings.json file and stubs location is in the output + assert f"Update {settings_json} with the following information" in out + assert str(stubs_location) in out + assert "Please ensure the .vscode folder is in the root of your project or repository" in out + + +@pytest.mark.cli +@pytest.mark.python_env +@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) +def test_ideconfig_cli_venv(test_env, run_subprocess, rootdir, pytestconfig): + """Test the IDE configuration location when a virtual environment is active.""" + # Set the revision number + revision = pytestconfig.getoption("ansys_version") + + # Install pymechanical + subprocess.check_call( + [test_env.python, "-m", "pip", "install", "-e", "."], + cwd=rootdir, + env=test_env.env, + ) + + # Get the virtual environment location + subprocess_output = run_subprocess( + [test_env.python, "-c", "'import sys; print(sys.prefix)'"], + env=test_env.env, + ) + # Decode stdout and fix extra backslashes in paths + venv_loc = subprocess_output[1].decode().replace("\\\\", "\\") + + # Run ansys-mechanical-ideconfig in the test virtual environment + subprocess_output_ideconfig = run_subprocess( + [ + "ansys-mechanical-ideconfig", + "--ide", + "vscode", + "--target", + "user", + "--revision", + str(revision), + ], + env=test_env.env, + ) + # Decode stdout and fix extra backslashes in paths + stdout = subprocess_output_ideconfig[1].decode().replace("\\\\", "\\") + + # Assert virtual environment is in the stdout + assert venv_loc in stdout + + +@pytest.mark.cli +@pytest.mark.python_env +@pytest.mark.version_range(MIN_STUBS_REVN, MAX_STUBS_REVN) +def test_ideconfig_cli_default(test_env, run_subprocess, rootdir, pytestconfig): + """Test the IDE configuration location when no arguments are supplied.""" + # Get the revision number + revision = pytestconfig.getoption("ansys_version") + # Set part of the settings.json path + settings_json_fragment = Path("Code") / "User" / "settings.json" + + # Install pymechanical + subprocess.check_call( + [test_env.python, "-m", "pip", "install", "-e", "."], + cwd=rootdir, + env=test_env.env, + ) + + # Get the virtual environment location + subprocess_output = run_subprocess( + [test_env.python, "-c", "'import sys; print(sys.prefix)'"], + env=test_env.env, + ) + # Decode stdout and fix extra backslashes in paths + venv_loc = subprocess_output[1].decode().replace("\\\\", "\\") + + # Run ansys-mechanical-ideconfig in the test virtual environment + subprocess_output_ideconfig = run_subprocess( + ["ansys-mechanical-ideconfig"], + env=test_env.env, + ) + # Decode stdout and fix extra backslashes in paths + stdout = subprocess_output_ideconfig[1].decode().replace("\\\\", "\\") + + assert revision in stdout + assert str(settings_json_fragment) in stdout + assert venv_loc in stdout diff --git a/tests/test_mechanical.py b/tests/test_mechanical.py index 5ef1fcc67..d4db05b43 100644 --- a/tests/test_mechanical.py +++ b/tests/test_mechanical.py @@ -1,558 +1,558 @@ -# 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. - -import json -import os -import pathlib -import re - -import ansys.tools.path -import grpc -import pytest - -import ansys.mechanical.core as pymechanical -import ansys.mechanical.core.errors as errors -import ansys.mechanical.core.misc as misc -import conftest - - -@pytest.mark.remote_session_connect -def test_run_python_script_success(mechanical): - result = mechanical.run_python_script("2+3") - assert result == "5" - - -@pytest.mark.remote_session_connect -def test_run_python_script_success_return_empty(mechanical): - result = mechanical.run_python_script("ExtAPI.DataModel.Project") - if misc.is_windows(): - assert result == "" - else: - assert result == "Ansys.ACT.Automation.Mechanical.Project" - - -@pytest.mark.remote_session_connect -def test_run_python_script_error(mechanical): - with pytest.raises(grpc.RpcError) as exc_info: - mechanical.run_python_script("import test") - - assert exc_info.value.details() == "No module named test" - - -@pytest.mark.remote_session_connect -def test_run_python_from_file_success(mechanical): - current_working_directory = os.getcwd() - script_path = os.path.join( - current_working_directory, "tests", "scripts", "run_python_success.py" - ) - print("running python script : ", script_path) - result = mechanical.run_python_script_from_file(script_path) - - assert result == "test" - - -# @pytest.mark.remote_session_connect -# def test_run_python_from_file_log_messages(mechanical): -# current_working_directory = os.getcwd() -# script_path = os.path.join(current_working_directory, "tests", "scripts", "log_message.py") -# print("running python script : ", script_path) -# -# print("logging_not enabled") -# result = mechanical.run_python_script_from_file(script_path) -# -# print("logging_enabled") -# result = mechanical.run_python_script_from_file( -# script_path, enable_logging=True, log_level="DEBUG", progress_interval=1000 -# ) -# -# result = mechanical.run_python_script_from_file( -# script_path, enable_logging=True, log_level="INFO", progress_interval=1000 -# ) -# -# result = mechanical.run_python_script_from_file( -# script_path, enable_logging=True, log_level="WARNING", progress_interval=1000 -# ) -# -# result = mechanical.run_python_script_from_file( -# script_path, enable_logging=True, log_level="ERROR", progress_interval=1000 -# ) -# -# result = mechanical.run_python_script_from_file( -# script_path, enable_logging=True, log_level="CRITICAL", progress_interval=1000 -# ) -# -# assert result == "log_test" - - -@pytest.mark.remote_session_connect -def test_run_python_script_from_file_error(mechanical): - with pytest.raises(grpc.RpcError) as exc_info: - current_working_directory = os.getcwd() - script_path = os.path.join( - current_working_directory, "tests", "scripts", "run_python_error.py" - ) - print("running python script : ", script_path) - mechanical.run_python_script_from_file(script_path) - - assert exc_info.value.details() == "name 'get_myname' is not defined" - - -@pytest.mark.remote_session_connect -@pytest.mark.parametrize("file_name", [r"hsec.x_t"]) -def test_upload(mechanical, file_name, assets): - mechanical.run_python_script("ExtAPI.DataModel.Project.New()") - directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - print(directory) - - file_path = os.path.join(assets, file_name) - mechanical.upload( - file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 - ) - - base_name = os.path.basename(file_path) - combined_path = os.path.join(directory, base_name) - file_path_modified = combined_path.replace("\\", "\\\\") - # we are working with iron python 2.7 on mechanical side - # use python 2.7 style formatting - # path = '%s' % file_path_modified - script = 'import os\nos.path.exists("%s")' % file_path_modified - print(script) - result = mechanical.run_python_script(script) - assert bool(result) - - -@pytest.mark.remote_session_connect -# we are using only a small test file -# change the chunk_size for that -# ideally this will be 64*1024, 1024*1024, etc. -@pytest.mark.parametrize("chunk_size", [10, 50, 100]) -def test_upload_with_different_chunk_size(mechanical, chunk_size, assets): - file_path = os.path.join(assets, "hsec.x_t") - mechanical.run_python_script("ExtAPI.DataModel.Project.New()") - directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - mechanical.upload( - file_name=file_path, file_location_destination=directory, chunk_size=chunk_size - ) - - -def get_solve_out_path(mechanical): - solve_out_path = "" - for file_path in mechanical.list_files(): - if file_path.find("solve.out") != -1: - solve_out_path = file_path - break - - return solve_out_path - - -def write_file_contents_to_console(path): - with open(path, "rt") as file: - for line in file: - print(line, end="") - - -def disable_distributed_solve(mechanical): - script = ( - 'ExtAPI.Application.SolveConfigurations["My Computer"].' - "SolveProcessSettings.DistributeSolution = False" - ) - mechanical.run_python_script(script) - - -def enable_distributed_solve(mechanical): - script = ( - 'ExtAPI.Application.SolveConfigurations["My Computer"].' - "SolveProcessSettings.DistributeSolution = True" - ) - mechanical.run_python_script(script) - - -def solve_and_return_results(mechanical): - current_working_directory = os.getcwd() - file_path = os.path.join(current_working_directory, "tests", "assets", "hsec.x_t") - - mechanical.clear() - directory = mechanical.project_directory - mechanical.upload( - file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 - ) - - python_script = os.path.join(current_working_directory, "tests", "scripts", "api.py") - - text_file = open(python_script, "r") - # read whole file to a string - data = text_file.read() - # close file - text_file.close() - - file_path_string = "\n" - - # let us append the scripts to run - func_to_call = """ -import os -directory = ExtAPI.DataModel.Project.ProjectDirectory -file_path_modified=os.path.join(directory,'hsec.x_t') -attach_geometry(file_path_modified) -generate_mesh() -add_static_structural_analysis_bc_results() -solve_model() -return_total_deformation() - """ - python_script = data + file_path_string + func_to_call - - result = mechanical.run_python_script( - python_script, enable_logging=True, log_level="INFO", progress_interval=1000 - ) - - # if solve fails, solve.out contains enough information - solve_out_path = get_solve_out_path(mechanical) - - if solve_out_path != "": - print(f"downloading {solve_out_path} from server") - print(f"downloading to {current_working_directory}") - solve_out_local_path_list = mechanical.download( - solve_out_path, target_dir=current_working_directory - ) - solve_out_local_path = solve_out_local_path_list[0] - print(solve_out_local_path) - - write_file_contents_to_console(solve_out_local_path) - - # done with solve.out - remove it - os.remove(solve_out_local_path) - - return result - - -def verify_project_download(mechanical, tmpdir): - files = mechanical.list_files() - number_of_files = len(files) - - print("files available: ") - for file in files: - print(file) - assert number_of_files > 0 - - # download the project - project_directory = mechanical.project_directory - print(f"project directory: {project_directory}") - - target_dir = os.path.join(tmpdir, "mechanical_project") - # add a trailing path separator - target_dir = os.path.join(target_dir, "") - print(f"creating target directory {target_dir}") - if not os.path.exists(target_dir): - os.mkdir(target_dir) - - out_files = mechanical.download_project(target_dir=target_dir) - print("downloaded files:") - for file in out_files: - print(file) - assert os.path.exists(file) and os.path.getsize(file) > 0 - - files = mechanical.list_files() - assert len(files) == len(out_files) - - target_dir = os.path.join(tmpdir, "mechanical_project2") - # add a trailing path separator - target_dir = os.path.join(target_dir, "") - print(f"creating target directory {target_dir}") - if not os.path.exists(target_dir): - os.mkdir(target_dir) - - # project not saved. - # no mechdb available. - extensions = ["mechdb"] - with pytest.raises(ValueError): - mechanical.download_project(extensions=extensions, target_dir=target_dir) - - extensions = ["xml", "rst"] - out_files = mechanical.download_project(extensions=extensions, target_dir=target_dir) - print(f"downloaded files for extensions: {extensions}") - for file in out_files: - print(file) - assert os.path.exists(file) and os.path.getsize(file) > 0 - extension = pathlib.Path(file).suffix - extension_without_dot = extension[1:] - assert extension_without_dot in extensions - - -@pytest.mark.remote_session_connect -# @pytest.mark.wip -# @pytest.mark.skip(reason="avoid long running") -def test_upload_attach_mesh_solve_use_api_non_distributed_solve(mechanical, tmpdir): - # default is distributed solve - # let's disable the distributed solve and then solve - # enable the distributed solve back - - # this test could run under a container with 1 cpu - # let us disable distributed solve - disable_distributed_solve(mechanical) - - result = solve_and_return_results(mechanical) - - # revert back to distributed solve - enable_distributed_solve(mechanical) - - dict_result = json.loads(result) - - min_value = float(dict_result["Minimum"].split(" ")[0]) - max_value = float(dict_result["Maximum"].split(" ")[0]) - avg_value = float(dict_result["Average"].split(" ")[0]) - - print(f"min_value = {min_value} max_value = {max_value} avg_value = {avg_value}") - - result = mechanical.run_python_script("ExtAPI.DataModel.Project.Model.Analyses[0].ObjectState") - assert "5" == result.lower() - - verify_project_download(mechanical, tmpdir) - - -@pytest.mark.remote_session_connect -def test_upload_attach_mesh_solve_use_api_distributed_solve(mechanical, tmpdir): - # default is distributed solve - - result = solve_and_return_results(mechanical) - - dict_result = json.loads(result) - - min_value = float(dict_result["Minimum"].split(" ")[0]) - max_value = float(dict_result["Maximum"].split(" ")[0]) - avg_value = float(dict_result["Average"].split(" ")[0]) - - print(f"min_value = {min_value} max_value = {max_value} avg_value = {avg_value}") - - result = mechanical.run_python_script("ExtAPI.DataModel.Project.Model.Analyses[0].ObjectState") - assert "5" == result.lower() - - verify_project_download(mechanical, tmpdir) - - -def verify_download(mechanical, tmpdir, file_name, chunk_size): - directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") - print(directory) - - current_working_directory = os.getcwd() - file_path = os.path.join(current_working_directory, "tests", "assets", file_name) - mechanical.upload( - file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 - ) - - print(f"using the temporary directory: {tmpdir}") - file_path = os.path.join(directory, file_name) - local_directory = tmpdir.strpath - - # test with different download chunk_size - local_path_list = mechanical.download( - files=file_path, target_dir=local_directory, chunk_size=chunk_size - ) - print("downloaded files:") - for local_path in local_path_list: - print(f" downloaded file: {local_path}") - assert os.path.exists(local_path) and os.path.getsize(local_path) > 0 - - -@pytest.mark.remote_session_connect -# @pytest.mark.wip -@pytest.mark.parametrize("file_name", ["hsec.x_t"]) -def test_download_file(mechanical, tmpdir, file_name): - verify_download(mechanical, tmpdir, file_name, 1024 * 1024) - - -@pytest.mark.remote_session_connect -# we are using only a small test file -# change the chunk_size for that -# ideally this will be 64*1024, 1024*1024, etc. -@pytest.mark.parametrize("chunk_size", [10, 50, 100]) -def test_download_file_different_chunk_size1(mechanical, tmpdir, chunk_size): - file_name = "hsec.x_t" - - verify_download(mechanical, tmpdir, file_name, chunk_size) - - -@pytest.mark.remote_session_launch -def test_launch_meshing_mode(mechanical_meshing): - result = mechanical_meshing.run_python_script("2+3") - assert result == "5" - - -@pytest.mark.remote_session_launch -def test_launch_result_mode(mechanical_result): - result = mechanical_result.run_python_script("2+3") - assert result == "5" - - -@pytest.mark.remote_session_launch -def test_close_all_Local_instances(tmpdir): - list_ports = [] - mechanical = conftest.launch_mechanical_instance(cleanup_on_exit=False) - print(mechanical.name) - list_ports.append(mechanical._port) - - # connect to the launched instance - mechanical2 = conftest.connect_to_mechanical_instance(mechanical._port, clear_on_connect=True) - print(mechanical2.name) - - test_upload_attach_mesh_solve_use_api_non_distributed_solve(mechanical2, tmpdir) - - # use the settings and launch another Mechanical instance - mechanical.launch(cleanup_on_exit=False) - print(mechanical.name) - list_ports.append(mechanical._port) - - pymechanical.close_all_local_instances(list_ports, use_thread=False) - for value in list_ports: - assert value not in pymechanical.LOCAL_PORTS - - -@pytest.mark.remote_session_launch -def test_find_mechanical_path(): - if pymechanical.mechanical.get_start_instance(): - path = ansys.tools.path.get_mechanical_path() - version = ansys.tools.path.version_from_path("mechanical", path) - - if misc.is_windows(): - assert "AnsysWBU.exe" in path - else: - assert ".workbench" in path - - assert re.match(r"\d{3}", str(version)) and version >= 232 - - -@pytest.mark.remote_session_launch -def test_change_default_mechanical_path(): - if pymechanical.mechanical.get_start_instance(): - path = ansys.tools.path.get_mechanical_path() - version = ansys.tools.path.version_from_path("mechanical", path) - - pymechanical.change_default_mechanical_path(path) - - path_new = ansys.tools.path.get_mechanical_path() - version_new = ansys.tools.path.version_from_path("mechanical", path) - - assert path_new == path - assert version_new == version - - -@pytest.mark.remote_session_launch -def test_version_from_path(): - windows_path = "C:\\Program Files\\ANSYS Inc\\v242\\aisol\\bin\\winx64\\AnsysWBU.exe" - version = ansys.tools.path.version_from_path("mechanical", windows_path) - assert version == 242 - - linux_path = "/usr/ansys_inc/v242/aisol/.workbench" - version = ansys.tools.path.version_from_path("mechanical", linux_path) - assert version == 242 - - with pytest.raises(RuntimeError): - # doesn't contain version - path = "C:\\Program Files\\ANSYS Inc\\aisol\\bin\\winx64\\AnsysWBU.exe" - ansys.tools.path.version_from_path("mechanical", path) - - -@pytest.mark.remote_session_launch -def test_valid_port(): - # no error thrown when everything is ok. - pymechanical.mechanical.check_valid_port(10000, 1000, 60000) - - with pytest.raises(ValueError): - pymechanical.mechanical.check_valid_port("10000") - - with pytest.raises(ValueError): - pymechanical.mechanical.check_valid_port(100, 1000, 60000) - - -@pytest.mark.remote_session_launch -def test_server_log_level(): - server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("DEBUG") - assert 1 == server_log_level - - server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("INFO") - assert 2 == server_log_level - - server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("WARNING") - assert 3 == server_log_level - - server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("ERROR") - assert 4 == server_log_level - - server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("CRITICAL") - assert 5 == server_log_level - - with pytest.raises(ValueError): - pymechanical.mechanical.Mechanical.convert_to_server_log_level("NON_EXITING_LEVEL") - - -@pytest.mark.remote_session_launch -def test_launch_mechanical_non_existent_path(): - cwd = os.getcwd() - - if misc.is_windows(): - exec_file = os.path.join(cwd, "test", "AnsysWBU.exe") - else: - exec_file = os.path.join(cwd, "test", ".workbench") - - with pytest.raises(FileNotFoundError): - pymechanical.launch_mechanical(exec_file=exec_file) - - -@pytest.mark.remote_session_launch -def test_launch_grpc_not_supported_version(): - cwd = os.getcwd() - - if misc.is_windows(): - exec_file = os.path.join(cwd, "ANSYS Inc", "v230", "aisol", "bin", "win64", "AnsysWBU.exe") - else: - exec_file = os.path.join(cwd, "ansys_inc", "v230", "aisol", ".workbench") - - with pytest.raises(errors.VersionError): - pymechanical.mechanical.launch_grpc(exec_file=exec_file) - - -# def test_call_before_launch_or_connect(): -# import ansys.mechanical.core as pymechanical -# from ansys.mechanical.core.errors import MechanicalExitedError -# -# # we are not checking any valid value passed to each call, -# # we just verify an exception being raised. -# -# mechanical1 = pymechanical.launch_mechanical(start_instance=True) -# mechanical1.exit() -# -# error = "Mechanical has already exited." -# -# with pytest.raises(MechanicalExitedError, match=error): -# mechanical1.run_python_script("2+5") -# -# with pytest.raises(MechanicalExitedError, match=error): -# mechanical1.run_python_script_from_file("test.py") -# -# # currently we exit silently -# # with pytest.raises(ValueError, match=error): -# # mechanical.exit(force_exit=True) -# -# with pytest.raises(MechanicalExitedError, match=error): -# mechanical1.upload(file_name="test.x_t", file_location_destination="some_destination", -# chunk_size=1024) -# -# with pytest.raises(MechanicalExitedError, match=error): -# mechanical1.download(files="test.x_t", target_dir="some_local_directory", chunk_size=1024) +# 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. + +import json +import os +import pathlib +import re + +import ansys.tools.path +import grpc +import pytest + +import ansys.mechanical.core as pymechanical +import ansys.mechanical.core.errors as errors +import ansys.mechanical.core.misc as misc +import conftest + + +@pytest.mark.remote_session_connect +def test_run_python_script_success(mechanical): + result = mechanical.run_python_script("2+3") + assert result == "5" + + +@pytest.mark.remote_session_connect +def test_run_python_script_success_return_empty(mechanical): + result = mechanical.run_python_script("ExtAPI.DataModel.Project") + if misc.is_windows(): + assert result == "" + else: + assert result == "Ansys.ACT.Automation.Mechanical.Project" + + +@pytest.mark.remote_session_connect +def test_run_python_script_error(mechanical): + with pytest.raises(grpc.RpcError) as exc_info: + mechanical.run_python_script("import test") + + assert exc_info.value.details() == "No module named test" + + +@pytest.mark.remote_session_connect +def test_run_python_from_file_success(mechanical): + current_working_directory = os.getcwd() + script_path = os.path.join( + current_working_directory, "tests", "scripts", "run_python_success.py" + ) + print("running python script : ", script_path) + result = mechanical.run_python_script_from_file(script_path) + + assert result == "test" + + +# @pytest.mark.remote_session_connect +# def test_run_python_from_file_log_messages(mechanical): +# current_working_directory = os.getcwd() +# script_path = os.path.join(current_working_directory, "tests", "scripts", "log_message.py") +# print("running python script : ", script_path) +# +# print("logging_not enabled") +# result = mechanical.run_python_script_from_file(script_path) +# +# print("logging_enabled") +# result = mechanical.run_python_script_from_file( +# script_path, enable_logging=True, log_level="DEBUG", progress_interval=1000 +# ) +# +# result = mechanical.run_python_script_from_file( +# script_path, enable_logging=True, log_level="INFO", progress_interval=1000 +# ) +# +# result = mechanical.run_python_script_from_file( +# script_path, enable_logging=True, log_level="WARNING", progress_interval=1000 +# ) +# +# result = mechanical.run_python_script_from_file( +# script_path, enable_logging=True, log_level="ERROR", progress_interval=1000 +# ) +# +# result = mechanical.run_python_script_from_file( +# script_path, enable_logging=True, log_level="CRITICAL", progress_interval=1000 +# ) +# +# assert result == "log_test" + + +@pytest.mark.remote_session_connect +def test_run_python_script_from_file_error(mechanical): + with pytest.raises(grpc.RpcError) as exc_info: + current_working_directory = os.getcwd() + script_path = os.path.join( + current_working_directory, "tests", "scripts", "run_python_error.py" + ) + print("running python script : ", script_path) + mechanical.run_python_script_from_file(script_path) + + assert exc_info.value.details() == "name 'get_myname' is not defined" + + +@pytest.mark.remote_session_connect +@pytest.mark.parametrize("file_name", [r"hsec.x_t"]) +def test_upload(mechanical, file_name, assets): + mechanical.run_python_script("ExtAPI.DataModel.Project.New()") + directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + print(directory) + + file_path = os.path.join(assets, file_name) + mechanical.upload( + file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 + ) + + base_name = os.path.basename(file_path) + combined_path = os.path.join(directory, base_name) + file_path_modified = combined_path.replace("\\", "\\\\") + # we are working with iron python 2.7 on mechanical side + # use python 2.7 style formatting + # path = '%s' % file_path_modified + script = 'import os\nos.path.exists("%s")' % file_path_modified + print(script) + result = mechanical.run_python_script(script) + assert bool(result) + + +@pytest.mark.remote_session_connect +# we are using only a small test file +# change the chunk_size for that +# ideally this will be 64*1024, 1024*1024, etc. +@pytest.mark.parametrize("chunk_size", [10, 50, 100]) +def test_upload_with_different_chunk_size(mechanical, chunk_size, assets): + file_path = os.path.join(assets, "hsec.x_t") + mechanical.run_python_script("ExtAPI.DataModel.Project.New()") + directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + mechanical.upload( + file_name=file_path, file_location_destination=directory, chunk_size=chunk_size + ) + + +def get_solve_out_path(mechanical): + solve_out_path = "" + for file_path in mechanical.list_files(): + if file_path.find("solve.out") != -1: + solve_out_path = file_path + break + + return solve_out_path + + +def write_file_contents_to_console(path): + with open(path, "rt") as file: + for line in file: + print(line, end="") + + +def disable_distributed_solve(mechanical): + script = ( + 'ExtAPI.Application.SolveConfigurations["My Computer"].' + "SolveProcessSettings.DistributeSolution = False" + ) + mechanical.run_python_script(script) + + +def enable_distributed_solve(mechanical): + script = ( + 'ExtAPI.Application.SolveConfigurations["My Computer"].' + "SolveProcessSettings.DistributeSolution = True" + ) + mechanical.run_python_script(script) + + +def solve_and_return_results(mechanical): + current_working_directory = os.getcwd() + file_path = os.path.join(current_working_directory, "tests", "assets", "hsec.x_t") + + mechanical.clear() + directory = mechanical.project_directory + mechanical.upload( + file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 + ) + + python_script = os.path.join(current_working_directory, "tests", "scripts", "api.py") + + text_file = open(python_script, "r") + # read whole file to a string + data = text_file.read() + # close file + text_file.close() + + file_path_string = "\n" + + # let us append the scripts to run + func_to_call = """ +import os +directory = ExtAPI.DataModel.Project.ProjectDirectory +file_path_modified=os.path.join(directory,'hsec.x_t') +attach_geometry(file_path_modified) +generate_mesh() +add_static_structural_analysis_bc_results() +solve_model() +return_total_deformation() + """ + python_script = data + file_path_string + func_to_call + + result = mechanical.run_python_script( + python_script, enable_logging=True, log_level="INFO", progress_interval=1000 + ) + + # if solve fails, solve.out contains enough information + solve_out_path = get_solve_out_path(mechanical) + + if solve_out_path != "": + print(f"downloading {solve_out_path} from server") + print(f"downloading to {current_working_directory}") + solve_out_local_path_list = mechanical.download( + solve_out_path, target_dir=current_working_directory + ) + solve_out_local_path = solve_out_local_path_list[0] + print(solve_out_local_path) + + write_file_contents_to_console(solve_out_local_path) + + # done with solve.out - remove it + os.remove(solve_out_local_path) + + return result + + +def verify_project_download(mechanical, tmpdir): + files = mechanical.list_files() + number_of_files = len(files) + + print("files available: ") + for file in files: + print(file) + assert number_of_files > 0 + + # download the project + project_directory = mechanical.project_directory + print(f"project directory: {project_directory}") + + target_dir = os.path.join(tmpdir, "mechanical_project") + # add a trailing path separator + target_dir = os.path.join(target_dir, "") + print(f"creating target directory {target_dir}") + if not os.path.exists(target_dir): + os.mkdir(target_dir) + + out_files = mechanical.download_project(target_dir=target_dir) + print("downloaded files:") + for file in out_files: + print(file) + assert os.path.exists(file) and os.path.getsize(file) > 0 + + files = mechanical.list_files() + assert len(files) == len(out_files) + + target_dir = os.path.join(tmpdir, "mechanical_project2") + # add a trailing path separator + target_dir = os.path.join(target_dir, "") + print(f"creating target directory {target_dir}") + if not os.path.exists(target_dir): + os.mkdir(target_dir) + + # project not saved. + # no mechdb available. + extensions = ["mechdb"] + with pytest.raises(ValueError): + mechanical.download_project(extensions=extensions, target_dir=target_dir) + + extensions = ["xml", "rst"] + out_files = mechanical.download_project(extensions=extensions, target_dir=target_dir) + print(f"downloaded files for extensions: {extensions}") + for file in out_files: + print(file) + assert os.path.exists(file) and os.path.getsize(file) > 0 + extension = pathlib.Path(file).suffix + extension_without_dot = extension[1:] + assert extension_without_dot in extensions + + +@pytest.mark.remote_session_connect +# @pytest.mark.wip +# @pytest.mark.skip(reason="avoid long running") +def test_upload_attach_mesh_solve_use_api_non_distributed_solve(mechanical, tmpdir): + # default is distributed solve + # let's disable the distributed solve and then solve + # enable the distributed solve back + + # this test could run under a container with 1 cpu + # let us disable distributed solve + disable_distributed_solve(mechanical) + + result = solve_and_return_results(mechanical) + + # revert back to distributed solve + enable_distributed_solve(mechanical) + + dict_result = json.loads(result) + + min_value = float(dict_result["Minimum"].split(" ")[0]) + max_value = float(dict_result["Maximum"].split(" ")[0]) + avg_value = float(dict_result["Average"].split(" ")[0]) + + print(f"min_value = {min_value} max_value = {max_value} avg_value = {avg_value}") + + result = mechanical.run_python_script("ExtAPI.DataModel.Project.Model.Analyses[0].ObjectState") + assert "5" == result.lower() + + verify_project_download(mechanical, tmpdir) + + +@pytest.mark.remote_session_connect +def test_upload_attach_mesh_solve_use_api_distributed_solve(mechanical, tmpdir): + # default is distributed solve + + result = solve_and_return_results(mechanical) + + dict_result = json.loads(result) + + min_value = float(dict_result["Minimum"].split(" ")[0]) + max_value = float(dict_result["Maximum"].split(" ")[0]) + avg_value = float(dict_result["Average"].split(" ")[0]) + + print(f"min_value = {min_value} max_value = {max_value} avg_value = {avg_value}") + + result = mechanical.run_python_script("ExtAPI.DataModel.Project.Model.Analyses[0].ObjectState") + assert "5" == result.lower() + + verify_project_download(mechanical, tmpdir) + + +def verify_download(mechanical, tmpdir, file_name, chunk_size): + directory = mechanical.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory") + print(directory) + + current_working_directory = os.getcwd() + file_path = os.path.join(current_working_directory, "tests", "assets", file_name) + mechanical.upload( + file_name=file_path, file_location_destination=directory, chunk_size=1024 * 1024 + ) + + print(f"using the temporary directory: {tmpdir}") + file_path = os.path.join(directory, file_name) + local_directory = tmpdir.strpath + + # test with different download chunk_size + local_path_list = mechanical.download( + files=file_path, target_dir=local_directory, chunk_size=chunk_size + ) + print("downloaded files:") + for local_path in local_path_list: + print(f" downloaded file: {local_path}") + assert os.path.exists(local_path) and os.path.getsize(local_path) > 0 + + +@pytest.mark.remote_session_connect +# @pytest.mark.wip +@pytest.mark.parametrize("file_name", ["hsec.x_t"]) +def test_download_file(mechanical, tmpdir, file_name): + verify_download(mechanical, tmpdir, file_name, 1024 * 1024) + + +@pytest.mark.remote_session_connect +# we are using only a small test file +# change the chunk_size for that +# ideally this will be 64*1024, 1024*1024, etc. +@pytest.mark.parametrize("chunk_size", [10, 50, 100]) +def test_download_file_different_chunk_size1(mechanical, tmpdir, chunk_size): + file_name = "hsec.x_t" + + verify_download(mechanical, tmpdir, file_name, chunk_size) + + +@pytest.mark.remote_session_launch +def test_launch_meshing_mode(mechanical_meshing): + result = mechanical_meshing.run_python_script("2+3") + assert result == "5" + + +@pytest.mark.remote_session_launch +def test_launch_result_mode(mechanical_result): + result = mechanical_result.run_python_script("2+3") + assert result == "5" + + +@pytest.mark.remote_session_launch +def test_close_all_Local_instances(tmpdir): + list_ports = [] + mechanical = conftest.launch_mechanical_instance(cleanup_on_exit=False) + print(mechanical.name) + list_ports.append(mechanical._port) + + # connect to the launched instance + mechanical2 = conftest.connect_to_mechanical_instance(mechanical._port, clear_on_connect=True) + print(mechanical2.name) + + test_upload_attach_mesh_solve_use_api_non_distributed_solve(mechanical2, tmpdir) + + # use the settings and launch another Mechanical instance + mechanical.launch(cleanup_on_exit=False) + print(mechanical.name) + list_ports.append(mechanical._port) + + pymechanical.close_all_local_instances(list_ports, use_thread=False) + for value in list_ports: + assert value not in pymechanical.LOCAL_PORTS + + +@pytest.mark.remote_session_launch +def test_find_mechanical_path(): + if pymechanical.mechanical.get_start_instance(): + path = ansys.tools.path.get_mechanical_path() + version = ansys.tools.path.version_from_path("mechanical", path) + + if misc.is_windows(): + assert "AnsysWBU.exe" in path + else: + assert ".workbench" in path + + assert re.match(r"\d{3}", str(version)) and version >= 232 + + +@pytest.mark.remote_session_launch +def test_change_default_mechanical_path(): + if pymechanical.mechanical.get_start_instance(): + path = ansys.tools.path.get_mechanical_path() + version = ansys.tools.path.version_from_path("mechanical", path) + + pymechanical.change_default_mechanical_path(path) + + path_new = ansys.tools.path.get_mechanical_path() + version_new = ansys.tools.path.version_from_path("mechanical", path) + + assert path_new == path + assert version_new == version + + +@pytest.mark.remote_session_launch +def test_version_from_path(): + windows_path = "C:\\Program Files\\ANSYS Inc\\v251\\aisol\\bin\\winx64\\AnsysWBU.exe" + version = ansys.tools.path.version_from_path("mechanical", windows_path) + assert version == 251 + + linux_path = "/usr/ansys_inc/v251/aisol/.workbench" + version = ansys.tools.path.version_from_path("mechanical", linux_path) + assert version == 251 + + with pytest.raises(RuntimeError): + # doesn't contain version + path = "C:\\Program Files\\ANSYS Inc\\aisol\\bin\\winx64\\AnsysWBU.exe" + ansys.tools.path.version_from_path("mechanical", path) + + +@pytest.mark.remote_session_launch +def test_valid_port(): + # no error thrown when everything is ok. + pymechanical.mechanical.check_valid_port(10000, 1000, 60000) + + with pytest.raises(ValueError): + pymechanical.mechanical.check_valid_port("10000") + + with pytest.raises(ValueError): + pymechanical.mechanical.check_valid_port(100, 1000, 60000) + + +@pytest.mark.remote_session_launch +def test_server_log_level(): + server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("DEBUG") + assert 1 == server_log_level + + server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("INFO") + assert 2 == server_log_level + + server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("WARNING") + assert 3 == server_log_level + + server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("ERROR") + assert 4 == server_log_level + + server_log_level = pymechanical.mechanical.Mechanical.convert_to_server_log_level("CRITICAL") + assert 5 == server_log_level + + with pytest.raises(ValueError): + pymechanical.mechanical.Mechanical.convert_to_server_log_level("NON_EXITING_LEVEL") + + +@pytest.mark.remote_session_launch +def test_launch_mechanical_non_existent_path(): + cwd = os.getcwd() + + if misc.is_windows(): + exec_file = os.path.join(cwd, "test", "AnsysWBU.exe") + else: + exec_file = os.path.join(cwd, "test", ".workbench") + + with pytest.raises(FileNotFoundError): + pymechanical.launch_mechanical(exec_file=exec_file) + + +@pytest.mark.remote_session_launch +def test_launch_grpc_not_supported_version(): + cwd = os.getcwd() + + if misc.is_windows(): + exec_file = os.path.join(cwd, "ANSYS Inc", "v230", "aisol", "bin", "win64", "AnsysWBU.exe") + else: + exec_file = os.path.join(cwd, "ansys_inc", "v230", "aisol", ".workbench") + + with pytest.raises(errors.VersionError): + pymechanical.mechanical.launch_grpc(exec_file=exec_file) + + +# def test_call_before_launch_or_connect(): +# import ansys.mechanical.core as pymechanical +# from ansys.mechanical.core.errors import MechanicalExitedError +# +# # we are not checking any valid value passed to each call, +# # we just verify an exception being raised. +# +# mechanical1 = pymechanical.launch_mechanical(start_instance=True) +# mechanical1.exit() +# +# error = "Mechanical has already exited." +# +# with pytest.raises(MechanicalExitedError, match=error): +# mechanical1.run_python_script("2+5") +# +# with pytest.raises(MechanicalExitedError, match=error): +# mechanical1.run_python_script_from_file("test.py") +# +# # currently we exit silently +# # with pytest.raises(ValueError, match=error): +# # mechanical.exit(force_exit=True) +# +# with pytest.raises(MechanicalExitedError, match=error): +# mechanical1.upload(file_name="test.x_t", file_location_destination="some_destination", +# chunk_size=1024) +# +# with pytest.raises(MechanicalExitedError, match=error): +# mechanical1.download(files="test.x_t", target_dir="some_local_directory", chunk_size=1024)