From 8c4a2571df94352cfd9f024b896d07b720a54e58 Mon Sep 17 00:00:00 2001 From: afernand Date: Fri, 6 Jun 2025 12:51:19 +0200 Subject: [PATCH 01/16] feat: Move ansys-tools-path --- .github/workflows/cicd.yml | 34 + pyproject.toml | 14 +- src/ansys/tools/path/__init__.py | 86 ++ src/ansys/tools/path/applications/__init__.py | 49 + src/ansys/tools/path/applications/dyna.py | 41 + src/ansys/tools/path/applications/mapdl.py | 43 + .../tools/path/applications/mechanical.py | 47 + src/ansys/tools/path/misc.py | 65 + src/ansys/tools/path/path.py | 1316 +++++++++++++++++ src/ansys/tools/path/py.typed | 0 src/ansys/tools/path/save.py | 58 + tests/conftest.py | 36 + tests/path/integration/test_integration.py | 86 ++ tests/path/unit/test_metadata.py | 47 + tests/path/unit/test_misc.py | 39 + tests/path/unit/test_path.py | 608 ++++++++ 16 files changed, 2567 insertions(+), 2 deletions(-) create mode 100644 src/ansys/tools/path/__init__.py create mode 100644 src/ansys/tools/path/applications/__init__.py create mode 100644 src/ansys/tools/path/applications/dyna.py create mode 100644 src/ansys/tools/path/applications/mapdl.py create mode 100644 src/ansys/tools/path/applications/mechanical.py create mode 100644 src/ansys/tools/path/misc.py create mode 100644 src/ansys/tools/path/path.py create mode 100644 src/ansys/tools/path/py.typed create mode 100644 src/ansys/tools/path/save.py create mode 100644 tests/conftest.py create mode 100644 tests/path/integration/test_integration.py create mode 100644 tests/path/unit/test_metadata.py create mode 100644 tests/path/unit/test_misc.py create mode 100644 tests/path/unit/test_path.py diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 1cba208e..bfa59215 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -39,6 +39,40 @@ jobs: python-version: ${{ matrix.python-version }} whitelist-license-check: "termcolor" # Has MIT license, but it's not recognized + build-tests: + name: Build and Testing + runs-on: ubuntu-22.04 + needs: [smoke-tests] + container: + image: ghcr.io/ansys/pymapdl/mapdl:v22.2-ubuntu + options: "-u=0:0 --entrypoint /bin/bash" + credentials: + username: ${{ secrets.GH_USERNAME }} + password: ${{ secrets.GITHUB_TOKEN }} + env: + ANSYS_LOCAL: true + ON_UBUNTU: true + + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + + - name: Install library, with test extra + run: python -m pip install .[tests] + + - name: Unit testing + run: | + python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + files: .cov/coverage.xml + + package: name: Package library runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index b506a00d..0deb22c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,13 +22,23 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] -dependencies = [] +dependencies = [ + "platformdirs>=3.6.0", + "click>=8.1.3", # for CLI interface +] [project.optional-dependencies] -tests = [] +tests = [ + "pytest==8.4.0", + "pytest-cov==6.1.1", + "pyfakefs==5.8.0" +] doc = [] +[project.scripts] +save-ansys-path = "ansys.tools.path.save:cli" + [project.urls] Source = "https://github.com/ansys/ansys-tools" Issues = "https://github.com/ansys/ansys-tools/issues" diff --git a/src/ansys/tools/path/__init__.py b/src/ansys/tools/path/__init__.py new file mode 100644 index 00000000..5a2d7547 --- /dev/null +++ b/src/ansys/tools/path/__init__.py @@ -0,0 +1,86 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tools to find/cache installed Ansys products. + +WARNING: This is not concurrent-safe (multiple python processes might race on this data.) +""" + +import importlib.metadata as importlib_metadata + +__version__ = importlib_metadata.version(__name__.replace(".", "-")) + + +from ansys.tools.path.path import ( + LOG, + SETTINGS_DIR, + SUPPORTED_ANSYS_VERSIONS, + change_default_ansys_path, # deprecated + change_default_dyna_path, + change_default_mapdl_path, + change_default_mechanical_path, + clear_configuration, + find_ansys, # deprecated + find_dyna, + find_mapdl, + find_mechanical, + get_ansys_path, # deprecated + get_available_ansys_installations, + get_dyna_path, + get_latest_ansys_installation, + get_mapdl_path, + get_mechanical_path, + get_saved_application_path, + save_ansys_path, # deprecated + save_dyna_path, + save_mapdl_path, + save_mechanical_path, + version_from_path, +) + +__all__ = [ + "LOG", + "SETTINGS_DIR", + "SUPPORTED_ANSYS_VERSIONS", + "change_default_mapdl_path", + "change_default_mechanical_path", + "change_default_dyna_path", + "clear_configuration", + "find_mapdl", + "find_mechanical", + "find_dyna", + "get_available_ansys_installations", + "get_latest_ansys_installation", + "get_mapdl_path", + "get_mechanical_path", + "get_saved_application_path", + "get_dyna_path", + "save_mapdl_path", + "save_mechanical_path", + "save_dyna_path", + "version_from_path", + "change_default_ansys_path", + "find_ansys", + "get_ansys_path", + "save_ansys_path", +] diff --git a/src/ansys/tools/path/applications/__init__.py b/src/ansys/tools/path/applications/__init__.py new file mode 100644 index 00000000..3f8e812c --- /dev/null +++ b/src/ansys/tools/path/applications/__init__.py @@ -0,0 +1,49 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Application plugin for ansys-tools-path. + +This defines the interface of a plugin, which is implemented using a module. +""" + +# TODO - consider using pluggy? +# TODO: Remove? + + +class ApplicationPlugin: + """Class for application plugins.""" + + def is_valid_executable_path(self, exe_loc: str) -> bool: + r"""Check if the executable path is valid for the application. + + Parameters + ---------- + exe_loc : str + The path to the executable file. + + Returns + ------- + bool + ``True`` if the path is valid for the application, ``False`` otherwise. + """ + raise Exception("This is just a base class.") diff --git a/src/ansys/tools/path/applications/dyna.py b/src/ansys/tools/path/applications/dyna.py new file mode 100644 index 00000000..ba87d35c --- /dev/null +++ b/src/ansys/tools/path/applications/dyna.py @@ -0,0 +1,41 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""dyna-specific logic for ansys-tools-path.""" + + +# TODO: Remove? +def is_valid_executable_path(exe_loc: str) -> bool: + """Check if the executable path is valid for Ansys Dyna. + + Parameters + ---------- + exe_loc : str + The path to the executable file. + + Returns + ------- + bool + ``True`` if the path is valid for Ansys Dyna, ``False`` otherwise. + """ + # dyna paths can be anything + return True diff --git a/src/ansys/tools/path/applications/mapdl.py b/src/ansys/tools/path/applications/mapdl.py new file mode 100644 index 00000000..fb728493 --- /dev/null +++ b/src/ansys/tools/path/applications/mapdl.py @@ -0,0 +1,43 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""MAPDL-specific logic for ansys-tools-path.""" + +from pathlib import Path +import re + + +def is_valid_executable_path(exe_loc: str) -> bool: + """Check if the executable path is valid for Ansys MAPDL. + + Parameters + ---------- + exe_loc : str + The path to the executable file. + + Returns + ------- + bool + ``True`` if the path is valid for Ansys MAPDL, ``False`` otherwise. + """ + path = Path(exe_loc) + return path.is_file() and re.search(r"ansys\d{3}", path.name) is not None diff --git a/src/ansys/tools/path/applications/mechanical.py b/src/ansys/tools/path/applications/mechanical.py new file mode 100644 index 00000000..14e24e47 --- /dev/null +++ b/src/ansys/tools/path/applications/mechanical.py @@ -0,0 +1,47 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Mechanical-specific logic for ansys-tools-path.""" + +from pathlib import Path +import re + +from ansys.tools.path.misc import is_windows + + +def is_valid_executable_path(exe_loc: str) -> bool: + """Check if the executable path is valid for Ansys Mechanical. + + Parameters + ---------- + exe_loc : str + The path to the executable file. + + Returns + ------- + bool + ``True`` if the path is valid for Ansys Mechanical, ``False`` otherwise. + """ + path = Path(exe_loc) + if is_windows(): # pragma: no cover + return path.is_file() and re.search("AnsysWBU.exe", path.name, re.IGNORECASE) is not None + return path.is_file() and re.search(r"\.workbench$", path.name) is not None diff --git a/src/ansys/tools/path/misc.py b/src/ansys/tools/path/misc.py new file mode 100644 index 00000000..6adfb7e6 --- /dev/null +++ b/src/ansys/tools/path/misc.py @@ -0,0 +1,65 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Miscellaneous functions used by ansys-tools-path.""" + +import os + + +def is_float(input_string: str) -> bool: + r"""Return true when a string can be converted to a float. + + Parameters + ---------- + input_string : str + The string to check. + + Returns + ------- + bool + ``True`` if the string can be converted to a float, ``False`` otherwise. + """ + try: + float(input_string) + return True + except ValueError: + return False + + +def is_windows() -> bool: + r"""Check if the host machine is on Windows. + + Returns + ------- + ``True`` if the host machine is on Windows, ``False`` otherwise. + """ + return os.name == "nt" + + +def is_linux() -> bool: + r"""Check if the host machine is Linux. + + Returns + ------- + ``True`` if the host machine is Linux, ``False`` otherwise. + """ + return os.name == "posix" diff --git a/src/ansys/tools/path/path.py b/src/ansys/tools/path/path.py new file mode 100644 index 00000000..d78b26a2 --- /dev/null +++ b/src/ansys/tools/path/path.py @@ -0,0 +1,1316 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for installation path retrieval.""" + +from dataclasses import dataclass +import json +import logging +import os +from pathlib import Path +import re +from typing import Callable, Dict, List, Literal, Optional, Tuple, Union, cast +import warnings + +import platformdirs + +from ansys.tools.path.applications import ApplicationPlugin, dyna, mapdl, mechanical +from ansys.tools.path.misc import is_float, is_linux, is_windows + +PLUGINS: Dict[str, ApplicationPlugin] = {"mechanical": mechanical, "dyna": dyna, "mapdl": mapdl} + +LOG = logging.getLogger(__name__) + +PRODUCT_TYPE = Literal["mapdl", "mechanical", "dyna"] +SUPPORTED_VERSIONS_TYPE = Dict[int, str] + +linux_default_dirs = [["/", "usr", "ansys_inc"], ["/", "ansys_inc"], ["/", "install", "ansys_inc"]] +LINUX_DEFAULT_DIRS = [str(Path(*each)) for each in linux_default_dirs] + +CONFIG_FILE_NAME = "config.txt" + +SUPPORTED_ANSYS_VERSIONS: SUPPORTED_VERSIONS_TYPE = { + 252: "2025R2", + 251: "2025R1", + 242: "2024R2", + 241: "2024R1", + 232: "2023R2", + 231: "2023R1", + 222: "2022R2", + 221: "2022R1", + 212: "2021R2", + 211: "2021R1", + 202: "2020R2", + 201: "2020R1", + 195: "19.5", + 194: "19.4", + 193: "19.3", + 192: "19.2", + 191: "19.1", +} + +PRODUCT_EXE_INFO = { + "mapdl": { + "name": "Ansys MAPDL", + "pattern": "ansysxxx", + "patternpath": "vXXX/ansys/bin/ansysXXX", + }, + "dyna": { + "name": "Ansys LS-DYNA", # patternpath and pattern are not used for dyna + }, + "mechanical": { + "name": "Ansys Mechanical", + }, +} + +if is_windows(): # pragma: no cover + PRODUCT_EXE_INFO["mechanical"]["patternpath"] = "vXXX/aisol/bin/winx64/AnsysWBU.exe" + PRODUCT_EXE_INFO["mechanical"]["pattern"] = "AnsysWBU.exe" + PRODUCT_EXE_INFO["dyna"]["patternpath"] = "vXXX/ansys/bin/winx64/LSDYNAXXX.exe" + PRODUCT_EXE_INFO["dyna"]["pattern"] = "LSDYNAXXX.exe" +else: + PRODUCT_EXE_INFO["mechanical"]["patternpath"] = "vXXX/aisol/.workbench" + PRODUCT_EXE_INFO["mechanical"]["pattern"] = ".workbench" + PRODUCT_EXE_INFO["dyna"]["patternpath"] = "vXXX/ansys/bin/lsdynaXXX" + PRODUCT_EXE_INFO["dyna"]["pattern"] = "lsdynaXXX" + +# Settings directory +SETTINGS_DIR = Path(platformdirs.user_data_dir(appname="ansys_tools_path", appauthor="Ansys")) + +try: + SETTINGS_DIR.mkdir(parents=True, exist_ok=True) + LOG.debug(f"Settings directory ensured at: {SETTINGS_DIR}") +except OSError: + warnings.warn( + f"Unable to create settings directory at {SETTINGS_DIR}.\nWill be unable to cache product executable locations." + ) + +# Full path to the configuration file +CONFIG_FILE = SETTINGS_DIR / CONFIG_FILE_NAME + +# FileMigrationStrategy: TypeAlias = Callable[[], Dict[PRODUCT_TYPE, str]] + + +def _get_installed_windows_versions( + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Dict[int, str]: # pragma: no cover + """Get the AWP_ROOT environment variable values for supported versions. + + Parameters + ---------- + supported_versions : SUPPORTED_VERSIONS_TYPE, optional + A dictionary of supported Ansys versions. Defaults to `SUPPORTED_ANSYS_VERSIONS`. + + Returns + ------- + Dict[int, str] + A dictionary mapping Ansys version numbers to their installation paths. + """ + # The student version overwrites the AWP_ROOT env var + # (if it is installed later) + # However the priority should be given to the non-student version. + awp_roots: list[Tuple[int, str]] = [] + awp_roots_student: list[Tuple[int, str]] = [] + for ver in supported_versions: + path_str = os.environ.get(f"AWP_ROOT{ver}", "") + if not path_str: + continue + + path = Path(path_str) + if "student" in path_str.lower(): + awp_roots_student.insert(0, (-1 * ver, path_str)) + # Check if non-student version exists by replacing "\\ANSYS Student" + path_non_student = Path(str(path).replace("\\ANSYS Student", "")) + if path_non_student.is_dir(): + awp_roots.append((ver, str(path_non_student))) + else: + awp_roots.append((ver, path_str)) + + awp_roots.extend(awp_roots_student) + installed_versions: Dict[int, str] = {ver: p for ver, p in awp_roots if p and Path(p).is_dir()} + + if installed_versions: + LOG.debug(f"Found the following unified Ansys installation versions: {installed_versions}") + else: + LOG.debug("No unified Ansys installations found using 'AWP_ROOT' environments.") + + return installed_versions + + +def _get_default_linux_base_path() -> Optional[str]: + """Get the default base path of the Ansys unified install on Linux. + + Returns + ------- + Optional[str] + Base path to search for Ansys installations. + """ + for path_str in LINUX_DEFAULT_DIRS: + path = Path(path_str) + LOG.debug(f"Checking {path} as a potential ansys directory") + if path.is_dir(): + return str(path) + return None + + +def _get_default_windows_base_path() -> Optional[str]: # pragma: no cover + """Get the default base path of the Ansys unified install on Windows. + + Returns + ------- + Optional[str] + Base path to search for Ansys installations. + """ + program_files = Path(os.environ.get("PROGRAMFILES", "")) + base_path = program_files / "ANSYS Inc" + + if not base_path.exists(): + LOG.debug(f"The supposed 'base_path' {base_path} does not exist. No available ansys found.") + return None + return str(base_path) + + +def _expand_base_path(base_path: Optional[str]) -> Dict[int, str]: + """Expand the base path to all possible ansys Unified installations contained within. + + Parameters + ---------- + base_path: Optional[str] + Base path to search for Ansys installations. + + Returns + ------- + Dict[int, str] + A dictionary mapping Ansys version numbers to their installation paths. + """ + if base_path is None: + return {} + + base = Path(base_path) + + ansys_paths: Dict[int, str] = {} + + # Search for versions like /base_path/vXXX + for path in base.glob("v*"): + ver_str = path.name[-3:] + if is_float(ver_str): + ansys_paths[int(ver_str)] = str(path) + + # Search for ANSYS STUDENT versions like /base_path/ANSYS*/vXXX + # student_base_pattern = base / "ANSYS*" + student_paths = [] + for student_dir in base.glob("ANSYS*"): + student_paths.extend(student_dir.glob("v*")) + + if not student_paths: + return ansys_paths + + for path in student_paths: + ver_str = path.name[-3:] + if is_float(ver_str): + ansys_paths[-int(ver_str)] = str(path) + + return ansys_paths + + +def _get_available_base_unified( + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Dict[int, str]: + r"""Get a dictionary of available Ansys Unified Installation versions with their base paths. + + Returns + ------- + Paths for Unified Installation versions installed. + + Examples + -------- + On Windows: + + >>> _get_available_base_unified() + >>> {251: "C:\\Program Files\\ANSYS Inc\\v251"} + + On Linux: + + >>> _get_available_base_unified() + >>> {251: "/usr/ansys_inc/v251"} + """ + base_path = None + if is_windows(): # pragma: no cover + installed_versions = _get_installed_windows_versions(supported_versions) + if installed_versions: + return installed_versions + else: # pragma: no cover + base_path = _get_default_windows_base_path() + elif is_linux(): + base_path = _get_default_linux_base_path() + else: # pragma: no cover + raise OSError(f"Unsupported OS {os.name}") + return _expand_base_path(base_path) + + +def get_available_ansys_installations( + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Dict[int, str]: + r"""Return a dictionary of available Ansys unified installation versions with their base paths. + + Returns + ------- + dict[int: str] + Return all Ansys unified installations paths in Windows. + + Notes + ----- + On Windows, It uses the environment variable ``AWP_ROOTXXX``. + + The student versions are returned at the end of the dict and + with negative value for the version. + + Examples + -------- + >>> from ansys.tools.path import get_available_ansys_installations + >>> get_available_ansys_installations() + {251: 'C:\\Program Files\\ANSYS Inc\\v251', + 242: 'C:\\Program Files\\ANSYS Inc\\v242', + -242: 'C:\\Program Files\\ANSYS Inc\\ANSYS Student\\v242'} + + Return all installed Ansys paths in Linux. + + >>> get_available_ansys_installations() + {251: '/usr/ansys_inc/v251', + 242: '/usr/ansys_inc/v242', + 241: '/usr/ansys_inc/v241'} + """ + return _get_available_base_unified(supported_versions) + + +def _get_unified_install_base_for_version( + version: Optional[Union[int, float]] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Tuple[str, str]: + """Search for the unified install of a given version from the supported versions. + + Returns + ------- + Tuple[str, str] + The base unified install path and version + """ + versions = _get_available_base_unified(supported_versions) + if not versions: + return "", "" + + if not version: + version = max(versions.keys()) + + elif isinstance(version, float): + # Using floats, converting to int. + version = int(version * 10) + + try: + ans_path = versions[version] + except KeyError as e: + raise ValueError(f"Version {version} not found. Available versions are {list(versions.keys())}") from e + + version = abs(version) + return ans_path, str(version) + + +def find_mechanical( + version: Optional[float] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Union[Tuple[str, float], Tuple[Literal[""], Literal[""]]]: + """ + Search for the Mechanical path in the standard installation location. + + Returns + ------- + mechanical_path : str + Full path to the executable file for the latest Mechanical version. + version : float | str + Version in the float format. For example, ``25.1`` for 2025 R1. + If no version has be found, version is set to "" + + Examples + -------- + On Windows: + + >>> from ansys.tools.path import find_mechanical + >>> find_mechanical() + ('C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe', 25.1) + + On Linux: + + >>> find_mechanical() + ('/usr/ansys_inc/v251/aisol/.workbench', 25.1) + """ + ans_path, version = _get_unified_install_base_for_version(version, supported_versions) + if not ans_path or not version: + return "", "" + + ans_path = Path(ans_path) + if is_windows(): # pragma: no cover + mechanical_bin = ans_path / "aisol" / "bin" / "winx64" / "AnsysWBU.exe" + else: + mechanical_bin = ans_path / "aisol" / ".workbench" + + return str(mechanical_bin), int(version) / 10 + + +def find_mapdl( + version: Optional[Union[int, float]] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Union[Tuple[str, float], Tuple[Literal[""], Literal[""]]]: + """Search for Ansys MAPDL path within the standard install location. + + Returns the path of the latest version. + + Parameters + ---------- + version : int, float, optional + Version of Ansys MAPDL to search for. + If using ``int``, it should follow the convention ``XXY``, where + ``XX`` is the major version, + and ``Y`` is the minor. + If using ``float``, it should follow the convention ``XX.Y``, where + ``XX`` is the major version, + and ``Y`` is the minor. + If ``None``, use latest available version on the machine. + + Returns + ------- + ansys_path : str + Full path to ANSYS executable. + + version : float + Version float. For example, 25.1 corresponds to 2025R1. + + Examples + -------- + Within Windows + + >>> from ansys.tools.path import find_mapdl + >>> find_mapdl() + 'C:/Program Files/ANSYS Inc/v251/ANSYS/bin/winx64/ansys251.exe', 25.1 + + Within Linux + + >>> find_mapdl() + (/usr/ansys_inc/v251/ansys/bin/ansys251, 25.1) + """ + ans_path, version = _get_unified_install_base_for_version(version, supported_versions) + if not ans_path or not version: + return "", "" + + ansys_bin_path = Path(ans_path) / "ansys" / "bin" + if is_windows(): + ansys_bin = ansys_bin_path / "winx64" / f"ansys{version}.exe" + else: + ansys_bin = ansys_bin_path / f"ansys{version}" + + return str(ansys_bin), int(version) / 10 + + +def find_dyna( + version: Optional[Union[int, float]] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Union[Tuple[str, float], Tuple[Literal[""], Literal[""]]]: + """Search for Ansys LS-Dyna path within the standard install location. + + Returns the path of the latest version. + + Parameters + ---------- + version : int, float, optional + Version of Ansys LS-Dyna to search for. + If using ``int``, it should follow the convention ``XXY``, where + ``XX`` is the major version, + and ``Y`` is the minor. + If using ``float``, it should follow the convention ``XX.Y``, where + ``XX`` is the major version, + and ``Y`` is the minor. + If ``None``, use latest available version on the machine. + + Returns + ------- + ansys_path : str + Full path to Ansys LS-Dyna executable. + + version : float + Version float. For example, 25.1 corresponds to 2025R1. + + Examples + -------- + Within Windows + + >>> from ansys.tools.path import find_dyna + >>> find_dyna() + 'C:/Program Files/ANSYS Inc/v251/ANSYS/bin/winx64/LSDYNA251.exe', 25.1 + + Within Linux + + >>> find_dyna() + (/usr/ansys_inc/v251/ansys/bin/lsdyna251, 25.1) + """ + ans_path, version = _get_unified_install_base_for_version(version, supported_versions) + if not ans_path or not version: + return "", "" + + ansys_bin_path = Path(ans_path) / "ansys" / "bin" + if is_windows(): + ansys_bin = ansys_bin_path / "winx64" / f"LSDYNA{version}.exe" + else: + ansys_bin = ansys_bin_path / f"lsdyna{version}" + + return str(ansys_bin), int(version) / 10 + + +def _find_installation( + product: str, + version: Optional[float] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Union[Tuple[str, float], Tuple[Literal[""], Literal[""]]]: + """Find the installation path for a certain product. + + Parameters + ---------- + product : str + The product type, either "mapdl", "mechanical", or "dyna". + version : float, optional + The version of the product to search for. If not provided, the latest version is used. + supported_versions : SUPPORTED_VERSIONS_TYPE, optional + A dictionary of supported versions for the product. Defaults to `SUPPORTED_ANSYS_VERSIONS`. + + Returns + ------- + Tuple[str, float] | Tuple[Literal[""], Literal[""]] + A tuple containing the path to the executable and the version number. + """ + if product == "mapdl": + return find_mapdl(version, supported_versions) + elif product == "mechanical": + return find_mechanical(version, supported_versions) + elif product == "dyna": + return find_dyna(version, supported_versions) + raise Exception("unexpected product") + + +def find_ansys( + version: Optional[float] = None, + supported_versions: SUPPORTED_VERSIONS_TYPE = SUPPORTED_ANSYS_VERSIONS, +) -> Union[Tuple[str, float], Tuple[Literal[""], Literal[""]]]: + """Obsolete method, use find_mapdl.""" + warnings.warn( + "This method is going to be deprecated in future versions. Please use 'find_mapdl'.", + category=DeprecationWarning, + ) + + return _find_installation("mapdl", version, supported_versions) + + +def _has_plugin(product: str) -> bool: + """Check if a product has a plugin defined in PLUGINS. + + Parameters + ---------- + product : str + The product type, either "mapdl", "mechanical", or "dyna". + + Returns + ------- + bool + True if the product has a plugin defined in PLUGINS, False otherwise. + """ + return product in PLUGINS + + +def is_valid_executable_path(product: PRODUCT_TYPE, exe_loc: str) -> bool: + """Check if the executable path is valid for the product. + + Parameters + ---------- + product : PRODUCT_TYPE + The product type, either "mapdl", "mechanical", or "dyna". + exe_loc : str + The executable path to check. + + Returns + ------- + bool + True if the executable path is valid for the product, False otherwise. + """ + return PLUGINS[product].is_valid_executable_path(exe_loc) + + +def _is_common_executable_path(product: PRODUCT_TYPE, exe_loc: str) -> bool: + """Check if the executable path is a common path for the product. + + Parameters + ---------- + product : PRODUCT_TYPE + The product type, either "mapdl", "mechanical", or "dyna". + exe_loc : str + The executable path to check. + + Returns + ------- + bool + True if the executable path is a common path for the product, False otherwise. + """ + exe_path = Path(exe_loc) + exe_str = str(exe_path) + + if product == "mapdl": + v_version = re.findall(r"v(\d\d\d)", exe_str) + ansys_version = re.findall(r"ansys(\d\d\d)", exe_str, re.IGNORECASE) + + return ( + len(v_version) != 0 + and len(ansys_version) != 0 + and v_version[-1] == ansys_version[-1] + and is_valid_executable_path("mapdl", exe_str) + and "ansys" in exe_path.parts + and "bin" in exe_path.parts + ) + + elif product == "dyna": + return "dyna" in exe_str + + elif product == "mechanical": + is_valid_path = is_valid_executable_path("mechanical", exe_str) + + if is_windows(): # pragma: no cover + parts = [part.lower() for part in exe_path.parts] + return ( + is_valid_path + and re.search(r"v\d\d\d", exe_str) is not None + and "aisol" in parts + and "bin" in parts + and "winx64" in parts + and "ansyswbu.exe" in parts + ) + + return ( + is_valid_path + and re.search(r"v\d\d\d", exe_str) is not None + and "aisol" in exe_path.parts + and ".workbench" in exe_path.parts + ) + + else: + raise Exception("unexpected application") + + +def _change_default_path(application: str, exe_loc: str) -> None: + exe_path = Path(exe_loc) + if exe_path.is_file(): + config_data = _read_config_file() + config_data[application] = str(exe_path) + _write_config_file(config_data) + else: + raise FileNotFoundError(f"File {exe_loc} is invalid or does not exist") + + +def change_default_mapdl_path(exe_loc: str) -> None: + """Change your default Ansys MAPDL path. + + Parameters + ---------- + exe_loc : str + Ansys MAPDL executable path. Must be a full path. + + Examples + -------- + Change default Ansys MAPDL location on Linux + + >>> from ansys.tools.path import change_default_mapdl_path, get_mapdl_path + >>> change_default_mapdl_path("/ansys_inc/v251/ansys/bin/ansys251") + >>> get_mapdl_path() + '/ansys_inc/v251/ansys/bin/ansys251' + + Change default Ansys location on Windows + + >>> mapdl_path = "C:/Program Files/ANSYS Inc/v251/ansys/bin/winx64/ANSYS251.exe" + >>> change_default_mapdl_path(mapdl_path) + + """ + _change_default_path("mapdl", exe_loc) + + +def change_default_dyna_path(exe_loc: str) -> None: + """Change your default Ansys LS-Dyna path. + + Parameters + ---------- + exe_loc : str + path to LS-Dyna executable. Must be a full path. This need not contain the name of the executable, + because the name of the LS-Dyna executable depends on the precision. + + Examples + -------- + Change default Ansys LS-Dyna location on Linux + + >>> from ansys.tools.path import change_default_dyna_path, get_dyna_path + >>> change_default_dyna_path("/ansys_inc/v251/ansys/bin/lsdyna251") + >>> get_dyna_path() + '/ansys_inc/v251/ansys/bin/lsdyna251' + + Change default Ansys LS-Dyna location on Windows + + >>> dyna_path = "C:/Program Files/ANSYS Inc/v251/ansys/bin/winx64/LSDYNA251.exe" + >>> change_default_dyna_path(dyna_path) + + """ + _change_default_path("dyna", exe_loc) + + +def change_default_mechanical_path(exe_loc: str) -> None: + """Change your default Mechanical path. + + Parameters + ---------- + exe_loc : str + Full path for the Mechanical executable file to use. + + Examples + -------- + On Windows: + + >>> from ansys.tools.path import change_default_mechanical_path, get_mechanical_path + >>> change_default_mechanical_path("C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe") + >>> get_mechanical_path() + 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe' + + On Linux: + + >>> from ansys.tools.path import change_default_mechanical_path, get_mechanical_path + >>> change_default_mechanical_path("/ansys_inc/v251/aisol/.workbench") + >>> get_mechanical_path() + '/ansys_inc/v251/aisol/.workbench' + + """ + _change_default_path("mechanical", exe_loc) + + +def change_default_ansys_path(exe_loc: str) -> None: + """Deprecated, use `change_default_mapdl_path` instead.""" # noqa: D401 + warnings.warn( + "This method is going to be deprecated in future versions. Please use 'change_default_mapdl_path'.", + category=DeprecationWarning, + ) + + _change_default_path("mapdl", exe_loc) + + +def _save_path(product: str, exe_loc: Optional[str] = None, allow_prompt: bool = True) -> str: + has_plugin = _has_plugin(product) + if exe_loc is None and has_plugin: + exe_loc, _ = _find_installation(product) + if exe_loc == "" and allow_prompt: + exe_loc = _prompt_path(product) # pragma: no cover + + if has_plugin: + if is_valid_executable_path(product, exe_loc): + _check_uncommon_executable_path(product, exe_loc) + _change_default_path(product, exe_loc) + return exe_loc + + +def save_mechanical_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> str: # pragma: no cover + """Find the Mechanical path or query user. + + Parameters + ---------- + exe_loc : string, optional + Path for the Mechanical executable file (``AnsysWBU.exe``). + The default is ``None``, in which case an attempt is made to + obtain the path from the following sources in this order: + + - The default Mechanical paths (for example, + ``C:/Program Files/Ansys Inc/vXXX/aisol/bin/AnsysWBU.exe``) + - The configuration file + - User input + + If a path is supplied, this method performs some checks. If the + checks are successful, it writes this path to the configuration + file. + + Returns + ------- + str + Path for the Mechanical executable file. + + Notes + ----- + The location of the configuration file ``config.txt`` can be found in + ``ansys.tools.path.SETTINGS_DIR``. For example: + + .. code:: pycon + + >>> from ansys.tools.path import SETTINGS_DIR + >>> import os + >>> print(os.path.join(SETTINGS_DIR, "config.txt")) + C:/Users/[username]]/AppData/Local/Ansys/ansys_tools_path/config.txt + + You can change the default for the ``exe_loc`` parameter either by modifying the + ``config.txt`` file or by running this code: + + .. code:: pycon + + >>> from ansys.tools.path import save_mechanical_path + >>> save_mechanical_path("/new/path/to/executable") + + """ + return _save_path("mechanical", exe_loc, allow_prompt) + + +def save_dyna_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> str: + """Find Ansys LD-Dyna's path or query user. + + If no ``exe_loc`` argument is supplied, this function attempt + to obtain the Ansys LS-Dyna executable from (and in order): + + - The default ansys paths (i.e. ``'C:/Program Files/Ansys Inc/vXXX/ansys/bin/winx64/LSDYNAXXX'``) + - The configuration file + - User input + + If ``exe_loc`` is supplied, this function does some checks. + If successful, it will write that ``exe_loc`` into the config file. + + Parameters + ---------- + exe_loc : str, optional + Path of the LS-Dyna executable ('lsdynaXXX'), by default ``None``. + + Returns + ------- + str + Path of the LS-Dyna executable. + + Notes + ----- + The location of the configuration file ``config.txt`` can be found in + ``ansys.tools.path.SETTINGS_DIR``. For example: + + .. code:: pycon + + >>> from ansys.tools.path import SETTINGS_DIR + >>> import os + >>> print(os.path.join(SETTINGS_DIR, "config.txt")) + C:/Users/[username]/AppData/Local/Ansys/ansys_tools_path/config.txt + + Examples + -------- + You can change the default ``exe_loc`` either by modifying the mentioned + ``config.txt`` file or by executing: + + >>> from ansys.tools.path import save_dyna_path + >>> save_dyna_path("/new/path/to/executable") + + """ + return _save_path("dyna", exe_loc, allow_prompt) + + +def save_mapdl_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> str: + """Find Ansys MAPDL's path or query user. + + If no ``exe_loc`` argument is supplied, this function attempt + to obtain the Ansys MAPDL executable from (and in order): + + - The default ansys paths (i.e. ``'C:/Program Files/Ansys Inc/vXXX/ansys/bin/winx64/ansysXXX'``) + - The configuration file + - User input + + If ``exe_loc`` is supplied, this function does some checks. + If successful, it will write that ``exe_loc`` into the config file. + + Parameters + ---------- + exe_loc : str, optional + Path of the MAPDL executable ('ansysXXX'), by default ``None``. + + Returns + ------- + str + Path of the MAPDL executable. + + Notes + ----- + The location of the configuration file ``config.txt`` can be found in + ``ansys.tools.path.SETTINGS_DIR``. For example: + + .. code:: pycon + + >>> from ansys.tools.path import SETTINGS_DIR + >>> import os + >>> print(os.path.join(SETTINGS_DIR, "config.txt")) + C:/Users/[username]/AppData/Local/Ansys/ansys_tools_path/config.txt + + Examples + -------- + You can change the default ``exe_loc`` either by modifying the mentioned + ``config.txt`` file or by executing: + + >>> from ansys.tools.path import save_mapdl_path + >>> save_mapdl_path("/new/path/to/executable") + + """ + return _save_path("mapdl", exe_loc, allow_prompt) + + +def save_ansys_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> str: + """Deprecated, use `save_mapdl_path` instead.""" # noqa: D401 + warnings.warn( + "This method is going to be deprecated in future versions. Please use 'save_ansys_path'.", + category=DeprecationWarning, + ) + return _save_path("mapdl", exe_loc, allow_prompt) + + +def _check_uncommon_executable_path(product: PRODUCT_TYPE, exe_loc: str): + """Check if the supplied executable path is not a common one. + + Parameters + ---------- + product : PRODUCT_TYPE + Product name, one of "mapdl", "mechanical", or "dyna". + exe_loc : str + Path to the executable file. + """ + if not _is_common_executable_path(product, exe_loc): + product_pattern_path = PRODUCT_EXE_INFO[product]["patternpath"] + product_name = PRODUCT_EXE_INFO[product]["name"] + warnings.warn( + f"The supplied path ('{exe_loc}') does not match the usual {product_name} executable path style" + f"('directory/{product_pattern_path}'). " + "You might have problems at later use." + ) + + +def _prompt_path(product: PRODUCT_TYPE) -> str: # pragma: no cover + """Prompt for the CLI. + + Parameters + ---------- + product : PRODUCT_TYPE + Product name, one of "mapdl", "mechanical", or "dyna". + + Returns + ------- + str + The path to the executable for the specified product. + """ + product_info = PRODUCT_EXE_INFO[product] + product_name = product_info["name"] + has_pattern = "pattern" in product_info and "patternpath" in product_info + print(f"Cached {product} executable not found") + print(f"You are about to enter manually the path of the {product_name} executable\n") + if has_pattern: + product_pattern = product_info["pattern"] + product_pattern_path = product_info["patternpath"] + print( + f"({product_pattern}, where XXX is the version\n" + f"This file is very likely to contained in path ending in '{product_pattern_path}'.\n" + ) + print( + "\nIf you experience problems with the input path you can overwrite the configuration\n" + "file by typing:\n" + f">>> from ansys.tools.path import save_{product}_path\n" + f">>> save_{product}_path('/new/path/to/executable/')\n" + ) + while True: + if has_pattern: + exe_loc = input(f"Enter the location of {product_name} ({product_pattern}):") + else: + exe_loc = input(f"Enter the location of {product_name}:") + + if is_valid_executable_path(product, exe_loc): + _check_uncommon_executable_path(product, exe_loc) + config_data = _read_config_file() + config_data[product] = exe_loc + _write_config_file(config_data) + break + else: + if has_pattern: + print( + f"The supplied path is either: not a valid file path, or does not match '{product_pattern}' name." + ) + else: + print("The supplied path is either: not a valid file path.") + return exe_loc + + +def clear_configuration(product: Union[PRODUCT_TYPE, Literal["all"]]) -> None: + """Clear the entry of the specified product in the configuration file.""" + config = _read_config_file() # we use read_config_file here because it will do the migration if necessary + if product == "all": + _write_config_file({}) + return + if product in config: + del config[product] + _write_config_file(config) + + +def _read_config_file() -> Dict[PRODUCT_TYPE, str]: + """Read config file for a given product, migrating if needed.""" + config_path = Path(CONFIG_FILE) + + if not config_path.is_file(): + _migrate_config_file() + + if config_path.is_file(): + content = config_path.read_text() + if content: + return json.loads(content) + + return {} + + +def _write_config_file(config_data: Dict[PRODUCT_TYPE, str]): + """Warning - this isn't threadsafe.""" + config_path = Path(CONFIG_FILE) + config_path.write_text(json.dumps(config_data)) + + +def _migrate_config_file() -> None: + """Migrate configuration if needed.""" + + def _migrate_txt_config_file() -> Dict[PRODUCT_TYPE, str]: + old_mapdl_config_path = Path(platformdirs.user_data_dir("ansys_mapdl_core")) / "config.txt" + old_mechanical_config_path = Path(platformdirs.user_data_dir("ansys_mechanical_core")) / "config.txt" + config_file_path = Path(CONFIG_FILE) + + if config_file_path.is_file(): + new_config_data = _read_config_file() + else: + new_config_data: Dict[PRODUCT_TYPE, str] = {} + + if "mapdl" not in new_config_data and old_mapdl_config_path.exists(): + new_config_data["mapdl"] = old_mapdl_config_path.read_text() + + if "mechanical" not in new_config_data and old_mechanical_config_path.exists(): + new_config_data["mechanical"] = old_mechanical_config_path.read_text() + + return new_config_data + + def _migrate_json_config_file() -> Dict[PRODUCT_TYPE, str]: # pragma: no cover + config_path = Path(platformdirs.user_data_dir("ansys_tools_path")) / "config.txt" + try: + old_config_data = config_path.read_text() + return cast(Dict[PRODUCT_TYPE, str], json.loads(old_config_data)) + except (ValueError, FileNotFoundError): + # If the config file cannot be parsed or does not exist, return an empty dict + return {} + + @dataclass + class FileMigrationStrategy: + paths: List[Path] + migration_function: Callable[[], Dict[PRODUCT_TYPE, str]] + + def __call__(self): + return self.migration_function() + + file_migration_strategy_list: List[FileMigrationStrategy] = [ + FileMigrationStrategy( + [ + Path(platformdirs.user_data_dir("ansys_mapdl_core")) / "config.txt", + Path(platformdirs.user_data_dir("ansys_mechanical_core")) / "config.txt", + ], + _migrate_txt_config_file, + ), + FileMigrationStrategy( + [Path(platformdirs.user_data_dir("ansys_tools_path")) / "config.txt"], + _migrate_json_config_file, + ), + ] + + # Filter to only keep config files that exist + file_migration_strategy_list = [ + strategy for strategy in file_migration_strategy_list if any(p.exists() for p in strategy.paths) + ] + + if not file_migration_strategy_list: + return + + # Use the migration strategy of the last file + latest_strategy = file_migration_strategy_list[-1] + _write_config_file(latest_strategy()) + + # Remove all old config files + for strategy in file_migration_strategy_list: + for path in strategy.paths: + if path.exists(): + path.unlink() + + +def _read_executable_path_from_config_file(product_name: PRODUCT_TYPE) -> Optional[str]: + """Read the executable path for the product given by `product_name` from config file. + + Parameters + ---------- + product_name : PRODUCT_TYPE + Name of the product to get the executable path for. For example, "mapdl", "dyna", or "mechanical". + + Returns + ------- + Optional[str] + The path to the executable if it exists in the configuration file, otherwise `None`. + """ + config_data = _read_config_file() + return config_data.get(product_name, None) + + +def get_saved_application_path(application: str) -> Optional[str]: + """Get the saved path for a specific application from the configuration file. + + Parameters + ---------- + application : str + Name of the application to get the path for. For example, "mapdl", "dyna", or "mechanical". + + Returns + ------- + Optional[str] + The path to the executable if it exists in the configuration file, otherwise `None`. + """ + exe_loc = _read_executable_path_from_config_file(application) + return exe_loc + + +def _get_application_path( + product: str, + allow_input: bool = True, + version: Optional[float] = None, + find: bool = True, +) -> Optional[str]: + _exe_loc = _read_executable_path_from_config_file(product) + if _exe_loc is not None: + if version is None: + return _exe_loc + else: + _version = version_from_path(product, _exe_loc) + if _version == version: + return _exe_loc + else: + LOG.debug( + f"Application {product} requested version {version} does not match with {_version} " + f"in config file. Trying to find version {version} ..." + ) + + LOG.debug(f"{product} path not found in config file") + if not _has_plugin(product): + raise Exception(f"Application {product} not registered.") + + if find: + try: + exe_loc, exe_version = _find_installation(product, version) + if (exe_loc, exe_version) != ("", ""): + if Path(exe_loc).is_file(): + return exe_loc + except ValueError: + pass # Continue to allow_input check + + if allow_input: + exe_loc = _prompt_path(product) + _change_default_path(product, exe_loc) + return exe_loc + + warnings.warn(f"No path found for {product} in default locations.") + return None + + +def get_mapdl_path(allow_input: bool = True, version: Optional[float] = None, find: bool = True) -> Optional[str]: + """Acquires Ansys MAPDL Path. + + First, it looks in the configuration file, used by `save_mapdl_path` + Then, it tries to find it based on conventions for where it usually is. + Lastly, it takes user input + + Parameters + ---------- + allow_input : bool, optional + Allow user input to find Ansys MAPDL path. The default is ``True``. + + version : float, optional + Version of Ansys MAPDL to search for. For example ``version=25.1``. + If ``None``, use latest. + + find: bool, optional + Allow ansys-tools-path to search for Ansys Mechanical in typical installation locations + + """ + return _get_application_path("mapdl", allow_input, version, find) + + +def get_dyna_path(allow_input: bool = True, version: Optional[float] = None, find: bool = True) -> Optional[str]: + """Acquires Ansys LS-Dyna Path from a cached file or user input. + + First, it looks in the configuration file, used by `save_dyna_path` + Then, it tries to find it based on conventions for where it usually is. + Lastly, it takes user input + + Parameters + ---------- + allow_input : bool, optional + Allow user input to find Ansys LS-Dyna path. The default is ``True``. + + version : float, optional + Version of Ansys LS-Dyna to search for. For example ``version=25.1``. + If ``None``, use latest. + + find: bool, optional + Allow ansys-tools-path to search for Ansys Mechanical in typical installation locations + + """ + return _get_application_path("dyna", allow_input, version, find) + + +def get_ansys_path(allow_input: bool = True, version: Optional[float] = None) -> Optional[str]: + """Deprecated, use `get_mapdl_path` instead.""" # noqa: D401 + warnings.warn( + "This method is going to be deprecated in future versions. Please use 'get_mapdl_path'.", + category=DeprecationWarning, + ) + return _get_application_path("mapdl", allow_input, version, True) + + +def get_mechanical_path(allow_input: bool = True, version: Optional[float] = None, find: bool = True) -> Optional[str]: + """Acquires Ansys Mechanical Path. + + First, it looks in the configuration file, used by `save_mechanical_path` + Then, it tries to find it based on conventions for where it usually is. + Lastly, it takes user input + + Parameters + ---------- + allow_input : bool, optional + Allow user input to find Ansys Mechanical path. The default is ``True``. + + version : float, optional + Version of Ansys Mechanical to search for. For example ``version=25.1``. + If ``None``, use latest. + + find: bool, optional + Allow ansys-tools-path to search for Ansys Mechanical in typical installation locations + + """ + return _get_application_path("mechanical", allow_input, version, find) + + +def _version_from_path(path: str, product_name: str, path_version_regex: str) -> int: + r"""Extract the version from the executable path. + + Parameters + ---------- + path: str + The path to the Ansys executable. + product_name: str + The name of the product. For example: + + mapdl = "Ansys MAPDL" + mechanical = "Ansys Mechanical" + + path_version_regex: str + The regex used to find the Ansys version in the executable path. For example: + + mapdl = r"v(\d\d\d).ansys" + mechanical = r'v(\d\d\d)' + + Returns + ------- + int + The version in the executable path. For example, "251". + """ + error_message = f"Unable to extract {product_name} version from {path}." + if path: + # expect v/ansys + # replace \\ with / to account for possible Windows path + matches = re.findall(rf"{path_version_regex}", path.replace("\\", "/"), re.IGNORECASE) + if not matches: + raise RuntimeError(error_message) + return int(matches[-1]) + else: + raise RuntimeError(error_message) + + +def version_from_path(product: PRODUCT_TYPE, path: str) -> int: + """Extract the product version from a path. + + Parameters + ---------- + path : str + The path to the Ansys executable. For example: + + Mechanical: + - Windows: ``C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe`` + - Linux: ``/usr/ansys_inc/v251/aisol/.workbench`` + + MAPDL: + - Windows: ``C:/Program Files/ANSYS Inc/v251/ansys/bin/winx64/ANSYS251.exe`` + - Linux: ``/usr/ansys_inc/v251/ansys/bin/mapdl`` + + product: PRODUCT_TYPE + The product. For example: mapdl, mechanical, or dyna. + + Returns + ------- + int + Integer version number (for example, 251). + + """ + product_name = PRODUCT_EXE_INFO[product]["name"] + if not isinstance(path, str): + raise ValueError( + f'The provided path, "{path}", is not a valid string. ' + f"Run the following command to save the path to the {product_name} executable:\n\n" + f" save-ansys-path --name {product} /path/to/{product}-executable\n" + ) + if (product != "dyna") and (product in PRODUCT_EXE_INFO.keys()): + path_version_regex = r"v(\d\d\d).ansys" if product == "mapdl" else r"v(\d\d\d)" + return _version_from_path(path, product_name, path_version_regex) + else: + raise Exception(f"Unexpected product, {product}") + + +def get_latest_ansys_installation() -> Tuple[int, str]: + """Return a tuple with the latest ansys installation version and its path. + + If there is a student version and a regular installation for the latest release, the regular one is returned + + Returns + ------- + Tuple[int, str] + Tuple with the latest version and path of the installation + + Raises + ------ + ValueError + No Ansys installation found + """ + installations = get_available_ansys_installations() + if not installations: + raise ValueError("No Ansys installation found") + + def sort_key(version: int) -> float: + if version < 0: + return abs(version) - 0.5 + return float(version) + + max_version = max(installations, key=sort_key) + return (max_version, installations[max_version]) diff --git a/src/ansys/tools/path/py.typed b/src/ansys/tools/path/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/ansys/tools/path/save.py b/src/ansys/tools/path/save.py new file mode 100644 index 00000000..ba741384 --- /dev/null +++ b/src/ansys/tools/path/save.py @@ -0,0 +1,58 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Convenience CLI to save path for ansys application in configuration.""" + +import click + +from ansys.tools.path.path import _save_path + + +@click.command() +@click.help_option("--help", "-h") +@click.argument("location") +@click.option( + "--name", + default=None, + type=str, + help='Application name. For example, "mapdl", "mechanical", or "dyna"', +) +@click.option( + "--allow-prompt", + is_flag=True, + default=False, + help="Allow prompt. Used in case a path is not given or the given path is not valid", +) +def cli( + name: str, + location: str, + allow_prompt: bool, +): + """CLI tool to store the path of a solver. + + USAGE: + + The following example demonstrates the main use of this tool: + + $ save-ansys-path --name dyna /path/to/dyna + """ + _save_path(name, location, allow_prompt) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b036ea76 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Pytest configuration.""" + +import sys + +import pytest + +ALL = set("darwin linux win32".split()) + + +def pytest_runtest_setup(item): + """Add platform-specific test skipping.""" + supported_platforms = ALL.intersection(mark.name for mark in item.iter_markers()) + plat = sys.platform + if supported_platforms and plat not in supported_platforms: + pytest.skip("cannot run on platform {}".format(plat)) diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_integration.py new file mode 100644 index 00000000..e74d9d87 --- /dev/null +++ b/tests/path/integration/test_integration.py @@ -0,0 +1,86 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for integration tests.""" + +import json +import os +from pathlib import Path + +import pytest + +from ansys.tools.path import ( + clear_configuration, + find_mapdl, + get_available_ansys_installations, + save_mapdl_path, +) +from ansys.tools.path.path import CONFIG_FILE + +skip_if_not_ansys_local = pytest.mark.skipif( + os.environ.get("ANSYS_LOCAL", "").upper() != "TRUE", reason="Skipping on CI" +) + + +@skip_if_not_ansys_local +def test_find_mapdl(): + """Test that the function finds the MAPDL executable and returns its path and version.""" + bin_file, ver = find_mapdl() + assert Path(bin_file).is_file() + assert ver != "" + + +@skip_if_not_ansys_local +def test_get_available_ansys_installation(): + """Test that the function returns a list of available Ansys installations.""" + assert get_available_ansys_installations() + + +@skip_if_not_ansys_local +@pytest.mark.linux +def test_save_mapdl_path(): + """Test saving the MAPDL path to the configuration file.""" + config_path = Path(CONFIG_FILE) + + # Backup existing config content if the config file exists + old_config = config_path.read_text() if config_path.is_file() else None + + # Find the MAPDL executable path for version 222 + path, _ = find_mapdl(version=222) + + # Save the found MAPDL path to the config + assert save_mapdl_path(path, allow_prompt=False) + + # Verify that the config file contains the correct mapdl path + config_data = json.loads(config_path.read_text()) + assert config_data == {"mapdl": "/ansys_inc/v222/ansys/bin/ansys222"} + + # Test saving None path does not overwrite the saved config + assert save_mapdl_path(None, allow_prompt=False) + config_data = json.loads(config_path.read_text()) + assert config_data == {"mapdl": "/ansys_inc/v222/ansys/bin/ansys222"} + + # Clear all configurations after the test + clear_configuration("all") + + # Restore original config if it existed before the test + if old_config is not None: + config_path.write_text(old_config) diff --git a/tests/path/unit/test_metadata.py b/tests/path/unit/test_metadata.py new file mode 100644 index 00000000..6855b2e2 --- /dev/null +++ b/tests/path/unit/test_metadata.py @@ -0,0 +1,47 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for testing metadata.""" + +import os + +from ansys.tools.path import __version__ + + +def test_pkg_version(): + """Test that the package version matches the version in pyproject.toml.""" + import importlib.metadata as importlib_metadata + + # Read from the pyproject.toml + # major, minor, patch + read_version = importlib_metadata.version("ansys-tools-path") + + assert __version__ == read_version + + +def test_cicd_envvar(): + """Test that the environment variable `ANSYS_LOCAL` exists and is set to True or False.""" + if not os.environ.get("ANSYS_LOCAL", ""): + # env var does not exists + raise RuntimeError( + "The env var 'ANSYS_LOCAL' does not exists. That env var is needed to tell Pytest which\n" + "tests should be run depending on if MAPDL is installed ('ANSYS_LOCAL'=True) or not." + ) diff --git a/tests/path/unit/test_misc.py b/tests/path/unit/test_misc.py new file mode 100644 index 00000000..0d0ffe2a --- /dev/null +++ b/tests/path/unit/test_misc.py @@ -0,0 +1,39 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for testing misc functions.""" + +import pytest + +from ansys.tools.path.misc import is_float + +values = [ + (11, True), + (11.1, True), + ("asdf", False), + ("1234asdf", False), +] + + +@pytest.mark.parametrize("values", values) +def test_is_float(values): + """Test the is_float function.""" + assert is_float(values[0]) == values[1] diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py new file mode 100644 index 00000000..927fdc68 --- /dev/null +++ b/tests/path/unit/test_path.py @@ -0,0 +1,608 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Module for testing path functionalities.""" + +import json +import logging +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import platformdirs +import pyfakefs # noqa +import pytest + +from ansys.tools.path import ( + LOG, + SETTINGS_DIR, + change_default_ansys_path, + change_default_dyna_path, + change_default_mapdl_path, + change_default_mechanical_path, + clear_configuration, + find_ansys, + find_dyna, + find_mapdl, + find_mechanical, + get_ansys_path, + get_available_ansys_installations, + get_dyna_path, + get_latest_ansys_installation, + get_mapdl_path, + get_mechanical_path, + save_dyna_path, + save_mapdl_path, + save_mechanical_path, + version_from_path, +) + +LOG.setLevel(logging.DEBUG) + +VERSIONS = [202, 211, 231] +STUDENT_VERSIONS = [201, 211] + + +def make_path(base, *parts): + """Make a path from the base and parts.""" + return str(Path(base, *parts)) + + +if sys.platform == "win32": + ANSYS_BASE_PATH = Path("C:/Program Files/ANSYS Inc") + STUDENT_DIR = "ANSYS Student" + BIN_DIR = ["ansys", "bin", "winx64"] + + ANSYS_INSTALLATION_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}") for v in VERSIONS] + ANSYS_STUDENT_INSTALLATION_PATHS = [make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}") for v in STUDENT_VERSIONS] + + MAPDL_INSTALL_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}", *BIN_DIR, f"ansys{v}.exe") for v in VERSIONS] + MAPDL_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", *BIN_DIR, f"ansys{v}.exe") for v in STUDENT_VERSIONS + ] + + DYNA_INSTALL_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}", *BIN_DIR, f"lsdyna{v}.exe") for v in VERSIONS] + DYNA_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", *BIN_DIR, f"lsdyna{v}.exe") for v in STUDENT_VERSIONS + ] + + MECHANICAL_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, f"v{v}", "aisol", "bin", "winx64", "ansyswbu.exe") for v in VERSIONS + ] + MECHANICAL_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", "aisol", "bin", "winx64", "ansyswbu.exe") + for v in STUDENT_VERSIONS + ] + +else: + ANSYS_BASE_PATH = Path("/ansys_inc") + STUDENT_DIR = "ANSYS Student" + BIN_DIR = ["ansys", "bin"] + + ANSYS_INSTALLATION_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}") for v in VERSIONS] + ANSYS_STUDENT_INSTALLATION_PATHS = [make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}") for v in STUDENT_VERSIONS] + + MAPDL_INSTALL_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}", *BIN_DIR, f"ansys{v}") for v in VERSIONS] + MAPDL_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", *BIN_DIR, f"ansys{v}") for v in STUDENT_VERSIONS + ] + + DYNA_INSTALL_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}", *BIN_DIR, f"lsdyna{v}") for v in VERSIONS] + DYNA_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", *BIN_DIR, f"lsdyna{v}") for v in STUDENT_VERSIONS + ] + + MECHANICAL_INSTALL_PATHS = [make_path(ANSYS_BASE_PATH, f"v{v}", "aisol", ".workbench") for v in VERSIONS] + MECHANICAL_STUDENT_INSTALL_PATHS = [ + make_path(ANSYS_BASE_PATH, STUDENT_DIR, f"v{v}", "aisol", ".workbench") for v in STUDENT_VERSIONS + ] + + +# Safe fallback access to the latest paths +def latest(path_list): + """Return the latest path from a list of paths.""" + return path_list[-1] if path_list else None + + +LATEST_ANSYS_INSTALLATION_PATHS = latest(ANSYS_INSTALLATION_PATHS) +LATEST_MAPDL_INSTALL_PATH = latest(MAPDL_INSTALL_PATHS) +LATEST_DYNA_INSTALL_PATH = latest(DYNA_INSTALL_PATHS) +LATEST_MECHANICAL_INSTALL_PATH = latest(MECHANICAL_INSTALL_PATHS) + + +@pytest.fixture +def mock_filesystem(fs): + """Mock a filesystem with Ansys installations for testing purposes.""" + for mapdl_install_path in MAPDL_INSTALL_PATHS + MAPDL_STUDENT_INSTALL_PATHS: + fs.create_file(mapdl_install_path) + for mechanical_install_path in MECHANICAL_INSTALL_PATHS + MECHANICAL_STUDENT_INSTALL_PATHS: + fs.create_file(mechanical_install_path) + for dyna_install_path in DYNA_INSTALL_PATHS + DYNA_STUDENT_INSTALL_PATHS: + fs.create_file(dyna_install_path) + fs.create_dir(platformdirs.user_data_dir(appname="ansys_tools_path", appauthor="Ansys")) + return fs + + +@pytest.fixture +def mock_filesystem_without_student_versions(fs): + """Mock a filesystem without student versions of Ansys installations.""" + for mapdl_install_path in MAPDL_INSTALL_PATHS: + fs.create_file(mapdl_install_path) + for mechanical_install_path in MECHANICAL_INSTALL_PATHS: + fs.create_file(mechanical_install_path) + for dyna_install_path in DYNA_INSTALL_PATHS: + fs.create_file(dyna_install_path) + fs.create_dir(platformdirs.user_data_dir(appname="ansys_tools_path", appauthor="Ansys")) + + +@pytest.fixture +def mock_filesystem_with_config(mock_filesystem): + """Mock the filesystem with a config file for testing purposes.""" + config_path = Path(platformdirs.user_data_dir(appname="ansys_tools_path", appauthor="Ansys")) / "config.txt" + mock_filesystem.create_file(str(config_path)) + config_content = json.dumps( + { + "mapdl": LATEST_MAPDL_INSTALL_PATH, + "mechanical": LATEST_MECHANICAL_INSTALL_PATH, + "dyna": LATEST_DYNA_INSTALL_PATH, + } + ) + config_path.write_text(config_content) + return mock_filesystem + + +@pytest.fixture +def mock_filesystem_with_empty_config(mock_filesystem): + """Mock the filesystem with an empty config file for testing purposes.""" + config_path = Path(platformdirs.user_data_dir(appname="ansys_tools_path", appauthor="Ansys")) / "config.txt" + mock_filesystem.create_file(str(config_path)) + config_path.write_text("") + return mock_filesystem + + +@pytest.fixture +def mock_filesystem_without_executable(fs): + """Mock the filesystem without executable files for testing purposes.""" + fs.create_dir(ANSYS_BASE_PATH) + + +@pytest.fixture +def mock_empty_filesystem(fs): + """Mock an empty filesystem for testing purposes.""" + return fs + + +@pytest.fixture +def mock_filesystem_with_only_old_config(mock_filesystem): + """Mock the filesystem with an old config file that only contains the MAPDL path.""" + config1_path = Path(platformdirs.user_data_dir(appname="ansys_mapdl_core")) / "config.txt" + mock_filesystem.create_file(str(config1_path)) + config1_path.write_text(MAPDL_INSTALL_PATHS[0]) + + config2_path = Path(platformdirs.user_data_dir(appname="ansys_tools_path")) / "config.txt" + mock_filesystem.create_file(str(config2_path)) + config2_path.write_text( + json.dumps({"mapdl": LATEST_MAPDL_INSTALL_PATH, "mechanical": LATEST_MECHANICAL_INSTALL_PATH}) + ) + + return mock_filesystem + + +@pytest.fixture +def mock_filesystem_with_only_oldest_config(mock_filesystem): + """Mock the filesystem with an old config file that only contains the MAPDL path.""" + config_path = Path(platformdirs.user_data_dir(appname="ansys_mapdl_core")) / "config.txt" + mock_filesystem.create_file(str(config_path)) + config_path.write_text(MAPDL_INSTALL_PATHS[0]) + + +@pytest.fixture +def mock_awp_environment_variable(monkeypatch): + """Mock the AWP_ROOT environment variables to simulate Ansys installations.""" + for awp_root_var in filter(lambda var: var.startswith("AWP_ROOT"), os.environ.keys()): + monkeypatch.delenv(awp_root_var) + for version, ansys_installation_path in zip(VERSIONS, ANSYS_INSTALLATION_PATHS): + monkeypatch.setenv(f"AWP_ROOT{version}", ansys_installation_path) + # this will replace all standard version with the student version + for version, ansys_student_installation_path in zip(STUDENT_VERSIONS, ANSYS_STUDENT_INSTALLATION_PATHS): + monkeypatch.setenv(f"AWP_ROOT{version}", ansys_student_installation_path) + + +def test_change_default_mapdl_path_file_dont_exist(mock_empty_filesystem): + """Test changing the default MAPDL path.""" + with pytest.raises(FileNotFoundError): + change_default_mapdl_path(MAPDL_INSTALL_PATHS[1]) + + +def test_change_default_dyna_path_file_dont_exist(mock_empty_filesystem): + """Test changing the default DYNA path.""" + with pytest.raises(FileNotFoundError): + change_default_dyna_path(DYNA_INSTALL_PATHS[1]) + + +@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) +def test_change_ansys_path(mock_empty_filesystem): + """Test changing the Ansys path.""" + with pytest.raises(FileNotFoundError): + change_default_ansys_path(MAPDL_INSTALL_PATHS[1]) + + +def test_change_default_mapdl_path(mock_filesystem): + """Test changing the default MAPDL path.""" + config_path = Path(platformdirs.user_data_dir("ansys_mapdl_core")) / "config.txt" + mock_filesystem.create_file(str(config_path)) + change_default_mapdl_path(MAPDL_INSTALL_PATHS[1]) + + +def test_change_default_mechanical_path(mock_filesystem): + """Test changing the default mechanical path.""" + change_default_mechanical_path(MECHANICAL_INSTALL_PATHS[1]) + + +@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) +def test_find_ansys(mock_filesystem): + """Test finding the latest Ansys installation.""" + ansys_bin, ansys_version = find_ansys() + # windows filesystem being case insensive we need to make a case insensive comparison + if sys.platform == "win32": + assert (ansys_bin.lower(), ansys_version) == (LATEST_MAPDL_INSTALL_PATH.lower(), 23.1) + else: + assert (ansys_bin, ansys_version) == (LATEST_MAPDL_INSTALL_PATH, 23.1) + + +@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) +def test_find_ansys_empty_fs(mock_empty_filesystem): + """Test finding Ansys when no installations are present.""" + ansys_bin, ansys_version = find_ansys() + assert (ansys_bin, ansys_version) == ("", "") + + +def test_find_mapdl(mock_filesystem): + """Test finding the latest MAPDL installation.""" + ansys_bin, ansys_version = find_mapdl() + # windows filesystem being case insensive we need to make a case insensive comparison + if sys.platform == "win32": + assert (ansys_bin.lower(), ansys_version) == (LATEST_MAPDL_INSTALL_PATH.lower(), 23.1) + else: + assert (ansys_bin, ansys_version) == (LATEST_MAPDL_INSTALL_PATH, 23.1) + + +def test_find_specific_mapdl(mock_filesystem, mock_awp_environment_variable): + """Test finding an specific MAPDL installation.""" + ansys_bin, ansys_version = find_mapdl(21.1) + if sys.platform == "win32": + assert (ansys_bin.lower(), ansys_version) == (MAPDL_INSTALL_PATHS[1].lower(), 21.1) + else: + assert (ansys_bin, ansys_version) == (MAPDL_INSTALL_PATHS[1], 21.1) + + +def test_find_mapdl_without_executable(mock_filesystem_without_executable): + """Test finding MAPDL without executable.""" + ansys_bin, ansys_version = find_mapdl() + assert (ansys_bin, ansys_version) == ("", "") + + +def test_find_mapdl_without_student(mock_filesystem_without_student_versions): + """Test find MAPDL no student.""" + ansys_bin, ansys_version = find_mapdl() + if sys.platform == "win32": + assert (ansys_bin.lower(), ansys_version) == (LATEST_MAPDL_INSTALL_PATH.lower(), 23.1) + else: + assert (ansys_bin, ansys_version) == (LATEST_MAPDL_INSTALL_PATH, 23.1) + + +def test_find_dyna(mock_filesystem): + """Test finding a dyna installation.""" + dyna_bin, dyna_version = find_dyna() + # windows filesystem being case insensive we need to make a case insensive comparison + if sys.platform == "win32": + assert (dyna_bin.lower(), dyna_version) == (LATEST_DYNA_INSTALL_PATH.lower(), 23.1) + else: + assert (dyna_bin, dyna_version) == (LATEST_DYNA_INSTALL_PATH, 23.1) + + +def test_find_specific_dyna(mock_filesystem, mock_awp_environment_variable): + """Test finding specific dyna install.""" + dyna_bin, dyna_version = find_dyna(21.1) + if sys.platform == "win32": + assert (dyna_bin.lower(), dyna_version) == (DYNA_INSTALL_PATHS[1].lower(), 21.1) + else: + assert (dyna_bin, dyna_version) == (DYNA_INSTALL_PATHS[1], 21.1) + + +def test_find_mechanical(mock_filesystem): + """Test find mechanical.""" + mechanical_bin, mechanical_version = find_mechanical() + if sys.platform == "win32": + assert (mechanical_bin.lower(), mechanical_version) == ( + LATEST_MECHANICAL_INSTALL_PATH.lower(), + 23.1, + ) + else: + assert (mechanical_bin, mechanical_version) == (LATEST_MECHANICAL_INSTALL_PATH, 23.1) + + +def test_find_specific_mechanical(mock_filesystem, mock_awp_environment_variable): + """Test finding specific mechanical install.""" + mechanical_bin, mechanical_version = find_mechanical(21.1) + if sys.platform == "win32": + assert (mechanical_bin.lower(), mechanical_version) == ( + MECHANICAL_INSTALL_PATHS[1].lower(), + 21.1, + ) + else: + assert (mechanical_bin, mechanical_version) == (MECHANICAL_INSTALL_PATHS[1], 21.1) + + +def test_inexistant_mechanical(mock_filesystem): + """Test inexistent mechanical path.""" + with pytest.raises(ValueError): + find_mechanical(21.6) + + +def test_find_mechanical_without_student(mock_filesystem_without_student_versions): + """Test find mechanical without student versions.""" + mechanical_bin, mechanical_version = find_mechanical() + if sys.platform == "win32": + assert (mechanical_bin.lower(), mechanical_version) == ( + LATEST_MECHANICAL_INSTALL_PATH.lower(), + 23.1, + ) + else: + assert (mechanical_bin, mechanical_version) == (LATEST_MECHANICAL_INSTALL_PATH, 23.1) + + +@pytest.mark.win32 +def test_get_available_ansys_installation_windows(mock_filesystem, mock_awp_environment_variable): + """Test get available Ansys installations on Windows.""" + available_ansys_installations = get_available_ansys_installations() + lowercase_available_ansys_installation = {} + for key, value in available_ansys_installations.items(): + lowercase_available_ansys_installation[key] = value.lower() + lowercase_ansys_installation_paths = list( + map(str.lower, ANSYS_INSTALLATION_PATHS + ANSYS_STUDENT_INSTALLATION_PATHS) + ) + assert lowercase_available_ansys_installation == dict( + zip([202, 211, 231] + [-201, -211], lowercase_ansys_installation_paths) + ) + + +@pytest.mark.linux +def test_get_available_ansys_installation_linux(mock_filesystem): + """Test get available Ansys installations on Linux.""" + assert get_available_ansys_installations() == dict( + zip( + [202, 211, 231] + [-201, -211], + ANSYS_INSTALLATION_PATHS + ANSYS_STUDENT_INSTALLATION_PATHS, + ) + ) + + +@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) +def test_get_ansys_path(mock_filesystem_with_config): + """Test get the ansys path.""" + mapdl_path = get_ansys_path() + if sys.platform == "win32": + assert mapdl_path is not None + assert mapdl_path.lower() == LATEST_MAPDL_INSTALL_PATH.lower() + else: + assert mapdl_path == LATEST_MAPDL_INSTALL_PATH + + +def test_get_mapdl_path(mock_filesystem_with_config): + """Test the get_mapdl_path function to ensure it returns the correct path.""" + mapdl_path = get_mapdl_path() + if sys.platform == "win32": + assert mapdl_path is not None + assert mapdl_path.lower() == LATEST_MAPDL_INSTALL_PATH.lower() + else: + assert mapdl_path == LATEST_MAPDL_INSTALL_PATH + + +def test_get_dyna_path(mock_filesystem_with_config): + """Test get the DYNA path.""" + dyna_path = get_dyna_path() + if sys.platform == "win32": + assert dyna_path is not None + assert dyna_path.lower() == LATEST_DYNA_INSTALL_PATH.lower() + else: + assert dyna_path == LATEST_DYNA_INSTALL_PATH + + +def test_get_mechanical_path(mock_filesystem_with_config): + """Test the get_mechanical_path function to ensure it returns the correct path.""" + mechanical_path = get_mechanical_path() + if sys.platform == "win32": + assert mechanical_path is not None + assert mechanical_path.lower() == LATEST_MECHANICAL_INSTALL_PATH.lower() + else: + assert mechanical_path == LATEST_MECHANICAL_INSTALL_PATH + + +def test_get_mechanical_path_custom(mock_filesystem): + """Test that will make the function ask for the path to the installation. + + It will mock the input with LATEST_MECHANICAL_PATH. + Doing this (even if the version and the install path don't match) + allow to check that we can enter a path for a version not detected + """ + with patch("builtins.input", side_effect=[LATEST_MECHANICAL_INSTALL_PATH]): + mechanical_path = get_mechanical_path(True, version=193) + assert mechanical_path is not None + if sys.platform == "win32": + assert mechanical_path.lower() == LATEST_MECHANICAL_INSTALL_PATH.lower() + else: + assert mechanical_path == LATEST_MECHANICAL_INSTALL_PATH + assert get_mechanical_path(False, version=193) is None + + +def test_get_mechanical_specific(mock_filesystem): + """Test the get_mechanical_path function with a specific version.""" + mechanical_path = get_mechanical_path(version=23.1) + assert mechanical_path is not None + if sys.platform == "win32": + assert mechanical_path.lower() == LATEST_MECHANICAL_INSTALL_PATH.lower() + else: + assert mechanical_path == LATEST_MECHANICAL_INSTALL_PATH + + +def test_get_latest_ansys_installation(mock_filesystem): + """Test the get_latest_ansys_installation function to ensure it returns the latest version and path correctly.""" + latest_ansys_version, latest_ansys_installation_path = get_latest_ansys_installation() + if sys.platform == "win32": + assert (latest_ansys_version, latest_ansys_installation_path.lower()) == ( + 231, + LATEST_ANSYS_INSTALLATION_PATHS.lower(), + ) + else: + assert latest_ansys_version, latest_ansys_installation_path == ( + 231, + LATEST_ANSYS_INSTALLATION_PATHS, + ) + + +def test_save_mapdl_path(mock_filesystem): + """Test save MAPDL path.""" + save_mapdl_path() + config_path = Path(SETTINGS_DIR) / "config.txt" + with config_path.open() as file: + json_file = json.load(file) + json_file = {key: val.lower() for key, val in json_file.items()} + if sys.platform == "win32": + assert json_file == {"mapdl": LATEST_MAPDL_INSTALL_PATH.lower()} + else: + assert json_file == {"mapdl": LATEST_MAPDL_INSTALL_PATH} + + +def test_save_dyna_path(mock_filesystem): + """Test save dyna path.""" + save_dyna_path() + config_path = SETTINGS_DIR / "config.txt" + with config_path.open() as file: + json_file = json.load(file) + json_file = {key: val.lower() for key, val in json_file.items()} + if sys.platform == "win32": + assert json_file == {"dyna": LATEST_DYNA_INSTALL_PATH.lower()} + else: + assert json_file == {"dyna": LATEST_DYNA_INSTALL_PATH} + + +def test_save_mechanical_path(mock_filesystem): + """Test the save mechanical path.""" + save_mechanical_path() + config_path = SETTINGS_DIR / "config.txt" + with config_path.open() as file: + json_file = json.load(file) + json_file = {key: val.lower() for key, val in json_file.items()} + if sys.platform == "win32": + assert json_file == {"mechanical": LATEST_MECHANICAL_INSTALL_PATH.lower()} + else: + assert json_file == {"mechanical": LATEST_MECHANICAL_INSTALL_PATH} + + +def test_version_from_path(mock_filesystem): + """Test the version_from_path function to ensure it correctly extracts the version from installation paths.""" + if sys.platform == "win32": + wrong_folder = "C:\\f" + else: + wrong_folder = "/f" + assert version_from_path("mapdl", MAPDL_INSTALL_PATHS[0]) == 202 + assert version_from_path("mechanical", LATEST_MECHANICAL_INSTALL_PATH) == 231 + with pytest.raises(Exception): + version_from_path("skvbhksbvks", LATEST_MAPDL_INSTALL_PATH) + with pytest.raises(RuntimeError): + version_from_path("mapdl", wrong_folder) + with pytest.raises(RuntimeError): + version_from_path("mechanical", wrong_folder) + + +def test_get_latest_ansys_installation_empty_fs(mock_empty_filesystem): + """Test that the function raises an error when no installations are found.""" + with pytest.raises(ValueError): + get_latest_ansys_installation() + + +@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) +def test_empty_config_file(mock_filesystem_with_empty_config): + """Test that the config file is empty and no paths are set.""" + assert get_ansys_path() == LATEST_MAPDL_INSTALL_PATH + + +@pytest.mark.win32 +def test_migration_old_config_file(mock_filesystem_with_only_old_config): + """Migrate old config files to the new location on Windows.""" + old_config1_location = Path(platformdirs.user_data_dir(appname="ansys_mapdl_core")) / "config.txt" + old_config2_location = Path(platformdirs.user_data_dir(appname="ansys_tools_path")) / "config.txt" + new_config_location = SETTINGS_DIR / "config.txt" + + assert get_mapdl_path() == LATEST_MAPDL_INSTALL_PATH + assert not old_config1_location.exists() + assert not old_config2_location.exists() + assert new_config_location.exists() + + +@pytest.mark.linux +def test_migration_old_config_file_linux(mock_filesystem_with_only_old_config): + """No migration should take place on Linux, as the config is already in the correct location. + + The config path change only applied to Windows. + """ + old_config1_location = Path(platformdirs.user_data_dir(appname="ansys_mapdl_core")) / "config.txt" + new_config_location = SETTINGS_DIR / "config.txt" + + assert get_mapdl_path() == LATEST_MAPDL_INSTALL_PATH + assert old_config1_location.exists() + assert new_config_location.exists() + + +def test_migration_oldest_config_file(mock_filesystem_with_only_oldest_config): + """Migrate the old config file.""" + old_config_location = Path(platformdirs.user_data_dir(appname="ansys_mapdl_core")) / "config.txt" + + # Check that get_mapdl_path correctly reads from the migrated config + assert get_mapdl_path() == MAPDL_INSTALL_PATHS[0] + + # Confirm old config no longer exists and new config is present + assert not old_config_location.exists() + assert (SETTINGS_DIR / "config.txt").exists() + + +def test_clear_config_file(mock_filesystem_with_config): + """Clear the config file.""" + config_file = SETTINGS_DIR / "config.txt" + + # Clear 'mapdl' key and check results + clear_configuration("mapdl") + content = json.loads(config_file.read_text()) + assert "mapdl" not in content + assert "mechanical" in content and content["mechanical"] is not None + + # Clear 'mechanical' key and verify + clear_configuration("mechanical") + content = json.loads(config_file.read_text()) + assert "mechanical" not in content + + # Clear a key that doesn't exist ('dyna') and check config still exists + clear_configuration("dyna") + assert config_file.exists() + content = json.loads(config_file.read_text()) + assert content == {} From 87667d4f2d9aea9b8f46b6820111ea85f60c1c0e Mon Sep 17 00:00:00 2001 From: afernand Date: Fri, 6 Jun 2025 13:25:01 +0200 Subject: [PATCH 02/16] fix: Metadata --- .github/workflows/cicd.yml | 2 +- pyproject.toml | 2 +- src/ansys/tools/__init__.py | 4 ++++ src/ansys/tools/path/__init__.py | 5 ----- tests/path/unit/test_metadata.py | 4 ++-- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bfa59215..c1be8e75 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -48,7 +48,7 @@ jobs: options: "-u=0:0 --entrypoint /bin/bash" credentials: username: ${{ secrets.GH_USERNAME }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GH_TOKEN }} env: ANSYS_LOCAL: true ON_UBUNTU: true diff --git a/pyproject.toml b/pyproject.toml index 0deb22c1..12875bf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] -name = "ansys-tools-common" +name = "ansys-tools" version = "0.1.dev0" description = "A set of tools for PyAnsys libraries" readme = "README.rst" diff --git a/src/ansys/tools/__init__.py b/src/ansys/tools/__init__.py index 19bbcbf3..c9178fac 100644 --- a/src/ansys/tools/__init__.py +++ b/src/ansys/tools/__init__.py @@ -20,3 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. """Main module.""" + +import importlib.metadata as importlib_metadata + +__version__ = importlib_metadata.version(__name__.replace(".", "-")) diff --git a/src/ansys/tools/path/__init__.py b/src/ansys/tools/path/__init__.py index 5a2d7547..c76a0c44 100644 --- a/src/ansys/tools/path/__init__.py +++ b/src/ansys/tools/path/__init__.py @@ -26,11 +26,6 @@ WARNING: This is not concurrent-safe (multiple python processes might race on this data.) """ -import importlib.metadata as importlib_metadata - -__version__ = importlib_metadata.version(__name__.replace(".", "-")) - - from ansys.tools.path.path import ( LOG, SETTINGS_DIR, diff --git a/tests/path/unit/test_metadata.py b/tests/path/unit/test_metadata.py index 6855b2e2..bb686598 100644 --- a/tests/path/unit/test_metadata.py +++ b/tests/path/unit/test_metadata.py @@ -23,7 +23,7 @@ import os -from ansys.tools.path import __version__ +from ansys.tools import __version__ def test_pkg_version(): @@ -32,7 +32,7 @@ def test_pkg_version(): # Read from the pyproject.toml # major, minor, patch - read_version = importlib_metadata.version("ansys-tools-path") + read_version = importlib_metadata.version("ansys-tools-common") assert __version__ == read_version From 5b2c1bd2b59935ff9aa05284fa14b430b9e28be9 Mon Sep 17 00:00:00 2001 From: afernand Date: Fri, 6 Jun 2025 13:27:17 +0200 Subject: [PATCH 03/16] fix: Metadata --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 12875bf6..0deb22c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] -name = "ansys-tools" +name = "ansys-tools-common" version = "0.1.dev0" description = "A set of tools for PyAnsys libraries" readme = "README.rst" From ff067ea5aa79978fcdd13986edc594dba3701bc6 Mon Sep 17 00:00:00 2001 From: afernand Date: Fri, 6 Jun 2025 13:40:07 +0200 Subject: [PATCH 04/16] fix: metadata --- src/ansys/tools/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/tools/__init__.py b/src/ansys/tools/__init__.py index c9178fac..eb16d911 100644 --- a/src/ansys/tools/__init__.py +++ b/src/ansys/tools/__init__.py @@ -23,4 +23,4 @@ import importlib.metadata as importlib_metadata -__version__ = importlib_metadata.version(__name__.replace(".", "-")) +__version__ = importlib_metadata.version("ansys-tools-common") From f484a66a2c9f6dc11a4ff5b32cf9176ccb3790fa Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 14:03:28 +0200 Subject: [PATCH 05/16] fix: Code comments --- .../tools/path/applications/mechanical.py | 5 +- src/ansys/tools/path/misc.py | 65 ------------------- src/ansys/tools/path/path.py | 39 ++++++++--- src/ansys/tools/{path => }/py.typed | 0 tests/path/unit/test_misc.py | 17 ----- tests/path/unit/test_path.py | 15 +++++ 6 files changed, 46 insertions(+), 95 deletions(-) delete mode 100644 src/ansys/tools/path/misc.py rename src/ansys/tools/{path => }/py.typed (100%) diff --git a/src/ansys/tools/path/applications/mechanical.py b/src/ansys/tools/path/applications/mechanical.py index 14e24e47..7e067249 100644 --- a/src/ansys/tools/path/applications/mechanical.py +++ b/src/ansys/tools/path/applications/mechanical.py @@ -22,11 +22,10 @@ """Mechanical-specific logic for ansys-tools-path.""" +import os from pathlib import Path import re -from ansys.tools.path.misc import is_windows - def is_valid_executable_path(exe_loc: str) -> bool: """Check if the executable path is valid for Ansys Mechanical. @@ -42,6 +41,6 @@ def is_valid_executable_path(exe_loc: str) -> bool: ``True`` if the path is valid for Ansys Mechanical, ``False`` otherwise. """ path = Path(exe_loc) - if is_windows(): # pragma: no cover + if os.name == "nt": # pragma: no cover return path.is_file() and re.search("AnsysWBU.exe", path.name, re.IGNORECASE) is not None return path.is_file() and re.search(r"\.workbench$", path.name) is not None diff --git a/src/ansys/tools/path/misc.py b/src/ansys/tools/path/misc.py deleted file mode 100644 index 6adfb7e6..00000000 --- a/src/ansys/tools/path/misc.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. -# SPDX-License-Identifier: MIT -# -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -"""Miscellaneous functions used by ansys-tools-path.""" - -import os - - -def is_float(input_string: str) -> bool: - r"""Return true when a string can be converted to a float. - - Parameters - ---------- - input_string : str - The string to check. - - Returns - ------- - bool - ``True`` if the string can be converted to a float, ``False`` otherwise. - """ - try: - float(input_string) - return True - except ValueError: - return False - - -def is_windows() -> bool: - r"""Check if the host machine is on Windows. - - Returns - ------- - ``True`` if the host machine is on Windows, ``False`` otherwise. - """ - return os.name == "nt" - - -def is_linux() -> bool: - r"""Check if the host machine is Linux. - - Returns - ------- - ``True`` if the host machine is Linux, ``False`` otherwise. - """ - return os.name == "posix" diff --git a/src/ansys/tools/path/path.py b/src/ansys/tools/path/path.py index d78b26a2..ae18fcae 100644 --- a/src/ansys/tools/path/path.py +++ b/src/ansys/tools/path/path.py @@ -33,7 +33,6 @@ import platformdirs from ansys.tools.path.applications import ApplicationPlugin, dyna, mapdl, mechanical -from ansys.tools.path.misc import is_float, is_linux, is_windows PLUGINS: Dict[str, ApplicationPlugin] = {"mechanical": mechanical, "dyna": dyna, "mapdl": mapdl} @@ -81,7 +80,7 @@ }, } -if is_windows(): # pragma: no cover +if os.name == "nt": # pragma: no cover PRODUCT_EXE_INFO["mechanical"]["patternpath"] = "vXXX/aisol/bin/winx64/AnsysWBU.exe" PRODUCT_EXE_INFO["mechanical"]["pattern"] = "AnsysWBU.exe" PRODUCT_EXE_INFO["dyna"]["patternpath"] = "vXXX/ansys/bin/winx64/LSDYNAXXX.exe" @@ -188,6 +187,26 @@ def _get_default_windows_base_path() -> Optional[str]: # pragma: no cover return str(base_path) +def _is_float(input_string: str) -> bool: + r"""Return true when a string can be converted to a float. + + Parameters + ---------- + input_string : str + The string to check. + + Returns + ------- + bool + ``True`` if the string can be converted to a float, ``False`` otherwise. + """ + try: + float(input_string) + return True + except ValueError: + return False + + def _expand_base_path(base_path: Optional[str]) -> Dict[int, str]: """Expand the base path to all possible ansys Unified installations contained within. @@ -211,7 +230,7 @@ def _expand_base_path(base_path: Optional[str]) -> Dict[int, str]: # Search for versions like /base_path/vXXX for path in base.glob("v*"): ver_str = path.name[-3:] - if is_float(ver_str): + if _is_float(ver_str): ansys_paths[int(ver_str)] = str(path) # Search for ANSYS STUDENT versions like /base_path/ANSYS*/vXXX @@ -225,7 +244,7 @@ def _expand_base_path(base_path: Optional[str]) -> Dict[int, str]: for path in student_paths: ver_str = path.name[-3:] - if is_float(ver_str): + if _is_float(ver_str): ansys_paths[-int(ver_str)] = str(path) return ansys_paths @@ -253,13 +272,13 @@ def _get_available_base_unified( >>> {251: "/usr/ansys_inc/v251"} """ base_path = None - if is_windows(): # pragma: no cover + if os.name == "nt": # pragma: no cover installed_versions = _get_installed_windows_versions(supported_versions) if installed_versions: return installed_versions else: # pragma: no cover base_path = _get_default_windows_base_path() - elif is_linux(): + elif os.name == "posix": base_path = _get_default_linux_base_path() else: # pragma: no cover raise OSError(f"Unsupported OS {os.name}") @@ -365,7 +384,7 @@ def find_mechanical( return "", "" ans_path = Path(ans_path) - if is_windows(): # pragma: no cover + if os.name == "nt": # pragma: no cover mechanical_bin = ans_path / "aisol" / "bin" / "winx64" / "AnsysWBU.exe" else: mechanical_bin = ans_path / "aisol" / ".workbench" @@ -419,7 +438,7 @@ def find_mapdl( return "", "" ansys_bin_path = Path(ans_path) / "ansys" / "bin" - if is_windows(): + if os.name == "nt": ansys_bin = ansys_bin_path / "winx64" / f"ansys{version}.exe" else: ansys_bin = ansys_bin_path / f"ansys{version}" @@ -473,7 +492,7 @@ def find_dyna( return "", "" ansys_bin_path = Path(ans_path) / "ansys" / "bin" - if is_windows(): + if os.name == "nt": ansys_bin = ansys_bin_path / "winx64" / f"LSDYNA{version}.exe" else: ansys_bin = ansys_bin_path / f"lsdyna{version}" @@ -595,7 +614,7 @@ def _is_common_executable_path(product: PRODUCT_TYPE, exe_loc: str) -> bool: elif product == "mechanical": is_valid_path = is_valid_executable_path("mechanical", exe_str) - if is_windows(): # pragma: no cover + if os.name == "nt": # pragma: no cover parts = [part.lower() for part in exe_path.parts] return ( is_valid_path diff --git a/src/ansys/tools/path/py.typed b/src/ansys/tools/py.typed similarity index 100% rename from src/ansys/tools/path/py.typed rename to src/ansys/tools/py.typed diff --git a/tests/path/unit/test_misc.py b/tests/path/unit/test_misc.py index 0d0ffe2a..b321b6dd 100644 --- a/tests/path/unit/test_misc.py +++ b/tests/path/unit/test_misc.py @@ -20,20 +20,3 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. """Module for testing misc functions.""" - -import pytest - -from ansys.tools.path.misc import is_float - -values = [ - (11, True), - (11.1, True), - ("asdf", False), - ("1234asdf", False), -] - - -@pytest.mark.parametrize("values", values) -def test_is_float(values): - """Test the is_float function.""" - assert is_float(values[0]) == values[1] diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py index 927fdc68..e9e001b3 100644 --- a/tests/path/unit/test_path.py +++ b/tests/path/unit/test_path.py @@ -55,6 +55,7 @@ save_mechanical_path, version_from_path, ) +from ansys.tools.path.path import _is_float LOG.setLevel(logging.DEBUG) @@ -606,3 +607,17 @@ def test_clear_config_file(mock_filesystem_with_config): assert config_file.exists() content = json.loads(config_file.read_text()) assert content == {} + + +values = [ + (11, True), + (11.1, True), + ("asdf", False), + ("1234asdf", False), +] + + +@pytest.mark.parametrize("values", values) +def test_is_float(values): + """Test the is_float function.""" + assert _is_float(values[0]) == values[1] From 268bf0d3386035ebf39a2d024e24f3353bf5f252 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 15:43:21 +0200 Subject: [PATCH 06/16] test: Mark tests that require mapdl --- .github/workflows/cicd.yml | 2 +- tests/path/integration/test_integration.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c1be8e75..8371a2f6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -65,7 +65,7 @@ jobs: - name: Unit testing run: | - python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html + python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html -m "not requires_mapdl" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_integration.py index e74d9d87..cbb81ac5 100644 --- a/tests/path/integration/test_integration.py +++ b/tests/path/integration/test_integration.py @@ -40,6 +40,7 @@ ) +@pytest.mark.requires_mapdl @skip_if_not_ansys_local def test_find_mapdl(): """Test that the function finds the MAPDL executable and returns its path and version.""" @@ -48,12 +49,14 @@ def test_find_mapdl(): assert ver != "" +@pytest.mark.requires_mapdl @skip_if_not_ansys_local def test_get_available_ansys_installation(): """Test that the function returns a list of available Ansys installations.""" assert get_available_ansys_installations() +@pytest.mark.requires_mapdl @skip_if_not_ansys_local @pytest.mark.linux def test_save_mapdl_path(): From 169fe6a5b79856582875275632181f7d4237a00d Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 15:52:52 +0200 Subject: [PATCH 07/16] test: Remove unneeded test --- .github/workflows/cicd.yml | 1 - tests/path/integration/test_integration.py | 8 -------- tests/path/unit/test_metadata.py | 12 ------------ 3 files changed, 21 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 502732dd..d3d89b80 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -59,7 +59,6 @@ jobs: username: ${{ secrets.GH_USERNAME }} password: ${{ secrets.GH_TOKEN }} env: - ANSYS_LOCAL: true ON_UBUNTU: true steps: diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_integration.py index cbb81ac5..7ecc206d 100644 --- a/tests/path/integration/test_integration.py +++ b/tests/path/integration/test_integration.py @@ -22,7 +22,6 @@ """Module for integration tests.""" import json -import os from pathlib import Path import pytest @@ -35,13 +34,8 @@ ) from ansys.tools.path.path import CONFIG_FILE -skip_if_not_ansys_local = pytest.mark.skipif( - os.environ.get("ANSYS_LOCAL", "").upper() != "TRUE", reason="Skipping on CI" -) - @pytest.mark.requires_mapdl -@skip_if_not_ansys_local def test_find_mapdl(): """Test that the function finds the MAPDL executable and returns its path and version.""" bin_file, ver = find_mapdl() @@ -50,14 +44,12 @@ def test_find_mapdl(): @pytest.mark.requires_mapdl -@skip_if_not_ansys_local def test_get_available_ansys_installation(): """Test that the function returns a list of available Ansys installations.""" assert get_available_ansys_installations() @pytest.mark.requires_mapdl -@skip_if_not_ansys_local @pytest.mark.linux def test_save_mapdl_path(): """Test saving the MAPDL path to the configuration file.""" diff --git a/tests/path/unit/test_metadata.py b/tests/path/unit/test_metadata.py index bb686598..99b5de41 100644 --- a/tests/path/unit/test_metadata.py +++ b/tests/path/unit/test_metadata.py @@ -21,8 +21,6 @@ # SOFTWARE. """Module for testing metadata.""" -import os - from ansys.tools import __version__ @@ -35,13 +33,3 @@ def test_pkg_version(): read_version = importlib_metadata.version("ansys-tools-common") assert __version__ == read_version - - -def test_cicd_envvar(): - """Test that the environment variable `ANSYS_LOCAL` exists and is set to True or False.""" - if not os.environ.get("ANSYS_LOCAL", ""): - # env var does not exists - raise RuntimeError( - "The env var 'ANSYS_LOCAL' does not exists. That env var is needed to tell Pytest which\n" - "tests should be run depending on if MAPDL is installed ('ANSYS_LOCAL'=True) or not." - ) From 6984534b06d449100850b41362b5d426362fadef Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 15:56:26 +0200 Subject: [PATCH 08/16] test: Fix --- tests/path/unit/test_path.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py index e9e001b3..91c0b0a2 100644 --- a/tests/path/unit/test_path.py +++ b/tests/path/unit/test_path.py @@ -373,6 +373,7 @@ def test_find_mechanical_without_student(mock_filesystem_without_student_version @pytest.mark.win32 +@pytest.mark.requires_mapdl def test_get_available_ansys_installation_windows(mock_filesystem, mock_awp_environment_variable): """Test get available Ansys installations on Windows.""" available_ansys_installations = get_available_ansys_installations() From 3e136d3672f1b2be742e67783f321afee9b57a20 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 15:58:49 +0200 Subject: [PATCH 09/16] test: fix ci --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d3d89b80..e9435fc0 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -73,7 +73,7 @@ jobs: - name: Unit testing run: | - python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html -m "not requires_mapdl" + python -m pytest -m "not requires_mapdl" -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 From 6a55b875e5f017a66fd0921ca80c571195105fd9 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 16:30:11 +0200 Subject: [PATCH 10/16] fix: Revert --- .github/workflows/cicd.yml | 12 ++---------- tests/path/integration/test_integration.py | 11 ++++++++--- tests/path/unit/test_metadata.py | 12 ++++++++++++ tests/path/unit/test_path.py | 1 - 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e9435fc0..c1be8e75 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -38,15 +38,6 @@ jobs: operating-system: ${{ matrix.os }} python-version: ${{ matrix.python-version }} whitelist-license-check: "termcolor" # Has MIT license, but it's not recognized - tests: - name: Run tests - runs-on: ubuntu-latest - steps: - - name: Run tests - uses: ansys/actions/tests-pytest@v9 - with: - library-name: ${{ env.PACKAGE_NAME }} - python-version: ${{ env.MAIN_PYTHON_VERSION }} build-tests: name: Build and Testing @@ -59,6 +50,7 @@ jobs: username: ${{ secrets.GH_USERNAME }} password: ${{ secrets.GH_TOKEN }} env: + ANSYS_LOCAL: true ON_UBUNTU: true steps: @@ -73,7 +65,7 @@ jobs: - name: Unit testing run: | - python -m pytest -m "not requires_mapdl" -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html + python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_integration.py index 7ecc206d..e74d9d87 100644 --- a/tests/path/integration/test_integration.py +++ b/tests/path/integration/test_integration.py @@ -22,6 +22,7 @@ """Module for integration tests.""" import json +import os from pathlib import Path import pytest @@ -34,8 +35,12 @@ ) from ansys.tools.path.path import CONFIG_FILE +skip_if_not_ansys_local = pytest.mark.skipif( + os.environ.get("ANSYS_LOCAL", "").upper() != "TRUE", reason="Skipping on CI" +) + -@pytest.mark.requires_mapdl +@skip_if_not_ansys_local def test_find_mapdl(): """Test that the function finds the MAPDL executable and returns its path and version.""" bin_file, ver = find_mapdl() @@ -43,13 +48,13 @@ def test_find_mapdl(): assert ver != "" -@pytest.mark.requires_mapdl +@skip_if_not_ansys_local def test_get_available_ansys_installation(): """Test that the function returns a list of available Ansys installations.""" assert get_available_ansys_installations() -@pytest.mark.requires_mapdl +@skip_if_not_ansys_local @pytest.mark.linux def test_save_mapdl_path(): """Test saving the MAPDL path to the configuration file.""" diff --git a/tests/path/unit/test_metadata.py b/tests/path/unit/test_metadata.py index 99b5de41..bb686598 100644 --- a/tests/path/unit/test_metadata.py +++ b/tests/path/unit/test_metadata.py @@ -21,6 +21,8 @@ # SOFTWARE. """Module for testing metadata.""" +import os + from ansys.tools import __version__ @@ -33,3 +35,13 @@ def test_pkg_version(): read_version = importlib_metadata.version("ansys-tools-common") assert __version__ == read_version + + +def test_cicd_envvar(): + """Test that the environment variable `ANSYS_LOCAL` exists and is set to True or False.""" + if not os.environ.get("ANSYS_LOCAL", ""): + # env var does not exists + raise RuntimeError( + "The env var 'ANSYS_LOCAL' does not exists. That env var is needed to tell Pytest which\n" + "tests should be run depending on if MAPDL is installed ('ANSYS_LOCAL'=True) or not." + ) diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py index 91c0b0a2..e9e001b3 100644 --- a/tests/path/unit/test_path.py +++ b/tests/path/unit/test_path.py @@ -373,7 +373,6 @@ def test_find_mechanical_without_student(mock_filesystem_without_student_version @pytest.mark.win32 -@pytest.mark.requires_mapdl def test_get_available_ansys_installation_windows(mock_filesystem, mock_awp_environment_variable): """Test get available Ansys installations on Windows.""" available_ansys_installations = get_available_ansys_installations() From 0e54ce614af59348bc528cee4220abb9efbf4d9e Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 16:39:10 +0200 Subject: [PATCH 11/16] test: Separate mapdl dependant tests --- .github/workflows/cicd.yml | 8 +---- .github/workflows/run_mapdl_tests.yml | 46 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/run_mapdl_tests.yml diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c1be8e75..a566cedc 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -43,14 +43,8 @@ jobs: name: Build and Testing runs-on: ubuntu-22.04 needs: [smoke-tests] - container: - image: ghcr.io/ansys/pymapdl/mapdl:v22.2-ubuntu - options: "-u=0:0 --entrypoint /bin/bash" - credentials: - username: ${{ secrets.GH_USERNAME }} - password: ${{ secrets.GH_TOKEN }} env: - ANSYS_LOCAL: true + ANSYS_LOCAL: false ON_UBUNTU: true steps: diff --git a/.github/workflows/run_mapdl_tests.yml b/.github/workflows/run_mapdl_tests.yml new file mode 100644 index 00000000..aa6bb696 --- /dev/null +++ b/.github/workflows/run_mapdl_tests.yml @@ -0,0 +1,46 @@ +name: GitHub CI + +on: + pull_request: + workflow_dispatch: + push: + tags: + - "*" + branches: + - main + +env: + PACKAGE_NAME: ansys-tools-common + MAIN_PYTHON_VERSION: 3.13 + +jobs: + build-tests: + name: Build and Testing + runs-on: ubuntu-22.04 + container: + image: ghcr.io/ansys/pymapdl/mapdl:v22.2-ubuntu + options: "-u=0:0 --entrypoint /bin/bash" + credentials: + username: ${{ secrets.GH_USERNAME }} + password: ${{ secrets.GH_TOKEN }} + env: + ANSYS_LOCAL: true + ON_UBUNTU: true + + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + - name: Install library, with test extra + run: python -m pip install .[tests] + + - name: Unit testing + run: | + python -m pytest -vx --cov=${{ env.PACKAGE_NAMESPACE }} --cov-report=term --cov-report=xml:.cov/coverage.xml --cov-report=html:.cov/html + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + files: .cov/coverage.xml \ No newline at end of file From 978d30f4796f35fc5df72ec16e8f3854b072aae4 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 9 Jun 2025 16:42:00 +0200 Subject: [PATCH 12/16] fix: Rename CI --- .github/workflows/run_mapdl_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_mapdl_tests.yml b/.github/workflows/run_mapdl_tests.yml index aa6bb696..6c651f8a 100644 --- a/.github/workflows/run_mapdl_tests.yml +++ b/.github/workflows/run_mapdl_tests.yml @@ -1,4 +1,4 @@ -name: GitHub CI +name: MAPDL dependent tests on: pull_request: From 28d263f47abb92d83c3053d712a599ebe513d9bf Mon Sep 17 00:00:00 2001 From: afernand Date: Wed, 25 Jun 2025 16:31:21 +0200 Subject: [PATCH 13/16] fix: Move to common folder and add latest changes --- src/ansys/tools/{ => common}/path/__init__.py | 0 src/ansys/tools/{ => common}/path/applications/__init__.py | 0 src/ansys/tools/{ => common}/path/applications/dyna.py | 0 src/ansys/tools/{ => common}/path/applications/mapdl.py | 0 src/ansys/tools/{ => common}/path/applications/mechanical.py | 0 src/ansys/tools/{ => common}/path/path.py | 1 + src/ansys/tools/{ => common}/path/save.py | 0 tests/path/integration/test_integration.py | 4 ++-- tests/path/unit/test_metadata.py | 2 +- tests/path/unit/test_path.py | 2 +- 10 files changed, 5 insertions(+), 4 deletions(-) rename src/ansys/tools/{ => common}/path/__init__.py (100%) rename src/ansys/tools/{ => common}/path/applications/__init__.py (100%) rename src/ansys/tools/{ => common}/path/applications/dyna.py (100%) rename src/ansys/tools/{ => common}/path/applications/mapdl.py (100%) rename src/ansys/tools/{ => common}/path/applications/mechanical.py (100%) rename src/ansys/tools/{ => common}/path/path.py (99%) rename src/ansys/tools/{ => common}/path/save.py (100%) diff --git a/src/ansys/tools/path/__init__.py b/src/ansys/tools/common/path/__init__.py similarity index 100% rename from src/ansys/tools/path/__init__.py rename to src/ansys/tools/common/path/__init__.py diff --git a/src/ansys/tools/path/applications/__init__.py b/src/ansys/tools/common/path/applications/__init__.py similarity index 100% rename from src/ansys/tools/path/applications/__init__.py rename to src/ansys/tools/common/path/applications/__init__.py diff --git a/src/ansys/tools/path/applications/dyna.py b/src/ansys/tools/common/path/applications/dyna.py similarity index 100% rename from src/ansys/tools/path/applications/dyna.py rename to src/ansys/tools/common/path/applications/dyna.py diff --git a/src/ansys/tools/path/applications/mapdl.py b/src/ansys/tools/common/path/applications/mapdl.py similarity index 100% rename from src/ansys/tools/path/applications/mapdl.py rename to src/ansys/tools/common/path/applications/mapdl.py diff --git a/src/ansys/tools/path/applications/mechanical.py b/src/ansys/tools/common/path/applications/mechanical.py similarity index 100% rename from src/ansys/tools/path/applications/mechanical.py rename to src/ansys/tools/common/path/applications/mechanical.py diff --git a/src/ansys/tools/path/path.py b/src/ansys/tools/common/path/path.py similarity index 99% rename from src/ansys/tools/path/path.py rename to src/ansys/tools/common/path/path.py index ae18fcae..d7ba52e0 100644 --- a/src/ansys/tools/path/path.py +++ b/src/ansys/tools/common/path/path.py @@ -47,6 +47,7 @@ CONFIG_FILE_NAME = "config.txt" SUPPORTED_ANSYS_VERSIONS: SUPPORTED_VERSIONS_TYPE = { + 261: "2026R1", 252: "2025R2", 251: "2025R1", 242: "2024R2", diff --git a/src/ansys/tools/path/save.py b/src/ansys/tools/common/path/save.py similarity index 100% rename from src/ansys/tools/path/save.py rename to src/ansys/tools/common/path/save.py diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_integration.py index e74d9d87..a5979018 100644 --- a/tests/path/integration/test_integration.py +++ b/tests/path/integration/test_integration.py @@ -27,13 +27,13 @@ import pytest -from ansys.tools.path import ( +from ansys.tools.common.path import ( clear_configuration, find_mapdl, get_available_ansys_installations, save_mapdl_path, ) -from ansys.tools.path.path import CONFIG_FILE +from ansys.tools.common.path.path import CONFIG_FILE skip_if_not_ansys_local = pytest.mark.skipif( os.environ.get("ANSYS_LOCAL", "").upper() != "TRUE", reason="Skipping on CI" diff --git a/tests/path/unit/test_metadata.py b/tests/path/unit/test_metadata.py index bb686598..1cafe335 100644 --- a/tests/path/unit/test_metadata.py +++ b/tests/path/unit/test_metadata.py @@ -23,7 +23,7 @@ import os -from ansys.tools import __version__ +from ansys.tools.common import __version__ def test_pkg_version(): diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py index e9e001b3..e3fe05b5 100644 --- a/tests/path/unit/test_path.py +++ b/tests/path/unit/test_path.py @@ -32,7 +32,7 @@ import pyfakefs # noqa import pytest -from ansys.tools.path import ( +from ansys.tools.common.path import ( LOG, SETTINGS_DIR, change_default_ansys_path, From a3cf0255c63568e76f188a45b66dd4df4f48373f Mon Sep 17 00:00:00 2001 From: afernand Date: Wed, 25 Jun 2025 16:34:40 +0200 Subject: [PATCH 14/16] fix: Update module paths --- doc/source/user_guide/ansys_downloader.rst | 2 +- doc/source/user_guide/ansys_tools_path.rst | 4 +-- pyproject.toml | 2 +- src/ansys/tools/common/path/__init__.py | 2 +- src/ansys/tools/common/path/path.py | 38 +++++++++++----------- src/ansys/tools/common/path/save.py | 2 +- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/doc/source/user_guide/ansys_downloader.rst b/doc/source/user_guide/ansys_downloader.rst index b3f48634..dabdbc00 100644 --- a/doc/source/user_guide/ansys_downloader.rst +++ b/doc/source/user_guide/ansys_downloader.rst @@ -6,7 +6,7 @@ Ansys example downloader You can use any of the functions available in the to identify the path of the local Ansys installation. -For example you can use :func:`find_ansys ` +For example you can use :func:`find_ansys ` to locate the path of the latest Ansys installation available: .. code:: pycon diff --git a/doc/source/user_guide/ansys_tools_path.rst b/doc/source/user_guide/ansys_tools_path.rst index 5e732ee2..65af6108 100644 --- a/doc/source/user_guide/ansys_tools_path.rst +++ b/doc/source/user_guide/ansys_tools_path.rst @@ -11,11 +11,11 @@ How to use You can use any of the functions available in the to identify the path of the local Ansys installation. -For example you can use :func:`find_ansys ` +For example you can use :func:`find_ansys ` to locate the path of the latest Ansys installation available: .. code:: pycon - >>> from ansys.tools.path import find_ansys + >>> from ansys.tools.common.path import find_ansys >>> find_ansys() 'C:/Program Files/ANSYS Inc/v211/ANSYS/bin/winx64/ansys211.exe', 21.1 diff --git a/pyproject.toml b/pyproject.toml index 628b8a10..61367160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ doc = [ ] [project.scripts] -save-ansys-path = "ansys.tools.path.save:cli" +save-ansys-path = "ansys.tools.common.path.save:cli" [project.urls] Source = "https://github.com/ansys/ansys-tools-common" diff --git a/src/ansys/tools/common/path/__init__.py b/src/ansys/tools/common/path/__init__.py index c76a0c44..7f90964f 100644 --- a/src/ansys/tools/common/path/__init__.py +++ b/src/ansys/tools/common/path/__init__.py @@ -26,7 +26,7 @@ WARNING: This is not concurrent-safe (multiple python processes might race on this data.) """ -from ansys.tools.path.path import ( +from ansys.tools.common.path.path import ( LOG, SETTINGS_DIR, SUPPORTED_ANSYS_VERSIONS, diff --git a/src/ansys/tools/common/path/path.py b/src/ansys/tools/common/path/path.py index d7ba52e0..8f65e3fb 100644 --- a/src/ansys/tools/common/path/path.py +++ b/src/ansys/tools/common/path/path.py @@ -32,7 +32,7 @@ import platformdirs -from ansys.tools.path.applications import ApplicationPlugin, dyna, mapdl, mechanical +from ansys.tools.common.path.applications import ApplicationPlugin, dyna, mapdl, mechanical PLUGINS: Dict[str, ApplicationPlugin] = {"mechanical": mechanical, "dyna": dyna, "mapdl": mapdl} @@ -305,7 +305,7 @@ def get_available_ansys_installations( Examples -------- - >>> from ansys.tools.path import get_available_ansys_installations + >>> from ansys.tools.common.path import get_available_ansys_installations >>> get_available_ansys_installations() {251: 'C:\\Program Files\\ANSYS Inc\\v251', 242: 'C:\\Program Files\\ANSYS Inc\\v242', @@ -371,7 +371,7 @@ def find_mechanical( -------- On Windows: - >>> from ansys.tools.path import find_mechanical + >>> from ansys.tools.common.path import find_mechanical >>> find_mechanical() ('C:/Program Files/ANSYS Inc/v251/aisol/bin/winx64/AnsysWBU.exe', 25.1) @@ -425,7 +425,7 @@ def find_mapdl( -------- Within Windows - >>> from ansys.tools.path import find_mapdl + >>> from ansys.tools.common.path import find_mapdl >>> find_mapdl() 'C:/Program Files/ANSYS Inc/v251/ANSYS/bin/winx64/ansys251.exe', 25.1 @@ -479,7 +479,7 @@ def find_dyna( -------- Within Windows - >>> from ansys.tools.path import find_dyna + >>> from ansys.tools.common.path import find_dyna >>> find_dyna() 'C:/Program Files/ANSYS Inc/v251/ANSYS/bin/winx64/LSDYNA251.exe', 25.1 @@ -659,7 +659,7 @@ def change_default_mapdl_path(exe_loc: str) -> None: -------- Change default Ansys MAPDL location on Linux - >>> from ansys.tools.path import change_default_mapdl_path, get_mapdl_path + >>> from ansys.tools.common.path import change_default_mapdl_path, get_mapdl_path >>> change_default_mapdl_path("/ansys_inc/v251/ansys/bin/ansys251") >>> get_mapdl_path() '/ansys_inc/v251/ansys/bin/ansys251' @@ -686,7 +686,7 @@ def change_default_dyna_path(exe_loc: str) -> None: -------- Change default Ansys LS-Dyna location on Linux - >>> from ansys.tools.path import change_default_dyna_path, get_dyna_path + >>> from ansys.tools.common.path import change_default_dyna_path, get_dyna_path >>> change_default_dyna_path("/ansys_inc/v251/ansys/bin/lsdyna251") >>> get_dyna_path() '/ansys_inc/v251/ansys/bin/lsdyna251' @@ -712,14 +712,14 @@ def change_default_mechanical_path(exe_loc: str) -> None: -------- On Windows: - >>> from ansys.tools.path import change_default_mechanical_path, get_mechanical_path + >>> from ansys.tools.common.path import change_default_mechanical_path, get_mechanical_path >>> change_default_mechanical_path("C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe") >>> get_mechanical_path() 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe' On Linux: - >>> from ansys.tools.path import change_default_mechanical_path, get_mechanical_path + >>> from ansys.tools.common.path import change_default_mechanical_path, get_mechanical_path >>> change_default_mechanical_path("/ansys_inc/v251/aisol/.workbench") >>> get_mechanical_path() '/ansys_inc/v251/aisol/.workbench' @@ -779,11 +779,11 @@ def save_mechanical_path(exe_loc: Optional[str] = None, allow_prompt: bool = Tru Notes ----- The location of the configuration file ``config.txt`` can be found in - ``ansys.tools.path.SETTINGS_DIR``. For example: + ``ansys.tools.common.path.SETTINGS_DIR``. For example: .. code:: pycon - >>> from ansys.tools.path import SETTINGS_DIR + >>> from ansys.tools.common.path import SETTINGS_DIR >>> import os >>> print(os.path.join(SETTINGS_DIR, "config.txt")) C:/Users/[username]]/AppData/Local/Ansys/ansys_tools_path/config.txt @@ -793,7 +793,7 @@ def save_mechanical_path(exe_loc: Optional[str] = None, allow_prompt: bool = Tru .. code:: pycon - >>> from ansys.tools.path import save_mechanical_path + >>> from ansys.tools.common.path import save_mechanical_path >>> save_mechanical_path("/new/path/to/executable") """ @@ -826,11 +826,11 @@ def save_dyna_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> Notes ----- The location of the configuration file ``config.txt`` can be found in - ``ansys.tools.path.SETTINGS_DIR``. For example: + ``ansys.tools.common.path.SETTINGS_DIR``. For example: .. code:: pycon - >>> from ansys.tools.path import SETTINGS_DIR + >>> from ansys.tools.common.path import SETTINGS_DIR >>> import os >>> print(os.path.join(SETTINGS_DIR, "config.txt")) C:/Users/[username]/AppData/Local/Ansys/ansys_tools_path/config.txt @@ -840,7 +840,7 @@ def save_dyna_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> You can change the default ``exe_loc`` either by modifying the mentioned ``config.txt`` file or by executing: - >>> from ansys.tools.path import save_dyna_path + >>> from ansys.tools.common.path import save_dyna_path >>> save_dyna_path("/new/path/to/executable") """ @@ -873,11 +873,11 @@ def save_mapdl_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> Notes ----- The location of the configuration file ``config.txt`` can be found in - ``ansys.tools.path.SETTINGS_DIR``. For example: + ``ansys.tools.common.path.SETTINGS_DIR``. For example: .. code:: pycon - >>> from ansys.tools.path import SETTINGS_DIR + >>> from ansys.tools.common.path import SETTINGS_DIR >>> import os >>> print(os.path.join(SETTINGS_DIR, "config.txt")) C:/Users/[username]/AppData/Local/Ansys/ansys_tools_path/config.txt @@ -887,7 +887,7 @@ def save_mapdl_path(exe_loc: Optional[str] = None, allow_prompt: bool = True) -> You can change the default ``exe_loc`` either by modifying the mentioned ``config.txt`` file or by executing: - >>> from ansys.tools.path import save_mapdl_path + >>> from ansys.tools.common.path import save_mapdl_path >>> save_mapdl_path("/new/path/to/executable") """ @@ -951,7 +951,7 @@ def _prompt_path(product: PRODUCT_TYPE) -> str: # pragma: no cover print( "\nIf you experience problems with the input path you can overwrite the configuration\n" "file by typing:\n" - f">>> from ansys.tools.path import save_{product}_path\n" + f">>> from ansys.tools.common.path import save_{product}_path\n" f">>> save_{product}_path('/new/path/to/executable/')\n" ) while True: diff --git a/src/ansys/tools/common/path/save.py b/src/ansys/tools/common/path/save.py index ba741384..0244301a 100644 --- a/src/ansys/tools/common/path/save.py +++ b/src/ansys/tools/common/path/save.py @@ -24,7 +24,7 @@ import click -from ansys.tools.path.path import _save_path +from ansys.tools.common.path.path import _save_path @click.command() From 919f95f01fa9f08229613e9885d7def4cc04977a Mon Sep 17 00:00:00 2001 From: afernand Date: Wed, 25 Jun 2025 16:48:13 +0200 Subject: [PATCH 15/16] fix: Missing module fix --- tests/path/unit/test_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/path/unit/test_path.py b/tests/path/unit/test_path.py index e3fe05b5..5a19f620 100644 --- a/tests/path/unit/test_path.py +++ b/tests/path/unit/test_path.py @@ -55,7 +55,7 @@ save_mechanical_path, version_from_path, ) -from ansys.tools.path.path import _is_float +from ansys.tools.common.path.path import _is_float LOG.setLevel(logging.DEBUG) From 4c3a2c0926e9775b9902426e500e4fcd8a9336c1 Mon Sep 17 00:00:00 2001 From: afernand Date: Thu, 26 Jun 2025 09:19:19 +0200 Subject: [PATCH 16/16] fix: Comments --- .github/workflows/cicd.yml | 2 +- .github/workflows/run_mapdl_tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 9465e376..a9e8ef4b 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -42,7 +42,7 @@ jobs: build-tests: name: Build and Testing - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest needs: [smoke-tests] env: ANSYS_LOCAL: false diff --git a/.github/workflows/run_mapdl_tests.yml b/.github/workflows/run_mapdl_tests.yml index 6c651f8a..349bce4d 100644 --- a/.github/workflows/run_mapdl_tests.yml +++ b/.github/workflows/run_mapdl_tests.yml @@ -1,4 +1,4 @@ -name: MAPDL dependent tests +name: MAPDL dependent tests for paths tool on: pull_request: