From 4bf268309764dd32b4cfde41b388df7add644c42 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 18:52:27 +0100 Subject: [PATCH 01/18] initial check-in: files are split out from original ansys-api-workbench repo --- .github/workflows/ci.yml | 83 ++++++ README.md | 105 +++++++- example/demo.ipynb | 188 +++++++++++++ example/load_geometry.wbjn | 13 + example/sample.png | Bin 0 -> 81063 bytes pyproject.toml | 3 + setup.py | 50 ++++ src/ansys/workbench/core/VERSION | 1 + src/ansys/workbench/core/__init__.py | 8 + src/ansys/workbench/core/example_data.py | 25 ++ src/ansys/workbench/core/launch_workbench.py | 181 +++++++++++++ src/ansys/workbench/core/py.typed | 0 src/ansys/workbench/core/workbench_client.py | 262 +++++++++++++++++++ user_guide.md | 114 ++++++++ 14 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 example/demo.ipynb create mode 100644 example/load_geometry.wbjn create mode 100644 example/sample.png create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 src/ansys/workbench/core/VERSION create mode 100644 src/ansys/workbench/core/__init__.py create mode 100644 src/ansys/workbench/core/example_data.py create mode 100644 src/ansys/workbench/core/launch_workbench.py create mode 100644 src/ansys/workbench/core/py.typed create mode 100644 src/ansys/workbench/core/workbench_client.py create mode 100644 user_guide.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a72316 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: Build and Publish ansys-workbench-core + +# run only on main branch. This avoids duplicated actions on PRs +on: + pull_request: + push: + tags: + - "*" + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MAIN_PYTHON_VERSION: "3.10" + PACKAGE_NAME: "ansys.workbench.core" + +jobs: + build: + name: Build package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + - name: Install build requirements + run: | + pip install -U pip + pip install build + - name: Build + run: python -m build + - name: Install + run: pip install dist/*.whl + - name: Test import + run: | + mkdir tmp + cd tmp + python -c "import ${{ env.PACKAGE_NAME }}; print('Sucessfully imported ${{ env.PACKAGE_NAME }}')" + python -c "from ${{ env.PACKAGE_NAME }} import __version__; print(__version__)" + - name: Upload packages + uses: actions/upload-artifact@v3 + with: + name: ansys-workbench-core-packages + path: dist/ + retention-days: 7 + + Release: + if: github.event_name == 'push' && contains(github.ref, 'refs/tags') + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + + - uses: actions/download-artifact@v3 + + - name: Display structure of downloaded files + run: ls -R + + - name: Upload to Ansys Private PyPi + run: | + pip install twine + twine upload --skip-existing ./ansys-workbench-core-packages/*.whl + twine upload --skip-existing ./ansys-workbench-core-packages/*.tar.gz + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} + TWINE_REPOSITORY_URL: https://pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/upload + + - name: Release + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + files: | + ./**/*.whl + ./**/*.tar.gz + ./**/*.pdf diff --git a/README.md b/README.md index 7fe00e6..5cab387 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,103 @@ -# pyworkbench -PyWorkbench +### ansys-workbench-core PyWorkbench Package + +This Python package contains the public API to PyWorkbench. + + +#### Installation + +NOTE: Users must first apply the `PYANSYS_PRIVATE_PYPI_PAT` token as an environment variable. This allows authentication with Private PyPI. +The value can be found at the following [link](https://dev-docs.solutions.ansys.com/solution_journey/journey_prepare/connect_to_private_pypi.html). + +Provided that these wheels have been published to PyPI, they can be +installed with: + +Linux (bash) +```bash +# Create and activate a virtualenv +python -m venv .venv +source .venv/bin/activate # can be deactivated with the 'deactivate' command +# Install pre-requisites to connect to artifact feed +pip install --upgrade pip +pip install keyring artifacts-keyring +#Install wMI (Not Available in Private PyPI At This Time) +pip install wMI==1.5.1 +# Install the latest package +pip install --index-url https://$PYANSYS_PRIVATE_PYPI_PAT@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core +# OR - Install a package version of your choice +pip install --index-url https://$PYANSYS_PRIVATE_PYPI_PAT@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core==0.1.2 +``` + +Windows (PowerShell) +```powershell +# Create and activate a virtualenv +python.exe -m venv .venv +.\venv\Scripts\Activate.ps1 # can be deactivated with the 'deactivate' command +# Install pre-requisites to connect to artifact feed +pip.exe install --upgrade pip +pip.exe install keyring artifacts-keyring +#Install wMI (Not Available in Private PyPI At This Time) +pip.exe install wMI==1.5.1 +# Install the latest package +pip.exe install --index-url https://$env:PYANSYS_PRIVATE_PYPI_PAT@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core +# OR - Install a package version of your choice +pip.exe install --index-url https://$env:PYANSYS_PRIVATE_PYPI_PAT@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core==0.1.2 +``` + +Windows (Command Prompt) +``` +# Create and activate a virtualenv +python -m venv .venv +.\venv\Scripts\activate.bat # can be deactivated with the 'deactivate' command +# Install pre-requisites to connect to artifact feed +pip install --upgrade pip +pip install keyring artifacts-keyring +#Install wMI (Not Available in Private PyPI At This Time) +pip install wMI==1.5.1 +# Install the latest package +pip install --index-url https://%PYANSYS_PRIVATE_PYPI_PAT%@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core +# OR - Install a package version of your choice +pip install --index-url https://%PYANSYS_PRIVATE_PYPI_PAT%@pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/simple ansys-workbench-core==0.1.2 +``` + + +#### Build + +To build the gRPC packages, run: + +``` +pip install build +python -m build +``` + +This will create both the source distribution containing just the protofiles +along with the wheel containing the protofiles and build Python interface +files. + +Note that the interface files are identical regardless of the version of Python +used to generate them, but the last pre-built wheel for ``grpcio~=1.17`` was +Python 3.7, so to improve your build time, use Python 3.7 when building the +wheel. + + +#### Automatic Deployment + +This repository contains GitHub CI/CD that enables the automatic building of +source and wheel packages for these gRPC Python interface files. By default, +these are built on PRs, the main branch, and on tags when pushing. Artifacts +are uploaded to GitHub for each PR and push to the main branch. Artifacts +are published to Ansys Private PyPI when tags are pushed. + +To release wheels to PyPI, ensure your branch is up-to-date and then +push tags. For example, for the version ``v0.1.5``. This version MUST MATCH +the version in `src/ansys/workbench/core/VERSION`. + +For example, if you intend to release version `0.1.5` to Private PyPI, the VERSION +file should contain '0.1.5'. You will then run: + +```bash +git tag v0.1.5 +git push --tags +``` + +Note that there is a 'v' prepended to the GitHub tag, keeping with best practices. +The 'v' is not required in the `VERSION` file. diff --git a/example/demo.ipynb b/example/demo.ipynb new file mode 100644 index 0000000..4a2db25 --- /dev/null +++ b/example/demo.ipynb @@ -0,0 +1,188 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "558376fd-674a-424c-8885-65a872acfd95", + "metadata": {}, + "source": [ + "# PyFluent and PyMechanical out of PyWorkbench session" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "014c5732-9f2b-423d-81ed-8e70928b2e47", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "from ansys.workbench.core.launch_workbench import launch_workbench\n", + "import ansys.fluent.core as pyfluent\n", + "from ansys.mechanical.core import launch_mechanical" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "705a3b24-b51d-4724-8a17-f7307873197c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# launch Workbench on the local machine\n", + "wb = launch_workbench(client_workdir='C:/Users/fli/demo')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d02aea0f-e99b-434b-b3d1-c60a87b092ea", + "metadata": {}, + "outputs": [], + "source": [ + "# upload-download-file round trip\n", + "wb.upload_file('sample.png')\n", + "wb.run_script_string(\"\"\"import os\n", + "wdir = GetServerWorkingDirectory()\n", + "if os.path.exists(os.path.join(wdir, 'renamed.png')):\n", + " os.remove(os.path.join(wdir, 'renamed.png'))\n", + "os.rename(os.path.join(wdir, 'sample.png'), os.path.join(wdir, 'renamed.png'))\n", + "\"\"\")\n", + "wb.download_file('renamed.png')\n", + "are_same = open('sample.png', 'rb').read() == open('renamed.png', 'rb').read()\n", + "print('are two files the same? ' + str(are_same))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6af4888-7482-4c40-96da-c959b4147b3a", + "metadata": {}, + "outputs": [], + "source": [ + "# create a Fluent system and return its name\n", + "fluent_sys_name = wb.run_script_string('import json\\nwb_script_result=json.dumps(GetTemplate(TemplateName=\\\"FLUENT\\\").CreateSystem().Name)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e88ab850-d840-4919-954c-eacd29a699fd", + "metadata": {}, + "outputs": [], + "source": [ + "# start PyFluent server on the system, then create a PyFluent client session\n", + "server_info_file=wb.start_pyfluent(system_name=fluent_sys_name)\n", + "fluent=pyfluent.connect_to_fluent(server_info_filepath=server_info_file)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "221ad401-82d1-480d-8910-da0dd34b5a2c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# basic status queries on the PyFluent session\n", + "print('server is serving? ' + str(fluent.health_check_service.is_serving))\n", + "print('server version: ' + fluent.get_fluent_version())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a3fd1a1-d261-45e0-9c9f-3f043d00aea6", + "metadata": {}, + "outputs": [], + "source": [ + "# upload an example Fluent case file, load it into the Fluent session\n", + "wb.upload_file_from_example_repo(\"elbow.cas.h5\", \"elbow_case_file\")\n", + "setup_state=wb.run_script_string(f\"\"\"import os\n", + "sys=GetSystem(Name='{fluent_sys_name}')\n", + "dir=GetServerWorkingDirectory()\n", + "sys.GetContainer(ComponentName=\"Setup\").Import(FilePath=os.path.join(dir, 'elbow.cas.h5'), FileType='CffCase')\n", + "setup=sys.GetComponent(Name=\"Setup\")\n", + "setup.Refresh()\n", + "wb_script_result = json.dumps(GetComponentState(Component=setup).State.ToString())\n", + "\"\"\")\n", + "print('Fluent Setup component is ' + setup_state)\n", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a94f8a4e-4995-4d48-8966-8734c6c9d4bc", + "metadata": {}, + "outputs": [], + "source": [ + "# create a Mechanical system on Workbench, then load geometry\n", + "wb.upload_file_from_example_repo(\"2pipes.agdb\", \"2pipes\")\n", + "ss_system_name = wb.run_script_file('load_geometry.wbjn')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "110c8edc-e890-422b-8323-3dddb2af346c", + "metadata": {}, + "outputs": [], + "source": [ + "# start PyMechanical server on the system, then create a PyMechanical client session\n", + "#server_port=wb.start_pymechanical(system_name=ss_system_name)\n", + "mechanical = launch_mechanical(start_instance=False, ip='localhost', port=server_port)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48320724-37fb-41d4-959e-d9b0437a7bff", + "metadata": {}, + "outputs": [], + "source": [ + "# perform some queries on the PyMechanical session\n", + "print(mechanical.version)\n", + "mechanical.list_files()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6dfe8b49-c81b-4718-851e-e1f6f0f30a12", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# shutdown the Workbench service\n", + "wb.exit()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/example/load_geometry.wbjn b/example/load_geometry.wbjn new file mode 100644 index 0000000..9b1ca10 --- /dev/null +++ b/example/load_geometry.wbjn @@ -0,0 +1,13 @@ +import os +import json + +work_dir = GetServerWorkingDirectory() +geometry_file = os.path.join(work_dir, "2pipes.agdb") + +# create a Static Structural system with the given geometry +template = GetTemplate(TemplateName="Static Structural", Solver="ANSYS") +system = CreateSystemFromTemplate(Template=template, Name="Static Structural (ANSYS)") +system.GetContainer(ComponentName="Geometry").SetFile(FilePath=geometry_file) + +# output the name of the system +wb_script_result = json.dumps(system.Name) diff --git a/example/sample.png b/example/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..145a3e9d1c18efc196019f66d9d9a5cc03429c33 GIT binary patch literal 81063 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 Y3k|Y$sGE6uAO?RECrk-F8X`#jf0LxL(f|Me literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..991f454 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools >= 42.0.0", "wheel", "ansys_tools_protoc_helper"] +build-backend = "setuptools.build_meta:__legacy__" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..be4ce8e --- /dev/null +++ b/setup.py @@ -0,0 +1,50 @@ +"""Installation file for the ansys-workbench-core package""" + +import os +from datetime import datetime + +import setuptools + +from ansys.tools.protoc_helper import CMDCLASS_OVERRIDE + +# Get the long description from the README file +HERE = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(HERE, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +product = "workbench" +library = "core" +package_info = ["ansys", product, library] +with open(os.path.join(HERE, "src", "ansys", product, library, "VERSION"), encoding="utf-8") as f: + version = f.read().strip() + +package_name = "ansys-workbench-core" +dot_package_name = '.'.join(filter(None, package_info)) + +description = f"Public Python API package for {package_name}, built on {datetime.now().strftime('%H:%M:%S on %d %B %Y')}" + +if __name__ == "__main__": + setuptools.setup( + name=package_name, + version=version, + author="ANSYS, Inc.", + author_email='frank.li@ansys.com', + description=description, + long_description=long_description, + long_description_content_type='text/markdown', + url=f"https://github.com/ansys-internal/{package_name}", + license="MIT", + python_requires=">=3.7", + install_requires=["grpcio~=1.17", "protobuf~=3.19", "wMI>=1.4.9", "tqdm>=4.65.0"], + package_dir = {"": "src"}, + packages=setuptools.find_namespace_packages("src", include=("ansys.*",)), + package_data={ + "": ["*.pyi", "py.typed", "VERSION"], + }, + entry_points={ + "ansys.tools.protoc_helper.proto_provider": [ + f"{dot_package_name}={dot_package_name}" + ], + }, + cmdclass=CMDCLASS_OVERRIDE + ) diff --git a/src/ansys/workbench/core/VERSION b/src/ansys/workbench/core/VERSION new file mode 100644 index 0000000..c946ee6 --- /dev/null +++ b/src/ansys/workbench/core/VERSION @@ -0,0 +1 @@ +0.1.6 diff --git a/src/ansys/workbench/core/__init__.py b/src/ansys/workbench/core/__init__.py new file mode 100644 index 0000000..ba9ec4c --- /dev/null +++ b/src/ansys/workbench/core/__init__.py @@ -0,0 +1,8 @@ +"""Autogenerated Python gRPC interface package for ansys-api-workbench.""" + +import pathlib + +__all__ = ["__version__"] + +with open(pathlib.Path(__file__).parent / "VERSION", encoding="utf-8") as f: + __version__ = f.read().strip() diff --git a/src/ansys/workbench/core/example_data.py b/src/ansys/workbench/core/example_data.py new file mode 100644 index 0000000..6562e9f --- /dev/null +++ b/src/ansys/workbench/core/example_data.py @@ -0,0 +1,25 @@ +import os +import logging +import shutil +import urllib.request + +class ExampleData: + """fetch data file from PyWorkbench example data repository.""" + + def _get_file_url(filename, dirname): + return f"https://github.com/pyansys/example-data/raw/master/pyworkbench/{dirname}/{filename}" + + def __retrieve_file(url, local_file_path): + logging.info(f"Downloading {url} from example data repository ...") + + with urllib.request.urlopen(url) as in_stream: + if (in_stream.code != 200): + raise Exception("error getting the url, code " + str(in_stream.code)) + with open(local_file_path, "wb") as out_file: + shutil.copyfileobj(in_stream, out_file) + logging.info(f"Downloaded the file as {local_file_path}") + + def download(filename, dirname, local_dir_path): + url = ExampleData._get_file_url(filename, dirname) + local_file_path = os.path.join(local_dir_path, filename) + return ExampleData.__retrieve_file(url, local_file_path) diff --git a/src/ansys/workbench/core/launch_workbench.py b/src/ansys/workbench/core/launch_workbench.py new file mode 100644 index 0000000..20a7beb --- /dev/null +++ b/src/ansys/workbench/core/launch_workbench.py @@ -0,0 +1,181 @@ +import os +import logging +import tempfile +import time +import uuid +import wmi +from ansys.workbench.core.workbench_client import WorkbenchClient + +class LaunchWorkbench: + """launch Workbench server on local or remote Windows machine and create a Workbench client that connects to the server. """ + + def __init__(self, release = '241', client_workdir = None, server_workdir = None, host = None, username = None, password = None): + self._release = release + self._server_workdir = server_workdir + self._host = host + self._username = username + self._password = password + self._wmi_connection = None + self._process_id = -1 + self._client = None + + if len(release) != 3 or not release.isdigit(): + raise Exception("invalid ANSYS release number: " + release) + if client_workdir is None: + client_workdir = tempfile.gettempdir() + self.client_workdir = client_workdir + port = self._launch_server() + if port is not None and port > 0: + if host is None: + host = 'localhost' + self.client = WorkbenchClient(local_workdir = client_workdir, server_host = host, server_port = port) + self.client.connect() + + def _launch_server(self): + try: + if self._host is None: + self._wmi_connection = wmi.WMI() + else: + self._wmi_connection = wmi.WMI(self._host, user=self._username, password=self._password) + logging.info("host connection established") + + install_path = None + for ev in self._wmi_connection.Win32_Environment(): + if ev.Name == "AWP_ROOT" + self._release: + install_path = ev.VariableValue + break + if install_path is None: + install_path = "C:/Program Files/Ansys Inc/v" + self._release + logging.warning("ANSYS installation not found. Assume the default location: " + install_path) + else: + logging.info("ANSYS installation found at: " + install_path) + exePath = os.path.join(install_path, "Framework", "bin", "Win64", "RunWB2.exe") + prefix = uuid.uuid4().hex + workdir_arg = '' + if self._server_workdir is not None: + workdir_arg = ",WorkingDirectory=\'" + self._server_workdir + "\'" + cmdLine = exePath + " -I -E \"StartServer(EnvironmentPrefix=\'" + prefix + "\'" + workdir_arg + ")\"" + + process_startup_info = self._wmi_connection.Win32_ProcessStartup.new(ShowWindow=1) + process_id, result = self._wmi_connection.Win32_Process.Create( + CommandLine = cmdLine, + ProcessStartupInformation = process_startup_info) + + if result == 0: + logging.info("Workbench launched on the host with process id " + str(process_id)) + self._process_id = process_id + else: + logging.error("Workbench failed to launch on the host") + return 0 + + # retrieve server port once WB is fully up running + port = None + timeout = 60*8 # set 8 minutes as upper limit for WB startup + start_time = time.time() + while True: + for ev in self._wmi_connection.Win32_Environment(): + if ev.Name == "ANSYS_FRAMEWORK_SERVER_PORT": + port = ev.VariableValue + if port.startswith(prefix): + port = port[len(prefix):] + break + else: + port = None + break + if port is not None: + break + if time.time() - start_time > timeout: + logging.error("Failed to start Workbench service within reasonable timeout") + break; + time.sleep(10) + if port is None: + logging.error("Failed to retrieve the port used by Workbench service") + else: + logging.info("Workbench service uses port " + port) + + return int(port) + + except wmi.x_wmi: + logging.error("wrong credential") + + def exit(self): + if self.client is not None: + self.client.disconnect() + self.client = None + + if self._wmi_connection is None: + return + + # collect parent-children mapping + children = { self._process_id : [] } + process_by_id = {} + for p in self._wmi_connection.Win32_Process(): + process_by_id[p.ProcessId] = p + children.setdefault(p.ProcessId, []) + if p.ParentProcessId is None: + continue; + children.setdefault(p.ParentProcessId, []) + children[p.ParentProcessId].append(p.ProcessId) + + # terminate related processes bottom-up + toTerminate = [] + thisLevel = set([ self._process_id ]) + while True: + nextLevel = set() + for p in thisLevel: + nextLevel.update(children[p]) + if len(nextLevel) == 0: + break + toTerminate.append(nextLevel) + thisLevel = nextLevel + for ps in reversed(toTerminate): + for p in ps: + logging.info("shutting down " + process_by_id[p].Name + " ...") + try: + process_by_id[p].Terminate() + except: + pass + + logging.info("Workbench server ended") + self._wmi_connection = None + self._process_id = -1 + + def set_console_log_level(self, log_level): + self.client.set_console_log_level(log_level) + + def set_log_file(self, log_file): + self.client.set_log_file(log_file) + + def reset_log_file(self): + self.client.reset_log_file() + + def run_script_string(self, script_string, log_level='error'): + return self.client.run_script_string(script_string, log_level) + + def run_script_file(self, script_file_name, log_level='error'): + return self.client.run_script_file(script_file_name, log_level) + + def upload_file(self, *file_list, show_progress=True): + self.client.upload_file(*file_list, show_progress=show_progress) + + def upload_file_from_example_repo(self, filename, dirname, show_progress=True): + self.client.upload_file_from_example_repo(filename, dirname, show_progress) + + def download_file(self, file_name, show_progress=True, target_dir=None): + return self.client.download_file(file_name, show_progress=show_progress, target_dir=target_dir) + + def start_pymechanical(self, system_name): + return self.client.start_pymechanical(system_name) + + def start_pyfluent(self, system_name): + return self.client.start_pyfluent(system_name) + +"""launch Workbench server on local or remote Windows machine and create a Workbench client that connects to the server. """ +def launch_workbench( + release = '241', + client_workdir = None, + server_workdir = None, + host = None, + username = None, + password = None): + return LaunchWorkbench(release, client_workdir, server_workdir, host, username, password) diff --git a/src/ansys/workbench/core/py.typed b/src/ansys/workbench/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/ansys/workbench/core/workbench_client.py b/src/ansys/workbench/core/workbench_client.py new file mode 100644 index 0000000..71f20d1 --- /dev/null +++ b/src/ansys/workbench/core/workbench_client.py @@ -0,0 +1,262 @@ +import os +import logging +import glob +import grpc +import json +import tqdm +from ansys.api.workbench.v0 import workbench_pb2 as wb +from ansys.api.workbench.v0.workbench_pb2_grpc import WorkbenchServiceStub +from ansys.workbench.core.example_data import ExampleData + +class WorkbenchClient: + """gRPC client used to connect to a Workbench server.""" + + def __init__(self, local_workdir, server_host, server_port): + self.workdir = local_workdir + self._server_host = server_host + self._server_port = server_port + self.__init_logging() + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.disconnect() + + def connect(self): + hnp = self._server_host + ':' + str(self._server_port) + self.channel = grpc.insecure_channel(hnp) + self.stub = WorkbenchServiceStub(self.channel) + logging.info("connected to the WB server at " + hnp) + + def disconnect(self): + if self.channel: + self.channel.close() + self.channel = None + self.stub = None + logging.info("disconnected from the WB server") + + def is_connected(self): + return self.channel != None + + def __init_logging(self): + self._logger = logging.getLogger("WB") + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + stream_handler.setLevel(logging.WARNING) + self._logger.addHandler(stream_handler) + self.__log_console_handler = stream_handler + + def set_console_log_level(self, log_level): + self.__log_console_handler.setLevel(WorkbenchClient.__to_python_log_level(log_level)) + + def set_log_file(self, log_file): + self.reset_log_file() + + file_handler = logging.handlers.WatchedFileHandler(log_file) + file_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + file_handler.setLevel(logging.DEBUG) + self._logger.addHandler(file_handler) + self.__log_file_handler = file_handler + + def reset_log_file(self): + if self.__log_file_handler is None: + return + self._logger.removeHandler(self.__log_file_handler) + self.__log_file_handler = None + + __log_file_handler = None + __log_console_handler = None + + + def run_script_string(self, script_string, log_level='error'): + if not self.is_connected(): + logging.error("Workbench client is not yet connected to a server") + request = wb.RunScriptRequest(content=script_string, log_level=WorkbenchClient.__to_server_log_level(log_level)) + for response in self.stub.RunScript(request): + if response.log and response.log.messages and len(response.log.messages) > 0: + for log_entry in response.log.messages: + self.__python_logging(log_entry.level, log_entry.message) + if response.result: + if response.result.error: + logging.error("error when running the script: " + response.result.error) + return None + elif response.result.result: + logging.info("the script run finished") + return json.loads(response.result.result) + + def run_script_file(self, script_file_name, log_level='error'): + if not self.is_connected(): + logging.error("Workbench client is not yet connected to a server") + script_path = os.path.join(self.workdir, script_file_name) + with open(script_path, encoding='UTF-8') as sf: + script_string = sf.read() + return self.run_script_string(script_string, log_level) + + def upload_file(self, *file_list, show_progress=True): + if not self.is_connected(): + logging.error("Workbench client is not yet connected to a server") + requested = [] + for file_pattern in file_list: + if '*' in file_pattern or '?' in file_pattern: + if not os.path.isabs(file_pattern): + file_pattern = os.path.join(self.workdir, file_pattern) + requested.extend(glob.glob(file_pattern)) + else: + requested.append(file_pattern) + existing_files = [] + nonexisting_files = [] + for file_name in requested: + if not os.path.isabs(file_name): + file_name = os.path.join(self.workdir, file_name) + if os.path.isfile(file_name): + existing_files.append(file_name) + else: + nonexisting_files.append(file_name) + if len(nonexisting_files) > 0: + logging.warning("The following files do not exist and are skipped: " + '\n'.join(nonexisting_files)) + for file_path in existing_files: + logging.info(f"uploading file {file_path}") + response = self.stub.UploadFile(self.__upload_iterator(file_path, show_progress)) + if response.error: + logging.error("error during file upload: " + response.error) + else: + logging.info("a file is uploaded to the server with name: " + response.file_name) + + def __upload_iterator(self, file_path, show_progress): + dir_path, file_name = os.path.split(file_path) + yield wb.UploadFileRequest(file_name=file_name) + + pbar = None + if show_progress: + bytes = os.path.getsize(file_path) + pbar = tqdm.tqdm( + total=bytes, + desc=f"Uploading {file_name}", + unit="B", + unit_scale=True, + unit_divisor=1024, + ) + + chunk_size = 65536 ## 64 kb + with open(file_path, mode="rb") as f: + while True: + chunk = f.read(chunk_size) + if chunk: + if pbar is not None: + pbar.update(len(chunk)) + yield wb.UploadFileRequest(file_content=chunk) + else: + if pbar is not None: + pbar.close() + return + + def upload_file_from_example_repo(self, filename, dirname, show_progress=True): + if not self.is_connected(): + logging.error("Workbench client is not yet connected to a server") + ExampleData.download(filename, dirname, self.workdir) + self.upload_file(filename, show_progress=show_progress) + + def download_file(self, file_name, show_progress=True, target_dir=None): + if not self.is_connected(): + logging.error("Workbench client is not yet connected to a server") + request = wb.DownloadFileRequest(file_name=file_name) + file_name = file_name.replace('*', '_').replace('?', '_') + td = target_dir + if td is None: + td = self.workdir + file_path = os.path.join(td, file_name) + pbar = None + started = False + for response in self.stub.DownloadFile(request): + if response.error: + logging.error("error during file download: " + response.error) + return None; + if response.file_info: + if response.file_info.is_archive: + file_name += ".zip" + file_path += ".zip" + if response.file_info.file_size > 0: + if show_progress: + pbar = tqdm.tqdm( + total=response.file_info.file_size, + desc=f"Downloading {file_name}", + unit="B", + unit_scale=True, + unit_divisor=1024, + ) + if response.file_content: + size = len(response.file_content) + if size > 0: + if not started: + if os.path.exists(file_path): + os.remove(file_path) + started = True + with open(file_path, mode="ab") as f: + f.write(response.file_content) + if pbar is not None: + pbar.update(size) + logging.info(f"downloaded the file {file_name}") + if pbar is not None: + pbar.close() + return file_name; + + def __python_logging(self, log_level, msg): + if log_level == wb.LOG_DEBUG: + self._logger.debug(msg) + elif log_level == wb.LOG_INFO: + self._logger.info(msg) + elif log_level == wb.LOG_WARNING: + self._logger.warn(msg) + elif log_level == wb.LOG_ERROR: + self._logger.error(msg) + elif log_level == wb.LOG_FATAL: + self._logger.fatal(msg) + + @staticmethod + def __to_python_log_level(log_level): + log_level = log_level.lower() + for level_name, server_level in WorkbenchClient.__log_levels.items(): + if log_level in level_name: + return server_level[1] + return logging.NOTSET + + @staticmethod + def __to_server_log_level(log_level): + log_level = log_level.lower() + for level_name, server_level in WorkbenchClient.__log_levels.items(): + if log_level in level_name: + return server_level[0] + return wb.LOG_NONE + + __log_levels = { + 'none null' : (wb.LOG_NONE, logging.NOTSET), + 'debug' : (wb.LOG_DEBUG, logging.DEBUG), + 'information' : (wb.LOG_INFO, logging.INFO), + 'warning' : (wb.LOG_WARNING, logging.WARNING), + 'error' : (wb.LOG_ERROR, logging.ERROR), + 'fatal critical' : (wb.LOG_FATAL, logging.CRITICAL), + } + + def start_pymechanical(self, system_name): + pymech_port = self.run_script_string(f"""import json +server_port=LaunchMechanicalServerOnSystem(SystemName="{system_name}") +wb_script_result=json.dumps(server_port) +""" +) + return pymech_port + + def start_pyfluent(self, system_name): + server_info_file_name = self.run_script_string(f"""import json +server_info_file=LaunchFluentServerOnSystem(SystemName="{system_name}") +wb_script_result=json.dumps(server_info_file) +""" +) + local_copy = os.path.join(self.workdir, server_info_file_name) + if os.path.exists(local_copy): + os.remove(local_copy) + self.download_file(server_info_file_name, show_progress=False) + return local_copy diff --git a/user_guide.md b/user_guide.md new file mode 100644 index 0000000..7801963 --- /dev/null +++ b/user_guide.md @@ -0,0 +1,114 @@ +# workig with Workbench gRPC service + + +## start Workbench client and connect to a running Workbench server +General users of Workbench gRPC service typically starts a Workbench client that connects to a running Workbench server on cloud, given the server's host name/IP and port. +A client side working directory should be specified. This directory is the default location for client side files. +``` +from ansys.api.workbench.v0.workbench_client import WorkbenchClient +workdir = "path_to_the_local_working_directory" +host = "server_machine_name_or_ip" +port = server_port_number +wb = WorkbenchClient(workdir, host, port) +wb.connect() +``` + +## start Workbench server and client +For solution method developers, it is often useful for implementation or debugging purpose to start Workbench server on the developer's desktop or some computer within the company network. One can manually start a Workbench server by executing command `StartServer()` in any Workbench session and take a note of the returned server port. + +Alternatively, one can launch Workbench server and client in Python script. To launch it on the local computer: +``` +from ansys.api.workbench.v0.launch_workbench import LaunchWorkbench +wb = LaunchWorkbench() +``` +or to launcher server on a remote Windows machine with valid user credentials: +``` +from ansys.api.workbench.v0.launch_workbench import LaunchWorkbench +host = "server_machine_name_or_ip" +username = "your_username_on_server_machine" +password = "your_password_on_server_machine" +wb = LaunchWorkbench(host, username, password) +``` +There are options to launch Workbench server of a certain release, or to use specified working directories on server or client side instead of the default directories. +``` +from ansys.api.workbench.v0.launch_workbench import LaunchWorkbench +wb = LaunchWorkbench(release='241', server_workdir='path_to_a_dir_on_server', client_workdir='path_to_a_dir_on_client') +``` + +## run commands/queries on Workbench server +Workbench scripts containing commands/queries can be executed on the server via +* `run_script_file`, which execute a script file in the client working directory; or +* `run_script_string`, which execute a script contained in the given string + +Any output that needs to be returned from these APIs can be assigned to the global variable `wb_script_result` in the Workbench script, as a JSON string. For example, the following Workbench script returns all message summaries from the Workbench session: +``` +import json +messages = [m.Summary for m in GetMessages()] +wb_script_result = json.dumps(messages) +``` +These run_script APIs can also be called with different logging levels. The default logging level is 'error'. The following line will print out all messages logged as either info/warning/error during the script run. +``` +wb.run_script_file('a_script_file_name', log_level='info') +``` + +## file handling +Data files can be uploaded to the server or downloaded from the server, using `upload_file` or `download_file` API. The client-side working directory is used to hold these files. There is also a working directory on the server for the same purpose. The server's working directory can be obtained via Workbench query `GetServerWorkingDirectory()`. + +This uploads all part files with a given prefix and all agdb files in the working directory, plus another file outside of the working directory, from client to server: +``` +wb.upload_file('model?.prt', '*.agdb', '/path/to/some/file') +``` + +The following server side Workbench script loads an uploaded geometry file from the server's working directory into a newly created Workbench system: +``` +import os +work_dir = GetServerWorkingDirectory() +geometry_file = os.path.join(work_dir, "2pipes.agdb") +template = GetTemplate(TemplateName="Static Structural", Solver="ANSYS") +system = CreateSystemFromTemplate(Template=template, Name="Static Structural (ANSYS)") +system.GetContainer(ComponentName="Geometry").SetFile(FilePath=geometry_file) +``` +The following server side Workbench script copies a Mechanical solver output file to the server's working directory to be downloaded later: +``` +import os +import shutil +work_dir = GetServerWorkingDirectory() +mechanical_dir = mechanical.project_directory +out_file_src = os.path.join(mechanical_dir, "solve.out") +out_file_des = os.path.join(work_dir, "solve.out") +shutil.copyfile(out_file_src, out_file_des) +``` +The client can then download all output file from the server: +``` +wb.download_file('*.out') +``` + +There is a special client API to upload a data file from [the ANSYS example database](https://github.com/ansys/example-data/tree/master/pyworkbench) directly to the Workbench server. The file name and subdirectory name in the database should be specified: +``` +client.upload_file_from_example_repo("2pipes.agdb", "2pipes") +``` + +All the file handling APIs come with progress bar that is shown by default. One can turn off progress bar with an optional argument: +``` +wb.download_file('solve.out', show_progress=False) +``` + +## start other PyANSYS services based on PyWorkbench +### PyMechanical +For any mechanical system in the Workbench project, PyMechanical service can be started and connected to from the same client machine. +The following server side script starts PyMechanical service for an existing Workbench system and returns the port useed by the service. +``` +import json +model = system.GetContainer(ComponentName="Model") +model.Edit() +mech_port = model.StartGrpcServer() +wb_script_result = json.dumps(mech_port) +``` +The following client script launches a PyMechanical client based on the port returned, then print the mechanical service's project directory. +``` +from ansys.mechanical.core import launch_mechanical +mechanical = launch_mechanical(start_instance=False, ip=host, port=mech_port) +print(mechanical.project_directory) +``` +### PyFluent +to be implemented From fef1618a039d5308b25bdc35a49c37ab769cfa9d Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 20:22:21 +0100 Subject: [PATCH 02/18] fix build --- .github/workflows/ci.yml | 75 +++++++++++++++++++++------------------- pyproject.toml | 48 +++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a72316..3a6676e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,52 +18,58 @@ env: PACKAGE_NAME: "ansys.workbench.core" jobs: + + style: + name: Code style + runs-on: ubuntu-latest + steps: + - name: PyAnsys code style checks + uses: ansys/actions/code-style@v4 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + build: - name: Build package + name: Build + runs-on: ${{ matrix.os }} + needs: [style] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest ] + python-version: ['3.10', '3.11'] + should-release: + - ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags') }} + steps: + - name: Build wheelhouse + uses: ansys/actions/build-wheelhouse@v4 + with: + library-name: ${{ env.PACKAGE_NAME }} + operating-system: ${{ matrix.os }} + python-version: ${{ matrix.python-version }} + + package: + name: Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup Python - uses: actions/setup-python@v4 + - name: Build library source and wheel artifacts + uses: ansys/actions/build-library@v4 with: + library-name: ${{ env.PACKAGE_NAME }} python-version: ${{ env.MAIN_PYTHON_VERSION }} - - name: Install build requirements - run: | - pip install -U pip - pip install build - - name: Build - run: python -m build - - name: Install - run: pip install dist/*.whl - - name: Test import - run: | - mkdir tmp - cd tmp - python -c "import ${{ env.PACKAGE_NAME }}; print('Sucessfully imported ${{ env.PACKAGE_NAME }}')" - python -c "from ${{ env.PACKAGE_NAME }} import __version__; print(__version__)" - - name: Upload packages - uses: actions/upload-artifact@v3 + - name: Upload package + uses: ansys/actions/upload-artifact@v3 with: - name: ansys-workbench-core-packages + name: ${{ env.PACKAGE_NAME }}-packages path: dist/ retention-days: 7 - Release: + release: + name: Release if: github.event_name == 'push' && contains(github.ref, 'refs/tags') - needs: [build] + needs: [package] runs-on: ubuntu-latest steps: - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ env.MAIN_PYTHON_VERSION }} - - - uses: actions/download-artifact@v3 - - - name: Display structure of downloaded files - run: ls -R - - - name: Upload to Ansys Private PyPi + - name: Upload to Ansys private PyPI run: | pip install twine twine upload --skip-existing ./ansys-workbench-core-packages/*.whl @@ -72,7 +78,6 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} TWINE_REPOSITORY_URL: https://pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/upload - - name: Release uses: softprops/action-gh-release@v1 with: diff --git a/pyproject.toml b/pyproject.toml index 991f454..3517176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,47 @@ [build-system] -requires = ["setuptools >= 42.0.0", "wheel", "ansys_tools_protoc_helper"] -build-backend = "setuptools.build_meta:__legacy__" +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +# Check https://flit.pypa.io/en/latest/pyproject_toml.html about this config file + +[project] +name = "ansys-mechanical-core" +version = "0.1.7" +description = "A python wrapper for Ansys Workbench" +readme = "README.md" +requires-python = ">=3.7,<4.0" +license = {file = "LICENSE"} +authors = [ + {name = "Frank Li", email = "frank.li@ansys.com"}, +] +maintainers = [ + {name = "Marshall Hanmer", email = "marshall.hanmer@ansys.com"}, +] + +classifiers = [ + "Development Status :: 4 - Beta", + 'Topic :: Scientific/Engineering', + "Programming Language :: Python", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +dependencies = [ + "ansys_api_workbench==0.1.7", + "ansys-platform-instancemanagement>=1.0.1", + "ansys-pythonnet>=3.1.0rc1", + "ansys-tools-path>=0.3.1", + "grpcio>=1.17.0", + "protobuf>=3.19.6,<3.21.0", + "tqdm>=4.65.0", + "wMI>=1.4.9", +] + +[project.urls] +Documentation = "https://workbench.docs.pyansys.com" +Source = "https://github.com/ansys-internal/pyworkbench" +Homepage = "https://github.com/ansys-internal/pyworkbench" +Tracker = "https://github.com/ansys-internal/pyworkbench/issues" + +[tool.flit.module] +name = "ansys.mechanical.core" From ff0bdd05bbda2c64d8ded514d971d2b64bddac59 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 20:26:51 +0100 Subject: [PATCH 03/18] fix build --- .github/workflows/ci.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6676e..2b620f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,19 +19,9 @@ env: jobs: - style: - name: Code style - runs-on: ubuntu-latest - steps: - - name: PyAnsys code style checks - uses: ansys/actions/code-style@v4 - with: - python-version: ${{ env.MAIN_PYTHON_VERSION }} - build: name: Build runs-on: ${{ matrix.os }} - needs: [style] strategy: fail-fast: false matrix: @@ -57,7 +47,7 @@ jobs: library-name: ${{ env.PACKAGE_NAME }} python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Upload package - uses: ansys/actions/upload-artifact@v3 + uses: actions/upload-artifact@v3 with: name: ${{ env.PACKAGE_NAME }}-packages path: dist/ From ddf1da8abedebc3a31b6e348bd18c3639171cadd Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 20:32:13 +0100 Subject: [PATCH 04/18] fix typo --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3517176..a3fd1bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi" # Check https://flit.pypa.io/en/latest/pyproject_toml.html about this config file [project] -name = "ansys-mechanical-core" +name = "ansys-workbench-core" version = "0.1.7" description = "A python wrapper for Ansys Workbench" readme = "README.md" @@ -44,4 +44,4 @@ Homepage = "https://github.com/ansys-internal/pyworkbench" Tracker = "https://github.com/ansys-internal/pyworkbench/issues" [tool.flit.module] -name = "ansys.mechanical.core" +name = "ansys.workbench.core" From 21d7b1c67d82a0cb574a5f377f28cc82301933ad Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 20:44:29 +0100 Subject: [PATCH 05/18] use api 0.1.6 to test build --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a3fd1bc..470da12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ - "ansys_api_workbench==0.1.7", + "ansys_api_workbench==0.1.6", "ansys-platform-instancemanagement>=1.0.1", "ansys-pythonnet>=3.1.0rc1", "ansys-tools-path>=0.3.1", From 8abf0d264293ebd184ef6184c6bbac5f16ba7747 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Wed, 30 Aug 2023 22:34:32 +0100 Subject: [PATCH 06/18] skip smoke test for now --- .github/workflows/ci.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b620f1..f656274 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,24 +19,6 @@ env: jobs: - build: - name: Build - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest ] - python-version: ['3.10', '3.11'] - should-release: - - ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags') }} - steps: - - name: Build wheelhouse - uses: ansys/actions/build-wheelhouse@v4 - with: - library-name: ${{ env.PACKAGE_NAME }} - operating-system: ${{ matrix.os }} - python-version: ${{ matrix.python-version }} - package: name: Package runs-on: ubuntu-latest From b1c1e444526e26903cc2edfa9ea4360f396aa24f Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 03:59:02 +0100 Subject: [PATCH 07/18] version file --- README.md | 8 ++++---- src/ansys/workbench/core/VERSION | 1 - src/ansys/workbench/core/__init__.py | 12 ++++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) delete mode 100644 src/ansys/workbench/core/VERSION diff --git a/README.md b/README.md index 5cab387..0e5d220 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,10 @@ are published to Ansys Private PyPI when tags are pushed. To release wheels to PyPI, ensure your branch is up-to-date and then push tags. For example, for the version ``v0.1.5``. This version MUST MATCH -the version in `src/ansys/workbench/core/VERSION`. +the version in `pyproject.toml`. -For example, if you intend to release version `0.1.5` to Private PyPI, the VERSION -file should contain '0.1.5'. You will then run: +For example, if you intend to release version `0.1.5` to Private PyPI, the +pyproject.toml file should contain '0.1.5'. You will then run: ```bash git tag v0.1.5 @@ -100,4 +100,4 @@ git push --tags ``` Note that there is a 'v' prepended to the GitHub tag, keeping with best practices. -The 'v' is not required in the `VERSION` file. +The 'v' is not required in the `pyproject.toml` file. diff --git a/src/ansys/workbench/core/VERSION b/src/ansys/workbench/core/VERSION deleted file mode 100644 index c946ee6..0000000 --- a/src/ansys/workbench/core/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.6 diff --git a/src/ansys/workbench/core/__init__.py b/src/ansys/workbench/core/__init__.py index ba9ec4c..c8c2e1d 100644 --- a/src/ansys/workbench/core/__init__.py +++ b/src/ansys/workbench/core/__init__.py @@ -1,8 +1,12 @@ """Autogenerated Python gRPC interface package for ansys-api-workbench.""" -import pathlib - __all__ = ["__version__"] -with open(pathlib.Path(__file__).parent / "VERSION", encoding="utf-8") as f: - __version__ = f.read().strip() +"""Version of ansys-mechanical-core module.""" +try: + import importlib.metadata as importlib_metadata +except ModuleNotFoundError: # pragma: no cover + import importlib_metadata + +# Read from the pyproject.toml +__version__ = importlib_metadata.version("ansys-workbench-core") From 5cfe98bca7000bb94ebcaa8a04ca6d830d1a31f3 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:11:40 +0100 Subject: [PATCH 08/18] correct api-workbench version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 470da12..a3fd1bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ - "ansys_api_workbench==0.1.6", + "ansys_api_workbench==0.1.7", "ansys-platform-instancemanagement>=1.0.1", "ansys-pythonnet>=3.1.0rc1", "ansys-tools-path>=0.3.1", From 065917866088350aebcd7042e483f4c3a069728c Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:24:13 +0100 Subject: [PATCH 09/18] fix release action --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f656274..67bfeb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,16 @@ jobs: release: name: Release - if: github.event_name == 'push' && contains(github.ref, 'refs/tags') + # if: github.event_name == 'push' && contains(github.ref, 'refs/tags') + if: github.event_name == 'push' needs: [package] runs-on: ubuntu-latest steps: - name: Upload to Ansys private PyPI run: | pip install twine - twine upload --skip-existing ./ansys-workbench-core-packages/*.whl - twine upload --skip-existing ./ansys-workbench-core-packages/*.tar.gz + twine upload --skip-existing ./dist/*.whl + twine upload --skip-existing ./dist/*.tar.gz env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} From f9b716fea675eb88d9a0bd175be4e8f39e417df0 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:28:24 +0100 Subject: [PATCH 10/18] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a3fd1bc..16b3581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" [project] name = "ansys-workbench-core" -version = "0.1.7" +version = "0.1.8" description = "A python wrapper for Ansys Workbench" readme = "README.md" requires-python = ">=3.7,<4.0" From 4ed696f13306d84714d79689745cc1f2da15cc45 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:34:17 +0100 Subject: [PATCH 11/18] try release action --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67bfeb9..42ea85e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,6 @@ jobs: release: name: Release - # if: github.event_name == 'push' && contains(github.ref, 'refs/tags') - if: github.event_name == 'push' needs: [package] runs-on: ubuntu-latest steps: From ec6a02b05e0a098b608038b2bc11330de14f6903 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:52:56 +0100 Subject: [PATCH 12/18] try fixing release action --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ea85e..716f70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: - name: Upload to Ansys private PyPI run: | pip install twine - twine upload --skip-existing ./dist/*.whl - twine upload --skip-existing ./dist/*.tar.gz + twine upload --skip-existing ./${{ env.PACKAGE_NAME }}-packages/*.whl + twine upload --skip-existing ./${{ env.PACKAGE_NAME }}-packages/*.tar.gz env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} From 68cf9b125c0bea53bb3e2ead7821d87aedf0cbcf Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 04:55:38 +0100 Subject: [PATCH 13/18] try again --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 716f70f..c539478 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: - name: Upload to Ansys private PyPI run: | pip install twine - twine upload --skip-existing ./${{ env.PACKAGE_NAME }}-packages/*.whl - twine upload --skip-existing ./${{ env.PACKAGE_NAME }}-packages/*.tar.gz + twine upload --skip-existing ./**/*.whl + twine upload --skip-existing ./**/*.tar.gz env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} From 102ee63a1a763b76b49219e3c6245ad5d561f7f1 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 05:03:19 +0100 Subject: [PATCH 14/18] fix release action --- .github/workflows/ci.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c539478..27f76cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Upload package uses: actions/upload-artifact@v3 with: - name: ${{ env.PACKAGE_NAME }}-packages + name: ansys-workbench-core-packages path: dist/ retention-days: 7 @@ -40,16 +40,27 @@ jobs: needs: [package] runs-on: ubuntu-latest steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + + - name: Download package + uses: actions/download-artifact@v3 + + - name: Display structure of downloaded files + run: ls -R + - name: Upload to Ansys private PyPI run: | pip install twine - twine upload --skip-existing ./**/*.whl - twine upload --skip-existing ./**/*.tar.gz + twine upload --skip-existing ./ansys-workbench-core-packages/*.whl + twine upload --skip-existing ./ansys-workbench-core-packages/*.tar.gz env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} TWINE_REPOSITORY_URL: https://pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/upload - - name: Release + - name: Release it uses: softprops/action-gh-release@v1 with: generate_release_notes: true From 304e79f75ee898de68d969363ad4a370b0ca8932 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 05:10:18 +0100 Subject: [PATCH 15/18] ready now? --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27f76cd..b971bd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: release: name: Release + if: github.event_name == 'push' && contains(github.ref, 'refs/tags') needs: [package] runs-on: ubuntu-latest steps: From 8bfb638746f4c69ab97b00eb56d41c0cb7d26a03 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 05:15:44 +0100 Subject: [PATCH 16/18] ... --- .github/workflows/ci.yml | 2 -- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b971bd0..ae2c43d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,5 +67,3 @@ jobs: generate_release_notes: true files: | ./**/*.whl - ./**/*.tar.gz - ./**/*.pdf diff --git a/pyproject.toml b/pyproject.toml index 16b3581..4d916b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" [project] name = "ansys-workbench-core" -version = "0.1.8" +version = "0.1.9" description = "A python wrapper for Ansys Workbench" readme = "README.md" requires-python = ">=3.7,<4.0" From d8444054e7359fd2e2b9dcfa4e11d141f27974ab Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 05:42:10 +0100 Subject: [PATCH 17/18] fixing... --- .github/workflows/ci.yml | 8 ++++++-- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2c43d..c2fcff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: - name: Download package uses: actions/download-artifact@v3 + with: + name: ansys-workbench-core-packages - name: Display structure of downloaded files run: ls -R @@ -61,9 +63,11 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} TWINE_REPOSITORY_URL: https://pkgs.dev.azure.com/pyansys/_packaging/pyansys/pypi/upload - - name: Release it + + - name: Github release uses: softprops/action-gh-release@v1 with: generate_release_notes: true files: | - ./**/*.whl + ./ansys-workbench-core-packages/*.whl + ./ansys-workbench-core-packages/*.tar.gz diff --git a/pyproject.toml b/pyproject.toml index 4d916b0..f076c86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" [project] name = "ansys-workbench-core" -version = "0.1.9" +version = "0.1.10" description = "A python wrapper for Ansys Workbench" readme = "README.md" requires-python = ">=3.7,<4.0" From 1546e85de076bca3deb4b717b812bef5793d3815 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 31 Aug 2023 05:48:36 +0100 Subject: [PATCH 18/18] continue fixing.. --- .github/workflows/ci.yml | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2fcff7..2d9e134 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,8 @@ jobs: - name: Upload to Ansys private PyPI run: | pip install twine - twine upload --skip-existing ./ansys-workbench-core-packages/*.whl - twine upload --skip-existing ./ansys-workbench-core-packages/*.tar.gz + twine upload --skip-existing ./*.whl + twine upload --skip-existing ./*.tar.gz env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYANSYS_PYPI_PRIVATE_PAT }} @@ -69,5 +69,5 @@ jobs: with: generate_release_notes: true files: | - ./ansys-workbench-core-packages/*.whl - ./ansys-workbench-core-packages/*.tar.gz + ./*.whl + ./*.tar.gz diff --git a/pyproject.toml b/pyproject.toml index f076c86..1e250f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" [project] name = "ansys-workbench-core" -version = "0.1.10" +version = "0.1.11" description = "A python wrapper for Ansys Workbench" readme = "README.md" requires-python = ">=3.7,<4.0"