diff --git a/.flake8 b/.flake8 index 04ddda89..3087c0ec 100644 --- a/.flake8 +++ b/.flake8 @@ -3,5 +3,5 @@ exclude = venv, __init__.py, doc/_build select = W191, W291, W293, W391, E115, E117, E122, E124, E125, E225, E231, E301, E303, E501, F401, F403 count = True max-complexity = 10 -max-line-length = 100 +max-line-length = 120 statistics = True \ No newline at end of file diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 46fdb516..f0b0282b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -24,41 +24,53 @@ jobs: - name: Install pre-commit requirements run: | - pip install poetry - poetry install -E pre-commit + pip install pre-commit - name: Run pre-commit run: | - poetry run pre-commit run --all-files || ( git status --short ; git diff ; exit 1 ) + pre-commit run --all-files || ( git status --short ; git diff ; exit 1 ) main: name: Build and Testing runs-on: ubuntu-latest + timeout-minutes: 20 + container: + image: ghcr.io/pyansys/mapdl:v22.2-ubuntu + options: "-u=0:0 --entrypoint /bin/bash" + credentials: + username: ${{ secrets.GH_USERNAME }} + password: ${{ secrets.MY_TOKEN }} + env: + ON_LOCAL: true + ON_UBUNTU: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: Create wheel run: | - pip install build + python -m pip install build python -m build --wheel - name: Validate wheel run: | - pip install twine - twine check dist/* + python -m pip install twine + python -m twine check dist/* - name: Install library, with test extra - run: pip install $(echo dist/*)[test] + run: python -m pip install $(echo dist/*)[test] - name: Unit testing run: | cd tests # so we're testing the install, not local - pytest -vx + python -m pytest -vx --cov=ansys.tools.path --cov-report=html + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 - name: Upload wheel uses: actions/upload-artifact@v2 @@ -80,32 +92,31 @@ jobs: - name: Install library, with docs extra run: | - pip install poetry - poetry install -E docs + pip install .[doc] - name: Build HTML run: | - poetry run make -C doc html SPHINXOPTS="-W" - - - name: Build PDF Documentation - run: | - sudo apt update - sudo apt-get install -y texlive-latex-extra latexmk - poetry run make -C doc latexpdf - - - name: Upload HTML Documentation - uses: actions/upload-artifact@v2 - with: - name: Documentation-html - path: doc/build/html - retention-days: 7 - - - name: Upload PDF Documentation - uses: actions/upload-artifact@v2 - with: - name: Documentation-pdf - path: doc/build/latex/*.pdf - retention-days: 7 + make -C doc html SPHINXOPTS="-W" + + # - name: Build PDF Documentation + # run: | + # sudo apt update + # sudo apt-get install -y texlive-latex-extra latexmk + # make -C doc latexpdf + + # - name: Upload HTML Documentation + # uses: actions/upload-artifact@v2 + # with: + # name: Documentation-html + # path: doc/build/html + # retention-days: 7 + + # - name: Upload PDF Documentation + # uses: actions/upload-artifact@v2 + # with: + # name: Documentation-pdf + # path: doc/build/latex/*.pdf + # retention-days: 7 Release: if: contains(github.ref, 'refs/tags') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e45e0d92..994ad3d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,15 @@ repos: - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.1.0 # IF VERSION CHANGES --> MODIFY "blacken-docs" MANUALLY AS WELL!! hooks: - id: black -- repo: https://github.com/asottile/reorder_python_imports - rev: v3.9.0 + +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.13.0 hooks: - - id: reorder-python-imports - args: ["--py37-plus"] + - id: blacken-docs + additional_dependencies: [black==23.1.0] + - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: @@ -16,6 +18,26 @@ repos: rev: v2.2.2 hooks: - id: codespell + args: ["--toml", "pyproject.toml"] + additional_dependencies: ["tomli"] + +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + +# this validates our github workflow files +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.21.0 + hooks: + - id: check-github-workflows + # - repo: https://github.com/pycqa/pydocstyle # rev: 6.1.1 # hooks: diff --git a/pyproject.toml b/pyproject.toml index fbdc6596..7c69d475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,26 @@ classifiers = [ ] dependencies = [ "importlib-metadata >=4.0", + "appdirs>=1.4.0", +] + + +[project.optional-dependencies] +test = [ + "pytest==7.2.1", + "pytest-cov==4.0.0", +] + +doc = [ + "Sphinx==5.0.2", + "numpydoc==1.4.0", + "ansys-sphinx-theme==0.4.2", + "sphinx-copybutton==0.5", +] + +build = [ + "build==0.8.0", + "twine==4.0.1", ] [tool.flit.module] @@ -37,15 +57,18 @@ Homepage = "https://github.com/pyansys/ansys-tools-path" [tool.black] -line-length = 100 +line-length = 120 [tool.isort] profile = "black" force_sort_within_sections = true -line_length = 100 +line_length = 120 default_section = "THIRDPARTY" src_paths = ["doc", "src", "tests"] +[tools.flake8] +max-line-length = 100 + [tool.coverage.run] source = ["ansys.tools"] diff --git a/requirements/requirements_build.txt b/requirements/requirements_build.txt deleted file mode 100644 index 3666776b..00000000 --- a/requirements/requirements_build.txt +++ /dev/null @@ -1,2 +0,0 @@ -build==0.8.0 -twine==4.0.1 diff --git a/requirements/requirements_doc.txt b/requirements/requirements_doc.txt deleted file mode 100644 index b3392e14..00000000 --- a/requirements/requirements_doc.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx==5.0.2 -numpydoc==1.4.0 -ansys-sphinx-theme==0.4.2 -sphinx-copybutton==0.5 diff --git a/requirements/requirements_tests.txt b/requirements/requirements_tests.txt deleted file mode 100644 index 71b21e3b..00000000 --- a/requirements/requirements_tests.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest==7.1.2 -pytest-cov==3.0.0 diff --git a/src/ansys/tools/path/__init__.py b/src/ansys/tools/path/__init__.py index 81848b7d..49f26d96 100644 --- a/src/ansys/tools/path/__init__.py +++ b/src/ansys/tools/path/__init__.py @@ -10,3 +10,13 @@ import importlib_metadata __version__ = importlib_metadata.version(__name__.replace(".", "-")) + + +from ansys.tools.path.path import ( + SUPPORTED_ANSYS_VERSIONS, + change_default_ansys_path, + find_ansys, + get_ansys_path, + get_available_ansys_installations, + save_ansys_path, +) diff --git a/src/ansys/tools/path/misc.py b/src/ansys/tools/path/misc.py new file mode 100644 index 00000000..318714e7 --- /dev/null +++ b/src/ansys/tools/path/misc.py @@ -0,0 +1,7 @@ +def is_float(input_string): + """Returns true when a string can be converted to a float""" + try: + float(input_string) + return True + except ValueError: + return False diff --git a/src/ansys/tools/path/path.py b/src/ansys/tools/path/path.py new file mode 100644 index 00000000..5a7e8627 --- /dev/null +++ b/src/ansys/tools/path/path.py @@ -0,0 +1,436 @@ +from glob import glob +import logging as LOG # Temporal hack +import os +import re +import warnings + +import appdirs + +from ansys.tools.path.misc import is_float + +LINUX_DEFAULT_DIRS = [["/", "usr", "ansys_inc"], ["/", "ansys_inc"]] +LINUX_DEFAULT_DIRS = [os.path.join(*each) for each in LINUX_DEFAULT_DIRS] + +CONFIG_FILE_NAME = "config.txt" + +SUPPORTED_ANSYS_VERSIONS = { + 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", +} + +# settings directory +SETTINGS_DIR = appdirs.user_data_dir("ansys_tools_path") +if not os.path.isdir(SETTINGS_DIR): # pragma: no cover + try: + LOG.debug(f"Created settings directory: {SETTINGS_DIR}") + os.makedirs(SETTINGS_DIR) + except: + warnings.warn("Unable to create settings directory.\n" "Will be unable to cache MAPDL executable location") + +CONFIG_FILE = os.path.join(SETTINGS_DIR, CONFIG_FILE_NAME) + + +def _version_from_path(path): + """Extract ansys version from a path. Generally, the version of + ANSYS is contained in the path: + + C:/Program Files/ANSYS Inc/v202/ansys/bin/winx64/ANSYS202.exe + + /usr/ansys_inc/v211/ansys/bin/mapdl + + Note that if the MAPDL executable, you have to rely on the version + in the path. + + Parameters + ---------- + path : str + Path to the MAPDL executable + + Returns + ------- + int + Integer version number (e.g. 211). + + """ + # expect v/ansys + # replace \\ with / to account for possible windows path + matches = re.findall(r"v(\d\d\d).ansys", path.replace("\\", "/"), re.IGNORECASE) + if not matches: + raise RuntimeError(f"Unable to extract Ansys version from {path}") + return int(matches[-1]) + + +def _get_available_base_ansys(supported_versions=SUPPORTED_ANSYS_VERSIONS): + """Return a dictionary of available Ansys versions with their base paths. + + Returns + ------- + dict[int: str] + Return all installed Ansys 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.mapdl.core import _get_available_base_ansys + >>> _get_available_base_ansys() + {222: 'C:\\Program Files\\ANSYS Inc\\v222', + 212: 'C:\\Program Files\\ANSYS Inc\\v212', + -222: 'C:\\Program Files\\ANSYS Inc\\ANSYS Student\\v222'} + + Return all installed Ansys paths in Linux. + + >>> _get_available_base_ansys() + {194: '/usr/ansys_inc/v194', + 202: '/usr/ansys_inc/v202', + 211: '/usr/ansys_inc/v211'} + """ + base_path = None + if os.name == "nt": # pragma: no cover + # 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 = [] + awp_roots_student = [] + + for ver in supported_versions: + path_ = os.environ.get(f"AWP_ROOT{ver}", "") + path_non_student = path_.replace("\\ANSYS Student", "") + + if "student" in path_.lower() and os.path.exists(path_non_student): + # Check if also exist a non-student version + awp_roots.append([ver, path_non_student]) + awp_roots_student.insert(0, [-1 * ver, path_]) + + else: + awp_roots.append([ver, path_]) + + awp_roots.extend(awp_roots_student) + installed_versions = {ver: path for ver, path in awp_roots if path and os.path.isdir(path)} + + if installed_versions: + LOG.debug(f"Found the following installed Ansys versions: {installed_versions}") + return installed_versions + else: # pragma: no cover + LOG.debug("No installed ANSYS found using 'AWP_ROOT' environments. Let's suppose a base path.") + base_path = os.path.join(os.environ["PROGRAMFILES"], "ANSYS INC") + if not os.path.exists(base_path): + LOG.debug(f"The supposed 'base_path'{base_path} does not exist. No available ansys found.") + return {} + elif os.name == "posix": + for path in LINUX_DEFAULT_DIRS: + if os.path.isdir(path): + base_path = path + else: # pragma: no cover + raise OSError(f"Unsupported OS {os.name}") + + if base_path is None: + return {} + + paths = glob(os.path.join(base_path, "v*")) + + # Testing for ANSYS STUDENT version + if not paths: # pragma: no cover + paths = glob(os.path.join(base_path, "ANSYS*")) + + if not paths: + return {} + + ansys_paths = {} + for path in paths: + ver_str = path[-3:] + if is_float(ver_str): + ansys_paths[int(ver_str)] = path + + return ansys_paths + + +def get_available_ansys_installations(supported_versions=SUPPORTED_ANSYS_VERSIONS): + """Return a dictionary of available Ansys versions with their base paths. + + Returns + ------- + dict[int: str] + Return all installed Ansys 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.mapdl.core import get_available_ansys_installations + >>> get_available_ansys_installations() + {222: 'C:\\Program Files\\ANSYS Inc\\v222', + 212: 'C:\\Program Files\\ANSYS Inc\\v212', + -222: 'C:\\Program Files\\ANSYS Inc\\ANSYS Student\\v222'} + + Return all installed Ansys paths in Linux. + + >>> get_available_ansys_installations() + {194: '/usr/ansys_inc/v194', + 202: '/usr/ansys_inc/v202', + 211: '/usr/ansys_inc/v211'} + """ + return _get_available_base_ansys(supported_versions) + + +def find_ansys(version=None, supported_versions=SUPPORTED_ANSYS_VERSIONS): + """Searches for ansys path within the standard install location + and returns the path of the latest version. + + Parameters + ---------- + version : int, float, optional + Version of ANSYS 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, 21.1 corresponds to 2021R1. + + Examples + -------- + Within Windows + + >>> from ansys.mapdl.core.launcher import find_ansys + >>> find_ansys() + 'C:/Program Files/ANSYS Inc/v211/ANSYS/bin/winx64/ansys211.exe', 21.1 + + Within Linux + + >>> find_ansys() + (/usr/ansys_inc/v211/ansys/bin/ansys211, 21.1) + """ + versions = _get_available_base_ansys(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) + if os.name == "nt": + ansys_bin = os.path.join(ans_path, "ansys", "bin", "winx64", f"ansys{version}.exe") + else: + ansys_bin = os.path.join(ans_path, "ansys", "bin", f"ansys{version}") + return ansys_bin, version / 10 + + +def is_valid_executable_path(exe_loc): + return ( + os.path.isfile(exe_loc) and re.search(r"ansys\d\d\d", os.path.basename(os.path.normpath(exe_loc))) is not None + ) + + +def is_common_executable_path(exe_loc): + path = os.path.normpath(exe_loc) + path = path.split(os.sep) + if re.search(r"v(\d\d\d)", exe_loc) is not None and re.search(r"ansys(\d\d\d)", exe_loc) is not None: + equal_version = re.search(r"v(\d\d\d)", exe_loc)[1] == re.search(r"ansys(\d\d\d)", exe_loc)[1] + else: + equal_version = False + + return ( + is_valid_executable_path(exe_loc) + and re.search(r"v\d\d\d", exe_loc) + and "ansys" in path + and "bin" in path + and equal_version + ) + + +def change_default_ansys_path(exe_loc): + """Change your default ansys path. + + Parameters + ---------- + exe_loc : str + Ansys executable path. Must be a full path. + + Examples + -------- + Change default Ansys location on Linux + + >>> from ansys.mapdl.core import launcher + >>> launcher.change_default_ansys_path('/ansys_inc/v201/ansys/bin/ansys201') + >>> launcher.get_ansys_path() + '/ansys_inc/v201/ansys/bin/ansys201' + + Change default Ansys location on Windows + + >>> ans_pth = 'C:/Program Files/ANSYS Inc/v193/ansys/bin/winx64/ANSYS193.exe' + >>> launcher.change_default_ansys_path(ans_pth) + + """ + if os.path.isfile(exe_loc): + with open(CONFIG_FILE, "w") as f: + f.write(exe_loc) + else: + raise FileNotFoundError("File %s is invalid or does not exist" % exe_loc) + + +def save_ansys_path(exe_loc=None, allow_prompt=True): + """Find MAPDL's path or query user. + + If no ``exe_loc`` argument is supplied, this function attempt + to obtain the MAPDL executable from (and in order): + + - The default ansys paths (i.e. ``'C:/Program Files/Ansys Inc/vXXX/ansys/bin/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 configuration file location (``config.txt``) can be found in + ``appdirs.user_data_dir("ansys_mapdl_core")``. For example: + + .. code:: pycon + + >>> import appdirs + >>> import os + >>> print(os.path.join(appdirs.user_data_dir("ansys_mapdl_core"), "config.txt")) + C:/Users/user/AppData/Local/ansys_mapdl_core/ansys_mapdl_core/config.txt + + Examples + -------- + You can change the default ``exe_loc`` either by modifying the mentioned + ``config.txt`` file or by executing: + + >>> from ansys.mapdl.core import save_ansys_path + >>> save_ansys_path('/new/path/to/executable') + + """ + if exe_loc is None: + exe_loc, _ = find_ansys() + + if is_valid_executable_path(exe_loc): + if not is_common_executable_path(exe_loc): + warn_uncommon_executable_path(exe_loc) + + change_default_ansys_path(exe_loc) + return exe_loc + + if exe_loc is not None: + if is_valid_executable_path(exe_loc): + return exe_loc + if allow_prompt: + exe_loc = _prompt_ansys_path() + return exe_loc + + +def _prompt_ansys_path(): # pragma: no cover + print("Cached ANSYS executable not found") + print( + "You are about to enter manually the path of the ANSYS MAPDL executable(ansysXXX,where XXX is the version\n" + "This file is very likely to contained in path ending in 'vXXX/ansys/bin/ansysXXX', but it is not required.\n" + "\nIf you experience problems with the input path you can overwrite the configuration file by typing:\n" + ">>> from ansys.mapdl.core.launcher import save_ansys_path\n" + ">>> save_ansys_path('/new/path/to/executable/')\n" + ) + need_path = True + while need_path: # pragma: no cover + exe_loc = input("Enter the location of an ANSYS executable (ansysXXX):") + + if is_valid_executable_path(exe_loc): + if not is_common_executable_path(exe_loc): + warn_uncommon_executable_path(exe_loc) + with open(CONFIG_FILE, "w") as f: + f.write(exe_loc) + need_path = False + else: + print("The supplied path is either: not a valid file path, or does not match 'ansysXXX' name.") + return exe_loc + + +def warn_uncommon_executable_path(exe_loc): + warnings.warn( + f"The supplied path ('{exe_loc}') does not match the usual ansys executable path style" + "('directory/vXXX/ansys/bin/ansysXXX'). " + "You might have problems at later use." + ) + + +def get_ansys_path(allow_input=True, version=None): + """Acquires ANSYS Path from a cached file or user input + + Parameters + ---------- + allow_input : bool, optional + Allow user input to find ANSYS path. The default is ``True``. + + version : float, optional + Version of ANSYS to search for. For example ``version=22.2``. + If ``None``, use latest. + + """ + exe_loc = None + if not version and os.path.isfile(CONFIG_FILE): + with open(CONFIG_FILE) as f: + exe_loc = f.read() + # verify + if not os.path.isfile(exe_loc) and allow_input: + exe_loc = save_ansys_path() + elif not version and allow_input: # create configuration file + exe_loc = save_ansys_path() + + if exe_loc is None: + exe_loc = find_ansys(version=version)[0] + if not exe_loc: + exe_loc = None + + return exe_loc diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 00000000..9f0daa07 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,15 @@ +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): + assert is_float(values[0]) == values[1] diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 00000000..a0435819 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,100 @@ +import os + +import pytest + +from ansys.tools.path import find_ansys +from ansys.tools.path.path import ( + CONFIG_FILE, + _version_from_path, + change_default_ansys_path, + get_ansys_path, + get_available_ansys_installations, + is_valid_executable_path, + save_ansys_path, + warn_uncommon_executable_path, +) + +# , save_ansys_path, get_ansys_path, get_available_ansys_installations, check_valid_ansys + + +""" +pytest -v --durations=10 \ + --cov=ansys.tools.path \ + --cov-report=html + +""" + +paths = [ + ("/usr/dir_v2019.1/slv/ansys_inc/v211/ansys/bin/ansys211", 211), + ("C:/Program Files/ANSYS Inc/v202/ansys/bin/win64/ANSYS202.exe", 202), + ("/usr/ansys_inc/v211/ansys/bin/mapdl", 211), + pytest.param(("/usr/ansys_inc/ansys/bin/mapdl", 211), marks=pytest.mark.xfail), +] + + +@pytest.mark.parametrize("path_data", paths) +def test_version_from_path(path_data): + exec_file, version = path_data + assert _version_from_path(exec_file) == version + + +def test_find_ansys_linux(): + # assuming ansys is installed, should be able to find it on linux + # without env var + bin_file, ver = find_ansys() + assert os.path.isfile(bin_file) + assert isinstance(ver, float) + + +def test_get_available_base_ansys(): + assert get_available_ansys_installations() + + +def test_is_valid_executable_path(): + path = get_available_ansys_installations().values() + path = list(path)[0] + assert not is_valid_executable_path(path) + + +def test_is_common_executable_path(): + path = get_available_ansys_installations().values() + path = list(path)[0] + assert not is_valid_executable_path(path) + + +def test_change_default_ansys_path(): + if os.path.isfile(CONFIG_FILE): + with open(CONFIG_FILE, "r") as fid: + assert "/bin/bash" not in fid.read() + + new_path = "/bin/bash" # Just to check something + change_default_ansys_path(new_path) + + with open(CONFIG_FILE, "r") as fid: + assert "/bin/bash" in fid.read() + + os.remove(CONFIG_FILE) + + with pytest.raises(FileNotFoundError): + change_default_ansys_path("asdf") + + +def test_save_ansys_path(): + if os.path.isfile(CONFIG_FILE): + os.remove(CONFIG_FILE) + + path = get_available_ansys_installations().values() + path = list(path)[0] + + assert save_ansys_path(path, allow_prompt=False) + assert save_ansys_path(None, allow_prompt=False) + + +def test_warn_uncommon_executable_path(): + with pytest.warns(UserWarning): + warn_uncommon_executable_path("qwer") + + +def test_get_ansys_path(): + assert get_ansys_path() + assert get_ansys_path(version=222)