From 746c1d8b3cd5909037d76235f452fff380b6c72a Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Tue, 25 Nov 2025 12:34:18 +0100 Subject: [PATCH 01/16] secure grpc channel --- .github/workflows/ci_cd.yml | 4 +- .github/workflows/nightly.yml | 4 +- .gitignore | 1 + README.rst | 7 +- doc/source/cheat_sheet/cheat_sheet_script.qmd | 5 +- .../getting_started/docker/common_docker.rst | 26 +- doc/source/getting_started/existing/index.rst | 2 +- examples/core/bsdf.py | 12 +- examples/core/lpf-preview.py | 3 +- examples/core/opt-prop.py | 4 +- examples/core/part.py | 3 +- examples/core/prism-example.py | 3 +- examples/core/project.py | 3 +- examples/core/sensor.py | 3 +- examples/core/simulation.py | 3 +- examples/core/source.py | 3 +- examples/kernel/modify-scene.py | 3 +- examples/kernel/object-link.py | 3 +- examples/kernel/scene-job.py | 3 +- examples/workflow/combine-speos.py | 7 +- examples/workflow/open-result.py | 6 +- .../speos/core/generic/general_methods.py | 5 +- src/ansys/speos/core/kernel/body.py | 2 +- src/ansys/speos/core/kernel/client.py | 64 +-- src/ansys/speos/core/kernel/face.py | 2 +- .../speos/core/kernel/grpc/cyberchannel.py | 479 ++++++++++++++++++ .../core/kernel/grpc/transportoptions.py | 171 +++++++ .../speos/core/kernel/intensity_template.py | 4 +- src/ansys/speos/core/kernel/job.py | 2 +- src/ansys/speos/core/kernel/part.py | 2 +- src/ansys/speos/core/kernel/scene.py | 4 +- .../speos/core/kernel/sensor_template.py | 4 +- .../speos/core/kernel/simulation_template.py | 4 +- src/ansys/speos/core/kernel/sop_template.py | 4 +- .../speos/core/kernel/source_template.py | 2 +- src/ansys/speos/core/kernel/spectrum.py | 4 +- src/ansys/speos/core/kernel/vop_template.py | 4 +- src/ansys/speos/core/launcher.py | 5 +- src/ansys/speos/core/speos.py | 18 +- tests/conftest.py | 39 +- tests/core/test_launcher.py | 25 +- tests/core/test_simulation.py | 4 +- tests/helper.py | 10 +- tests/kernel/test_client.py | 13 +- 44 files changed, 817 insertions(+), 162 deletions(-) create mode 100644 src/ansys/speos/core/kernel/grpc/cyberchannel.py create mode 100644 src/ansys/speos/core/kernel/grpc/transportoptions.py diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 11af18b2d..390f91c80 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -102,7 +102,7 @@ jobs: env: ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} run: | - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure --host 0.0.0.0 - name: "Run Ansys documentation building action" uses: ansys/actions/doc-build@c2fa7c93f6883114e0e643599431b33d29f0b13f # v10.1.4 with: @@ -165,7 +165,7 @@ jobs: ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} shell: bash run: | - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev -m 25000000 --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure -m 25000000 --host 0.0.0.0 - name: Run pytest shell: bash diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d01e00c0d..47024190f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -60,9 +60,9 @@ jobs: speos_version=${{ matrix.speos-version }} if [[ "$speos_version" == "251" ]]; then - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version --transport_insecure -m 25000000 else - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version --transport_insecure -m 25000000 --host 0.0.0.0 fi - name: Run pytest for Speos ${{ matrix.speos-version }} diff --git a/.gitignore b/.gitignore index ab653ee0c..772206dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ _autosummary # Testing .coverage +.cov/ .tox/ *,cover test-output.xml diff --git a/README.rst b/README.rst index 4b30914a2..a04ae23b3 100644 --- a/README.rst +++ b/README.rst @@ -73,7 +73,7 @@ All sources are located in ``_ folder. from ansys.speos.core.speos import Speos - speos = Speos(host="localhost", port=50098) + speos = Speos() Documentation and issues ------------------------ @@ -143,7 +143,7 @@ Then, to launch SpeosRPC server with product version 2025.1, you can run: cat GH_TOKEN.txt | docker login ghcr.io -u "$GH_USERNAME" --password-stdin docker pull ghcr.io/ansys/speos-rpc:251 - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:251 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:251 --transport_insecure .. note:: @@ -181,6 +181,9 @@ Launch unit tests pip install .[tests] pytest -vx +`-e` option allows to install the package in "editable" mode. +Can be very useful during new development and debugging. + Use jupyter notebook ~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/source/cheat_sheet/cheat_sheet_script.qmd b/doc/source/cheat_sheet/cheat_sheet_script.qmd index aec202615..13526185a 100644 --- a/doc/source/cheat_sheet/cheat_sheet_script.qmd +++ b/doc/source/cheat_sheet/cheat_sheet_script.qmd @@ -45,13 +45,12 @@ speos_server = launch_local_speos_rpc_server() # returns a connected speos instance ``` -Connect to an existing instance: +Connect to the existing server instance: ```{python} #| eval: false from ansys.speos.core import Speos -host_ip = '127.0.0.1' # localhost here -speos_server = Speos(host=host_ip, port=50098) +speos_server = Speos() ``` # Speos Solver files diff --git a/doc/source/getting_started/docker/common_docker.rst b/doc/source/getting_started/docker/common_docker.rst index e86fec402..da7c8cf51 100644 --- a/doc/source/getting_started/docker/common_docker.rst +++ b/doc/source/getting_started/docker/common_docker.rst @@ -42,21 +42,21 @@ To use another product version, please modify the image label from `251` to the .. code-block:: bash export LICENSE_SERVER="1055@XXX.XXX.XXX.XXX" - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:251 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:252 --transport_insecure .. tab-item:: Powershell .. code-block:: pwsh $env:LICENSE_SERVER="1055@XXX.XXX.XXX.XXX" - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$env:LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:251 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=$env:LICENSE_SERVER --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:252 --transport_insecure .. tab-item:: Windows CMD .. code-block:: bash set LICENSE_SERVER="1055@XXX.XXX.XXX.XXX" - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=%LICENSE_SERVER% --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:251 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e ANSYSLMD_LICENSE_FILE=%LICENSE_SERVER% --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:252 --transport_insecure Connect to the Speos service ---------------------------- @@ -65,22 +65,6 @@ After the Speos service is launched, connect to it with these commands: .. code:: python - from ansys.speos.core import Speos + from ansys.speos.core import Speos, default_docker_channel - speos = Speos() - -By default, the ``Speos`` instance connects to ``127.0.0.1`` (``"localhost"``) on -port ``50098``. - -You can change this by modifying the ``host`` and ``port`` -parameters of the ``Speos`` object, but note that you must also modify -your ``docker run`` command by changing the ``-50098`` argument. - -The following tabs show the commands that set the environment variables and ``Speos`` -function. - -.. code:: python - - from ansys.speos.core import Speos - - speos = Speos(host="127.0.0.1", port=50098) \ No newline at end of file + speos = Speos(channel = default_docker_channel()) diff --git a/doc/source/getting_started/existing/index.rst b/doc/source/getting_started/existing/index.rst index 72481f708..f207811f2 100644 --- a/doc/source/getting_started/existing/index.rst +++ b/doc/source/getting_started/existing/index.rst @@ -16,7 +16,7 @@ From Python, establish a connection to the existing Speos service by creating a from ansys.speos.core import Speos - speos = Speos(host="127.0.0.1", port=50098) + speos = Speos() If no error messages are received, your connection is established successfully. diff --git a/examples/core/bsdf.py b/examples/core/bsdf.py index f92ac6e9d..f80f96200 100644 --- a/examples/core/bsdf.py +++ b/examples/core/bsdf.py @@ -29,15 +29,11 @@ from ansys.speos.core import Speos from ansys.speos.core.bsdf import AnisotropicBSDF, BxdfDatapoint from ansys.speos.core.launcher import launch_local_speos_rpc_server -from ansys.speos.core.speos import SpeosClient - +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - # ### Define constants # Constants help ensure consistency and avoid repetition throughout the example. - -HOSTNAME = "localhost" -GRPC_PORT = 50098 # Be sure the Speos GRPC Server has been started on this port. USE_DOCKER = True # Set to False if you're running this example locally as a Notebook. # ### Define helper functions @@ -163,9 +159,9 @@ def create_spectrum(value, w_start=380.0, w_end=780.0, w_step=10): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) -else: - speos = launch_local_speos_rpc_server(port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) +else: + speos = launch_local_speos_rpc_server() # ### Create a BXDFDatapoint # to create a bsdf we need the bsdf for multiple incident angles. diff --git a/examples/core/lpf-preview.py b/examples/core/lpf-preview.py index d77900ac3..6dfe902d1 100644 --- a/examples/core/lpf-preview.py +++ b/examples/core/lpf-preview.py @@ -11,6 +11,7 @@ from ansys.speos.core import LightPathFinder, Project, Speos, launcher from ansys.speos.core.simulation import SimulationInteractive +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -45,7 +46,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/opt-prop.py b/examples/core/opt-prop.py index f705dc889..a2f3f9d43 100644 --- a/examples/core/opt-prop.py +++ b/examples/core/opt-prop.py @@ -17,7 +17,7 @@ from pathlib import Path from ansys.speos.core import Project, Speos, launcher - +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - # ### Define constants @@ -76,7 +76,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/part.py b/examples/core/part.py index 29de45d82..d38999948 100644 --- a/examples/core/part.py +++ b/examples/core/part.py @@ -17,6 +17,7 @@ from ansys.speos.core import Body, Face, Part, Project, Speos from ansys.speos.core.launcher import launch_local_speos_rpc_server +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -50,7 +51,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/prism-example.py b/examples/core/prism-example.py index 081b03420..ec4f05457 100644 --- a/examples/core/prism-example.py +++ b/examples/core/prism-example.py @@ -16,6 +16,7 @@ from ansys.speos.core.launcher import launch_local_speos_rpc_server from ansys.speos.core.sensor import Sensor3DIrradiance, SensorIrradiance from ansys.speos.core.simulation import SimulationDirect +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -50,7 +51,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/project.py b/examples/core/project.py index 4575b887c..2348c5c22 100644 --- a/examples/core/project.py +++ b/examples/core/project.py @@ -23,6 +23,7 @@ from ansys.speos.core.sensor import SensorIrradiance from ansys.speos.core.simulation import SimulationDirect from ansys.speos.core.source import SourceLuminaire, SourceSurface +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -57,7 +58,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/sensor.py b/examples/core/sensor.py index 910d22efd..1c95feae3 100644 --- a/examples/core/sensor.py +++ b/examples/core/sensor.py @@ -17,6 +17,7 @@ SensorIrradiance, SensorRadiance, ) +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # ### Define constants # @@ -73,7 +74,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/simulation.py b/examples/core/simulation.py index e95950587..fb28fba9b 100644 --- a/examples/core/simulation.py +++ b/examples/core/simulation.py @@ -15,6 +15,7 @@ from ansys.speos.core import Project, Speos, launcher from ansys.speos.core.simulation import SimulationInteractive, SimulationInverse +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -50,7 +51,7 @@ # be used to start a local instance of the service.. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/source.py b/examples/core/source.py index e30e77aca..3c233ad62 100644 --- a/examples/core/source.py +++ b/examples/core/source.py @@ -18,6 +18,7 @@ SourceRayFile, SourceSurface, ) +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -75,7 +76,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/modify-scene.py b/examples/kernel/modify-scene.py index 49ecf7fa9..7d8d5192a 100644 --- a/examples/kernel/modify-scene.py +++ b/examples/kernel/modify-scene.py @@ -36,6 +36,7 @@ from ansys.speos.core import Speos, launcher from ansys.speos.core.kernel.scene import ProtoScene from ansys.speos.core.kernel.sensor_template import ProtoSensorTemplate +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -67,7 +68,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/object-link.py b/examples/kernel/object-link.py index aff48668a..b2ef2abc5 100644 --- a/examples/kernel/object-link.py +++ b/examples/kernel/object-link.py @@ -14,6 +14,7 @@ # + from ansys.speos.core.kernel.sop_template import ProtoSOPTemplate from ansys.speos.core.speos import Speos +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - # ### Define constants @@ -30,7 +31,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/scene-job.py b/examples/kernel/scene-job.py index d59eb3009..8d27e1a42 100644 --- a/examples/kernel/scene-job.py +++ b/examples/kernel/scene-job.py @@ -14,6 +14,7 @@ from ansys.speos.core import launcher from ansys.speos.core.kernel.job import ProtoJob from ansys.speos.core.speos import Speos +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # ### Define constants # Constants help ensure consistency and avoid repetition throughout the example. @@ -43,7 +44,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(host=HOSTNAME, port=GRPC_PORT) + speos = Speos(channel = default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/workflow/combine-speos.py b/examples/workflow/combine-speos.py index ad47b70a5..a4ab757aa 100644 --- a/examples/workflow/combine-speos.py +++ b/examples/workflow/combine-speos.py @@ -14,6 +14,7 @@ from ansys.speos.core.simulation import SimulationInverse from ansys.speos.core.source import SourceLuminaire from ansys.speos.core.workflow.combine_speos import SpeosFileInstance, combine_speos +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -65,8 +66,10 @@ assets_data_path = Path("/path/to/your/download/assets/directory") # ## Create connection with speos rpc server - -speos = Speos(host=HOSTNAME, port=GRPC_PORT) +if USE_DOCKER: + speos = Speos(channel = default_docker_channel()) +else: + speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) # ## Combine several speos files into one project # diff --git a/examples/workflow/open-result.py b/examples/workflow/open-result.py index 63eaa88f1..8afc11901 100644 --- a/examples/workflow/open-result.py +++ b/examples/workflow/open-result.py @@ -12,6 +12,7 @@ from ansys.speos.core import Project, Speos from ansys.speos.core.simulation import SimulationDirect +from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -46,7 +47,10 @@ # client are the same # machine. -speos = Speos(host=HOSTNAME, port=GRPC_PORT) +if USE_DOCKER: + speos = Speos(channel = default_docker_channel()) +else: + speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) # ### Create project from a Speos file # diff --git a/src/ansys/speos/core/generic/general_methods.py b/src/ansys/speos/core/generic/general_methods.py index 0e53e6a95..5bc49fd55 100644 --- a/src/ansys/speos/core/generic/general_methods.py +++ b/src/ansys/speos/core/generic/general_methods.py @@ -178,11 +178,8 @@ def error_no_install(install_path: Union[Path, str], version: Union[int, str]): version : Union[int, str] Version """ - install_loc_msg = "" - if install_path: - install_loc_msg = f"at {Path(install_path).parent}" raise FileNotFoundError( - f"Ansys Speos RPC server installation not found{install_loc_msg}. " + f"Ansys Speos RPC server installation not found at {install_path}. " f"Please define AWP_ROOT{version} environment variable" ) diff --git a/src/ansys/speos/core/kernel/body.py b/src/ansys/speos/core/kernel/body.py index ffc52168d..c856dbce5 100644 --- a/src/ansys/speos/core/kernel/body.py +++ b/src/ansys/speos/core/kernel/body.py @@ -95,7 +95,7 @@ class BodyStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> body_db = speos.client.bodies() """ diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index b9c39df9c..24b947ef1 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -40,6 +40,8 @@ DEFAULT_VERSION, MAX_CLIENT_MESSAGE_SIZE, ) +from ansys.speos.core.kernel.grpc.transportoptions import TransportOptions, TransportMode, UDSOptions, InsecureOptions, WNUAOptions +from ansys.speos.core.kernel.grpc.cyberchannel import create_channel from ansys.speos.core.generic.general_methods import retrieve_speos_install_dir from ansys.speos.core.kernel.body import BodyLink, BodyStub from ansys.speos.core.kernel.face import FaceLink, FaceStub @@ -83,7 +85,7 @@ def wait_until_healthy(channel: grpc.Channel, timeout: float): Parameters ---------- - channel : ~grpc.Channel + channel : grpc.Channel Channel to wait until established and healthy. timeout : float Timeout in seconds. One attempt will be made each 100 milliseconds @@ -107,6 +109,33 @@ def wait_until_healthy(channel: grpc.Channel, timeout: float): f"Channel health check to target '{target_str}' timed out after {timeout} seconds." ) +def default_docker_channel( + host: Optional[str] = DEFAULT_HOST, + port: Union[str, int] = DEFAULT_PORT, + message_size: int = MAX_CLIENT_MESSAGE_SIZE + ) -> grpc.Channel: + return TransportOptions( + mode=TransportMode.INSECURE, + options=InsecureOptions(host=host, port=port, allow_remote_host=True) + ).create_channel(grpc_options=[("grpc.max_receive_message_length", message_size)]) + +def default_local_channel( + port: Union[str, int] = DEFAULT_PORT, + message_size: int = MAX_CLIENT_MESSAGE_SIZE + ) -> grpc.Channel: + """Create default transport options, WNUA on Windows, UDS on Linux""" + # Otherwise use default based on OS + if os.name == "nt": + transport = TransportOptions( + mode=TransportMode.WNUA, + options=WNUAOptions(host=DEFAULT_HOST, port=port) + ) + else: + transport = TransportOptions( + mode=TransportMode.UDS, + options=UDSOptions(uds_dir=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer") + ) + return transport.create_channel(grpc_options=[("grpc.max_receive_message_length", message_size)]) class SpeosClient: """ @@ -114,18 +143,9 @@ class SpeosClient: Parameters ---------- - host : str, optional - Host where the server is running. - By default, ``DEFAULT_HOST``. - port : Union[str, int], optional - Port number where the server is running. - By default, ``DEFAULT_PORT``. - channel : ~grpc.Channel, optional + channel : grpc.Channel, optional gRPC channel for server communication. By default, ``None``. - message_size: int - Maximum Message size of a newly generated channel - By default, ``MAX_CLIENT_MESSAGE_SIZE``. remote_instance : ansys.platform.instancemanagement.Instance The corresponding remote instance when the Speos Service is launched through PyPIM. This instance will be deleted when calling @@ -144,11 +164,8 @@ class SpeosClient: def __init__( self, - host: Optional[str] = DEFAULT_HOST, - port: Union[str, int] = DEFAULT_PORT, version: str = DEFAULT_VERSION, channel: Optional[grpc.Channel] = None, - message_size: int = MAX_CLIENT_MESSAGE_SIZE, remote_instance: Optional["Instance"] = None, timeout: Optional[int] = 60, logging_level: Optional[int] = logging.INFO, @@ -171,27 +188,16 @@ def __init__( else: self._version = version if channel: - # Used for PyPIM when directly providing a channel + # grpc channel is provided by caller, used by PyPIM or Docker server self._channel = channel - self._target = str(channel) else: - if host == "0.0.0.0": # nosec - warnings.warn( - "The service is exposed on all network interfaces. This is a security risk.", - stacklevel=2, - ) - - self._host = host - self._port = port - self._target = f"{host}:{port}" - self._channel = grpc.insecure_channel( - self._target, - options=[("grpc.max_receive_message_length", message_size)], - ) + self._channel = default_local_channel() + # do not finish initialization until channel is healthy wait_until_healthy(self._channel, timeout) # once connection with the client is established, create a logger + self._target = self._channel._channel.target().decode() self._log = LOGGER.add_instance_logger( name=self._target, client_instance=self, level=logging_level ) diff --git a/src/ansys/speos/core/kernel/face.py b/src/ansys/speos/core/kernel/face.py index 048236156..2f02044dd 100644 --- a/src/ansys/speos/core/kernel/face.py +++ b/src/ansys/speos/core/kernel/face.py @@ -97,7 +97,7 @@ class FaceStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> face_db = speos.client.faces() """ diff --git a/src/ansys/speos/core/kernel/grpc/cyberchannel.py b/src/ansys/speos/core/kernel/grpc/cyberchannel.py new file mode 100644 index 000000000..94ee8647a --- /dev/null +++ b/src/ansys/speos/core/kernel/grpc/cyberchannel.py @@ -0,0 +1,479 @@ +"""Module to create gRPC channels with different transport modes. + +This module provides functions to create gRPC channels based on the specified +transport mode, including insecure, Unix Domain Sockets (UDS), Windows Named User +Authentication (WNUA), and Mutual TLS (mTLS). + +Example +------- + channel = create_channel( + host="localhost", + port=50051, + transport_mode="mtls", + certs_dir="path/to/certs", + grpc_options=[('grpc.max_receive_message_length', 50 * 1024 * 1024)], + ) + stub = hello_pb2_grpc.GreeterStub(channel) + +""" + +# Only the create_channel function is exposed for external use +__all__ = ["create_channel", "verify_transport_mode", "verify_uds_socket"] + +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import cast +from warnings import warn +from typing import TypeGuard + +import grpc + +_IS_WINDOWS = os.name == "nt" +LOOPBACK_HOSTS = ("localhost", "127.0.0.1") + +logger = logging.getLogger(__name__) + +@dataclass +class CertificateFiles: + cert_file: str | Path | None = None + key_file: str | Path | None = None + ca_file: str | Path | None = None + +def create_channel( + transport_mode: str, + host: str | None = None, + port: int | str | None = None, + uds_service: str | None = None, + uds_dir: str | Path | None = None, + uds_id: str | None = None, + certs_dir: str | Path | None = None, + cert_files: CertificateFiles | None = None, + grpc_options: list[tuple[str, object]] | None = None, +) -> grpc.Channel: + """Create a gRPC channel based on the transport mode. + + Parameters + ---------- + transport_mode : str + Transport mode selected by the user. + Options are: "insecure", "uds", "wnua", "mtls" + host : str | None + Hostname or IP address of the server. + By default `None` - however, if not using UDS transport mode, + it will be requested. + port : int | str | None + Port in which the server is running. + By default `None` - however, if not using UDS transport mode, + it will be requested. + uds_service : str | None + Optional service name for the UDS socket. + By default `None` - however, if UDS is selected, it will + be requested. + uds_dir : str | Path | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use ".sock". + Otherwise, the socket filename will be "-.sock". + certs_dir : str | Path | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. + cert_files: CertificateFiles | None = None + Path to the client certificate file, client key file, and issuing certificate authority. + By default `None`. + If all three file paths are not all provided, use the certs_dir parameter. + grpc_options: list[tuple[str, object]] | None + gRPC channel options to pass when creating the channel. + Each option is a tuple of the form ("option_name", value). + By default `None` and thus no extra options are added. + + Returns + ------- + grpc.Channel + The created gRPC channel + + """ + def check_host_port(transport_mode, host, port) -> tuple[str, str, str]: + if host is None: + raise ValueError(f"When using {transport_mode.lower()} transport mode, 'host' must be provided.") + if port is None: + raise ValueError(f"When using {transport_mode.lower()} transport mode, 'port' must be provided.") + return transport_mode, host, port + + match transport_mode.lower(): + case "insecure": + transport_mode, host, port = check_host_port(transport_mode, host, port) + return create_insecure_channel(host, port, grpc_options) + case "uds": + return create_uds_channel(uds_service, uds_dir, uds_id, grpc_options) + case "wnua": + transport_mode, host, port = check_host_port(transport_mode, host, port) + return create_wnua_channel(host, port, grpc_options) + case "mtls": + transport_mode, host, port = check_host_port(transport_mode, host, port) + return create_mtls_channel(host, port, certs_dir, cert_files, grpc_options) + case _: + raise ValueError( + f"Unknown transport mode: {transport_mode}. " + "Valid options are: 'insecure', 'uds', 'wnua', 'mtls'." + ) + + +##################################### TRANSPORT MODE CHANNELS ##################################### + + +def create_insecure_channel( + host: str, port: int | str, grpc_options: list[tuple[str, object]] | None = None +) -> grpc.Channel: + """Create an insecure gRPC channel without TLS. + + Parameters + ---------- + host : str + Hostname or IP address of the server. + port : int | str + Port in which the server is running. + grpc_options: list[tuple[str, object]] | None + gRPC channel options to pass when creating the channel. + Each option is a tuple of the form ("option_name", value). + By default `None` and thus no extra options are added. + + Returns + ------- + grpc.Channel + The created gRPC channel + + """ + target = f"{host}:{port}" + warn( + f"Starting gRPC client without TLS on {target}. This is INSECURE. " + "Consider using a secure connection." + ) + logger.info(f"Connecting using INSECURE -> {target}") + return grpc.insecure_channel(target, options=grpc_options) + + +def create_uds_channel( + uds_service: str | None, + uds_dir: str | Path | None = None, + uds_id: str | None = None, + grpc_options: list[tuple[str, object]] | None = None, +) -> grpc.Channel: + """Create a gRPC channel using Unix Domain Sockets (UDS). + + Parameters + ---------- + uds_service : str + Service name for the UDS socket. + uds_dir : str | Path | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Optional ID to use for the UDS socket filename. + By default `None` and thus it will use ".sock". + Otherwise, the socket filename will be "-.sock". + grpc_options: list[tuple[str, object]] | None + gRPC channel options to pass when creating the channel. + Each option is a tuple of the form ("option_name", value). + By default `None` and thus only the default authority option is added. + + Returns + ------- + grpc.Channel + The created gRPC channel + + """ + if not is_uds_supported(): + raise RuntimeError( + "Unix Domain Sockets are not supported on this platform or gRPC version." + ) + + if not uds_service: + raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") + + # Determine UDS folder + uds_folder = determine_uds_folder(uds_dir) + + # Make sure the folder exists + uds_folder.mkdir(parents=True, exist_ok=True) + + # Generate socket filename with optional ID + socket_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" + target = f"unix:{uds_folder / socket_filename}" + # Set default authority to "localhost" for UDS connection + # This is needed to avoid issues with some gRPC implementations, + # see https://github.com/grpc/grpc/issues/34305 + options: list[tuple[str, object]] = [ + ("grpc.default_authority", "localhost"), + ] + if grpc_options: + options.extend(grpc_options) + logger.info(f"Connecting using UDS -> {target}") + return grpc.insecure_channel(target, options=options) + + +def create_wnua_channel( + host: str, + port: int | str, + grpc_options: list[tuple[str, object]] | None = None, +) -> grpc.Channel: + """Create a gRPC channel using Windows Named User Authentication (WNUA). + + Parameters + ---------- + host : str + Hostname or IP address of the server. + port : int | str + Port in which the server is running. + grpc_options: list[tuple[str, object]] | None + gRPC channel options to pass when creating the channel. + Each option is a tuple of the form ("option_name", value). + By default `None` and thus only the default authority option is added. + + Returns + ------- + grpc.Channel + The created gRPC channel + + """ + if not _IS_WINDOWS: + raise ValueError("Windows Named User Authentication (WNUA) is only supported on Windows.") + if host not in LOOPBACK_HOSTS: + raise ValueError("Remote host connections are not supported with WNUA.") + + target = f"{host}:{port}" + # Set default authority to "localhost" for WNUA connection + # This is needed to avoid issues with some gRPC implementations, + # see https://github.com/grpc/grpc/issues/34305 + options: list[tuple[str, object]] = [ + ("grpc.default_authority", "localhost"), + ] + if grpc_options: + options.extend(grpc_options) + logger.info(f"Connecting using WNUA -> {target}") + return grpc.insecure_channel(target, options=options) + + +def create_mtls_channel( + host: str, + port: int | str, + certs_dir: str | Path | None = None, + cert_files: CertificateFiles | None = None, + grpc_options: list[tuple[str, object]] | None = None, +) -> grpc.Channel: + """Create a gRPC channel using Mutual TLS (mTLS). + + Parameters + ---------- + host : str + Hostname or IP address of the server. + port : int | str + Port in which the server is running. + certs_dir : str | Path | None + Directory to use for TLS certificates. + By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. + If not found, it will use the "certs" folder assuming it is in the current working + directory. + cert_files: CertificateFiles | None + Path to the client certificate file, client key file, and issuing certificate authority. + By default `None`. + If all three file paths are not all provided, use the certs_dir parameter. + grpc_options: list[tuple[str, object]] | None + gRPC channel options to pass when creating the channel. + Each option is a tuple of the form ("option_name", value). + By default `None` and thus no extra options are added. + + Returns + ------- + grpc.Channel + The created gRPC channel + + """ + certs_folder = None + if cert_files is not None and cert_files.cert_file is not None and cert_files.key_file is not None and cert_files.ca_file is not None: + cert_file = Path(cert_files.cert_file).resolve() + key_file = Path(cert_files.key_file).resolve() + ca_file = Path(cert_files.ca_file).resolve() + else: + # Determine certificates folder + if certs_dir: + certs_folder = Path(certs_dir) + elif os.environ.get("ANSYS_GRPC_CERTIFICATES"): + certs_folder = Path(cast(str, os.environ.get("ANSYS_GRPC_CERTIFICATES"))) + else: + certs_folder = Path("certs") + ca_file = certs_folder / "ca.crt" + cert_file = certs_folder / "client.crt" + key_file = certs_folder / "client.key" + + # Load certificates + try: + with (ca_file).open("rb") as f: + trusted_certs = f.read() + with (cert_file).open("rb") as f: + client_cert = f.read() + with (key_file).open("rb") as f: + client_key = f.read() + except FileNotFoundError as e: + error_message = f"Certificate file not found: {e.filename}. " + if certs_folder is not None: + error_message += f"Ensure that the certificates are present in the '{certs_folder}' folder or " \ + "set the 'ANSYS_GRPC_CERTIFICATES' environment variable." + raise FileNotFoundError(error_message) from e + + # Create SSL credentials + credentials = grpc.ssl_channel_credentials( + root_certificates=trusted_certs, private_key=client_key, certificate_chain=client_cert + ) + + target = f"{host}:{port}" + logger.info(f"Connecting using mTLS -> {target}") + return grpc.secure_channel(target, credentials, options=grpc_options) + + +######################################## HELPER FUNCTIONS ######################################## + + +def version_tuple(version_str: str) -> tuple[int, ...]: + """Convert a version string into a tuple of integers for comparison. + + Parameters + ---------- + version_str : str + The version string to convert. + + Returns + ------- + tuple[int, ...] + A tuple of integers representing the version. + + """ + return tuple(int(x) for x in version_str.split(".")) + + +def check_grpc_version(): + """Check if the installed gRPC version meets the minimum requirement. + + Returns + ------- + bool + True if the gRPC version is sufficient, False otherwise. + + """ + min_version = "1.63.0" + current_version = grpc.__version__ + + try: + return version_tuple(current_version) >= version_tuple(min_version) + except ValueError: + logger.warning("Unable to parse gRPC version.") + return False + + +def is_uds_supported(): + """Check if Unix Domain Sockets (UDS) are supported on the current platform. + + Returns + ------- + bool + True if UDS is supported, False otherwise. + + """ + is_grpc_version_ok = check_grpc_version() + return is_grpc_version_ok if _IS_WINDOWS else True + + +def determine_uds_folder(uds_dir: str | Path | None = None) -> Path: + """Determine the directory to use for Unix Domain Sockets (UDS). + + Parameters + ---------- + uds_dir : str | Path | None + Directory to use for Unix Domain Sockets (UDS) transport mode. + By default `None` and thus it will use the "~/.conn" folder. + + Returns + ------- + Path + The path to the UDS directory. + + """ + # If no directory is provided, use default based on OS + if uds_dir: + return uds_dir if isinstance(uds_dir, Path) else Path(uds_dir) + else: + if _IS_WINDOWS: + return Path(os.environ["USERPROFILE"]) / ".conn" + else: + # Linux/POSIX + return Path(os.environ["HOME"], ".conn") + + +def verify_transport_mode(transport_mode: str, mode: str | None = None) -> None: + """Verify that the provided transport mode is valid. + + Parameters + ---------- + transport_mode : str + The transport mode to verify. + mode : str | None + Can be one of "all", "local" or "remote" to restrict the valid transport modes. + By default `None` and thus all transport modes are accepted. + + Raises + ------ + ValueError + If the transport mode is not one of the accepted values. + + """ + if mode == "local": + valid_modes = {"insecure", "uds", "wnua"} + elif mode == "remote": + valid_modes = {"insecure", "mtls"} + elif mode == "all" or mode is None: + valid_modes = {"insecure", "uds", "wnua", "mtls"} + else: + raise ValueError(f"Invalid mode: {mode}. Valid options are: 'all', 'local', 'remote'.") + + if transport_mode.lower() not in valid_modes: + raise ValueError( + f"Invalid transport mode: {transport_mode}. " + f"Valid options are: {', '.join(valid_modes)}." + ) + + +def verify_uds_socket( + uds_service: str, uds_dir: Path | None = None, uds_id: str | None = None +) -> bool: + """Verify that the UDS socket file has been created. + + Parameters + ---------- + uds_service : str + Service name for the UDS socket. + uds_dir : Path | None + Directory where the UDS socket file is expected to be (optional). + By default `None` and thus it will use the "~/.conn" folder. + uds_id : str | None + Unique identifier for the UDS socket (optional). + By default `None` and thus it will use ".sock". + Otherwise, the socket filename will be "-.sock". + + Returns + ------- + bool + True if the UDS socket file exists, False otherwise. + """ + # Generate socket filename with optional ID + uds_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" + + # Full path to the UDS socket file + uds_socket_path = determine_uds_folder(uds_dir) / uds_filename + + # Check if the UDS socket file exists + return uds_socket_path.exists() diff --git a/src/ansys/speos/core/kernel/grpc/transportoptions.py b/src/ansys/speos/core/kernel/grpc/transportoptions.py new file mode 100644 index 000000000..03543e771 --- /dev/null +++ b/src/ansys/speos/core/kernel/grpc/transportoptions.py @@ -0,0 +1,171 @@ +# Copyright (C) 2021 - 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. + +"""Define supported transport options for the FileTransfer Tool client. + +This module provides classes and enumerations to configure and manage +different transport modes (UDS, mTLS, Insecure) for the FileTransfer Tool. +""" + +from dataclasses import dataclass, field +import os +from pathlib import Path +import enum +from .cyberchannel import create_channel + +class TransportMode(enum.Enum): + """Enumeration of transport modes supported by the FileTransfer Tool.""" + + UDS = "uds" + MTLS = "mtls" + INSECURE = "insecure" + WNUA = "wnua" + + +@dataclass(kw_only=True) +class UDSOptions: + """Options for UDS transport mode.""" + + uds_dir: str | Path | None = None + uds_id: str | None = None + + def _to_cyberchannel_kwargs(self): + return { + "uds_dir": self.uds_dir, + "uds_id": self.uds_id, + "uds_service": "ansys_tools_filetransfer", + } + + +@dataclass(kw_only=True) +class MTLSOptions: + """Options for mTLS transport mode.""" + + certs_dir: str | Path | None = None + host: str = "localhost" + port: int + allow_remote_host: bool = False + + def _to_cyberchannel_kwargs(self): + if not self.allow_remote_host: + if self.host not in ("localhost", "127.0.0.1"): + raise ValueError( + f"Remote host '{self.host}' is not allowed when " + "'allow_remote_host' is set to False." + ) + return { + "certs_dir": self.certs_dir, + "host": self.host, + "port": self.port, + } + + +@dataclass(kw_only=True) +class InsecureOptions: + """Options for insecure transport mode.""" + + host: str = "localhost" + port: int + allow_remote_host: bool = False + + def _to_cyberchannel_kwargs(self): + if not self.allow_remote_host: + if self.host not in ("localhost", "127.0.0.1"): + raise ValueError( + f"Remote host '{self.host}' is not allowed when " + "'allow_remote_host' is set to False." + ) + return { + "host": self.host, + "port": self.port, + } + +@dataclass(kw_only=True) +class WNUAOptions: + """Options for Windows Named User Authentication transport mode.""" + + host: str = "localhost" + port: int + + def _to_cyberchannel_kwargs(self): + return { + "host": self.host, + "port": self.port, + } + +@dataclass(kw_only=True) +class TransportOptions: + """Transport options for the FileTransfer Tool client.""" + + mode: TransportMode + options: UDSOptions | MTLSOptions | InsecureOptions | WNUAOptions + + def __init__( + self, + mode: TransportMode | str = "uds", + options: UDSOptions | MTLSOptions | InsecureOptions | WNUAOptions | None = None, + ): + if isinstance(mode, str): + mode = TransportMode(mode) + if options is None: + if mode != TransportMode.UDS: + raise RuntimeError("TransportOptions must be provided for modes other than UDS.") + # The default cannot be set in the constructor signature since '_get_uds_dir_default' may raise. + options = UDSOptions() + + if mode == TransportMode.UDS: + if not isinstance(options, UDSOptions): + raise TypeError("For UDS transport mode, options must be of type UDSOptions.") + elif mode == TransportMode.MTLS: + if not isinstance(options, MTLSOptions): + raise TypeError("For mTLS transport mode, options must be of type MTLSOptions.") + elif mode == TransportMode.INSECURE: + if not isinstance(options, InsecureOptions): + raise TypeError( + "For Insecure transport mode, options must be of type InsecureOptions." + ) + elif mode == TransportMode.WNUA: + if not isinstance(options, WNUAOptions): + raise TypeError( + "For WNUA transport mode, options must be of type WNUAOptions." + ) + else: + raise ValueError(f"Unsupported transport mode: {mode}") + + self.mode = mode + self.options = options + + def _to_cyberchannel_kwargs(self): + """Convert transport options to cyberchannel kwargs.""" + return { + "transport_mode": self.mode.value, + **self.options._to_cyberchannel_kwargs(), + } + + def create_channel(self, grpc_options): + """ + Create a gRPC channel based on the transport options. + """ + return create_channel( + **self._to_cyberchannel_kwargs(), + grpc_options=grpc_options + ) diff --git a/src/ansys/speos/core/kernel/intensity_template.py b/src/ansys/speos/core/kernel/intensity_template.py index 04121e148..09dd1b2c7 100644 --- a/src/ansys/speos/core/kernel/intensity_template.py +++ b/src/ansys/speos/core/kernel/intensity_template.py @@ -56,7 +56,7 @@ class IntensityTemplateLink(CrudItem): >>> from ansys.speos.core.kernel.intensity_template import ( ... ProtoIntensityTemplate, ... ) - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> int_t_db = speos.client.intensity_templates() >>> int_t_message = ProtoIntensityTemplate(name="Cos_3_170") >>> int_t_message.cos.N = 3.0 @@ -127,7 +127,7 @@ class IntensityTemplateStub(CrudStub): intensity_templates() method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> int_t_db = speos.client.intensity_templates() """ diff --git a/src/ansys/speos/core/kernel/job.py b/src/ansys/speos/core/kernel/job.py index f2a5ca546..4f18a938f 100644 --- a/src/ansys/speos/core/kernel/job.py +++ b/src/ansys/speos/core/kernel/job.py @@ -162,7 +162,7 @@ class JobStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> job_db = speos.client.jobs() """ diff --git a/src/ansys/speos/core/kernel/part.py b/src/ansys/speos/core/kernel/part.py index 330a399d4..8291a2b6c 100644 --- a/src/ansys/speos/core/kernel/part.py +++ b/src/ansys/speos/core/kernel/part.py @@ -95,7 +95,7 @@ class PartStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> part_db = speos.client.parts() """ diff --git a/src/ansys/speos/core/kernel/scene.py b/src/ansys/speos/core/kernel/scene.py index 6b0a2908e..b1f84fe98 100644 --- a/src/ansys/speos/core/kernel/scene.py +++ b/src/ansys/speos/core/kernel/scene.py @@ -59,7 +59,7 @@ class SceneLink(CrudItem): -------- >>> from ansys.speos.core.speos import Speos >>> from ansys.speos.core.kernel.scene import ProtoScene - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sce_db = speos.client.scenes() >>> sce_link = sce_db.create(message=ProtoScene(name="Empty_Scene")) @@ -165,7 +165,7 @@ class SceneStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sce_db = speos.client.scenes() """ diff --git a/src/ansys/speos/core/kernel/sensor_template.py b/src/ansys/speos/core/kernel/sensor_template.py index fc5eeb965..ee26fe105 100644 --- a/src/ansys/speos/core/kernel/sensor_template.py +++ b/src/ansys/speos/core/kernel/sensor_template.py @@ -52,7 +52,7 @@ class SensorTemplateLink(CrudItem): -------- >>> from ansys.speos.core.speos import Speos >>> from ansys.speos.core.kernel.sensor_template import ProtoSensorTemplate - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> ssr_t_db = speos.client.sensor_templates() >>> ssr_t_message = ProtoSensorTemplate(name="Irradiance") >>> ssr_t_message.irradiance_sensor_template.sensor_type_photometric.SetInParent() @@ -114,7 +114,7 @@ class SensorTemplateStub(CrudStub): sensor_templates() method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> ssr_t_db = speos.client.sensor_templates() """ diff --git a/src/ansys/speos/core/kernel/simulation_template.py b/src/ansys/speos/core/kernel/simulation_template.py index efa72da52..f03e50afc 100644 --- a/src/ansys/speos/core/kernel/simulation_template.py +++ b/src/ansys/speos/core/kernel/simulation_template.py @@ -58,7 +58,7 @@ class SimulationTemplateLink(CrudItem): >>> from ansys.speos.core.kernel.simulation_template import ( ... ProtoSimulationTemplate, ... ) - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sim_t_db = speos.client.simulation_templates() >>> sim_t_message = ProtoSimulationTemplate(name="Direct") >>> sim_t_message.direct_mc_simulation_template.geom_distance_tolerance = 0.01 @@ -119,7 +119,7 @@ class SimulationTemplateStub(CrudStub): simulation_templates() method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sim_t_db = speos.client.simulation_templates() """ diff --git a/src/ansys/speos/core/kernel/sop_template.py b/src/ansys/speos/core/kernel/sop_template.py index 06031125a..3228eb925 100644 --- a/src/ansys/speos/core/kernel/sop_template.py +++ b/src/ansys/speos/core/kernel/sop_template.py @@ -49,7 +49,7 @@ class SOPTemplateLink(CrudItem): -------- >>> from ansys.speos.core.speos import Speos >>> from ansys.speos.core.kernel.sop_template import ProtoSOPTemplate - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sop_t_db = speos.client.sop_templates() >>> sop_t_message = ProtoSOPTemplate(name="Mirror_50") >>> sop_t_message.mirror.reflectance = 50 @@ -104,7 +104,7 @@ class SOPTemplateStub(CrudStub): method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> sop_t_db = speos.client.sop_templates() """ diff --git a/src/ansys/speos/core/kernel/source_template.py b/src/ansys/speos/core/kernel/source_template.py index 226b1c9fd..549dbb8a3 100644 --- a/src/ansys/speos/core/kernel/source_template.py +++ b/src/ansys/speos/core/kernel/source_template.py @@ -108,7 +108,7 @@ class SourceTemplateStub(CrudStub): source_templates() method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> src_t_db = speos.client.source_templates() """ diff --git a/src/ansys/speos/core/kernel/spectrum.py b/src/ansys/speos/core/kernel/spectrum.py index 6ee2a3669..878be99d3 100644 --- a/src/ansys/speos/core/kernel/spectrum.py +++ b/src/ansys/speos/core/kernel/spectrum.py @@ -52,7 +52,7 @@ class SpectrumLink(CrudItem): -------- >>> from ansys.speos.core.speos import Speos >>> from ansys.speos.core.kernel.spectrum import ProtoSpectrum - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> spe_db = speos.client.spectrums() >>> spe_message = ProtoSpectrum(name="Monochromatic_600") >>> spe_message.monochromatic.wavelength = 600 @@ -107,7 +107,7 @@ class SpectrumStub(CrudStub): Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> spe_db = speos.client.spectrums() """ diff --git a/src/ansys/speos/core/kernel/vop_template.py b/src/ansys/speos/core/kernel/vop_template.py index 69bd7b90b..15b242ef8 100644 --- a/src/ansys/speos/core/kernel/vop_template.py +++ b/src/ansys/speos/core/kernel/vop_template.py @@ -49,7 +49,7 @@ class VOPTemplateLink(CrudItem): -------- >>> from ansys.speos.core.speos import Speos >>> from ansys.speos.core.kernel.vop_template import ProtoVOPTemplate - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> vop_t_db = speos.client.vop_templates() >>> vop_t_message = ProtoVOPTemplate(name="Opaque") >>> vop_t_message.opaque.SetInParent() @@ -104,7 +104,7 @@ class VOPTemplateStub(CrudStub): method. Like in the following example: >>> from ansys.speos.core.speos import Speos - >>> speos = Speos(host="localhost", port=50098) + >>> speos = Speos() >>> vop_t_db = speos.client.vop_templates() """ diff --git a/src/ansys/speos/core/launcher.py b/src/ansys/speos/core/launcher.py index bed5fd913..fec3f45e3 100644 --- a/src/ansys/speos/core/launcher.py +++ b/src/ansys/speos/core/launcher.py @@ -37,6 +37,7 @@ ) from ansys.speos.core.generic.general_methods import retrieve_speos_install_dir from ansys.speos.core.speos import Speos +from ansys.speos.core.kernel.client import default_local_channel try: import ansys.platform.instancemanagement as pypim @@ -182,9 +183,7 @@ def launch_local_speos_rpc_server( subprocess.Popen(command, stdout=out, stderr=err) # nosec B603 return Speos( - host="localhost", - port=port, - message_size=client_message_size, + channel=default_local_channel(port=port, message_size=client_message_size), logging_level=log_level, logging_file=logfile, speos_install_path=speos_rpc_path, diff --git a/src/ansys/speos/core/speos.py b/src/ansys/speos/core/speos.py index f5a2c223b..1f4938710 100644 --- a/src/ansys/speos/core/speos.py +++ b/src/ansys/speos/core/speos.py @@ -45,22 +45,14 @@ class Speos: Parameters ---------- - host : str, optional - Host where the server is running. - By default, ``ansys.speos.core.kernel.client.DEFAULT_HOST``. - port : Union[str, int], optional - Port number where the server is running. - By default, ``ansys.speos.core.kernel.client.DEFAULT_PORT``. version : str The Speos server version to run, in the 3 digits format, such as "242". If unspecified, the version will be chosen as ``ansys.speos.core.kernel.client.LATEST_VERSION``. - channel : ~grpc.Channel, optional + channel : grpc.Channel, optional gRPC channel for server communication. + Can be created with ``ansys.speos.core.kernel.grpc.transportoptions`` and ``ansys.speos.core.kernel.grpc.cyberchannel`` By default, ``None``. - message_size: int - Maximum Message size of a newly generated channel - By default, ``MAX_CLIENT_MESSAGE_SIZE``. remote_instance : ansys.platform.instancemanagement.Instance The corresponding remote instance when the Speos Service is launched through PyPIM. This instance will be deleted when calling @@ -77,11 +69,8 @@ class Speos: def __init__( self, - host: str = DEFAULT_HOST, - port: Union[str, int] = DEFAULT_PORT, version: str = DEFAULT_VERSION, channel: Optional[Channel] = None, - message_size: int = MAX_CLIENT_MESSAGE_SIZE, remote_instance: Optional["Instance"] = None, timeout: Optional[int] = 60, logging_level: Optional[int] = logging.INFO, @@ -89,11 +78,8 @@ def __init__( speos_install_path: Optional[Union[Path, str]] = None, ): self._client = SpeosClient( - host=host, - port=port, version=version, channel=channel, - message_size=message_size, remote_instance=remote_instance, timeout=timeout, logging_level=logging_level, diff --git a/tests/conftest.py b/tests/conftest.py index 66189167d..bf05edfbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,7 @@ from ansys.speos.core import LOG from ansys.speos.core.generic.constants import MAX_CLIENT_MESSAGE_SIZE from ansys.speos.core.speos import Speos +from ansys.speos.core.kernel.client import default_local_channel, default_docker_channel try: import pyvista as pv @@ -50,10 +51,24 @@ IMAGE_RESULTS_DIR = Path(Path(__file__).parent, "image_results") IS_WINDOWS = os.name == "nt" +# Load the local config file +local_path = Path(os.path.realpath(__file__)).parent +local_config_file = local_path / "local_config.json" +if local_config_file.exists(): + with local_config_file.open() as f: + config = json.load(f) +else: + raise ValueError("Missing local_config.json file") + +# Check if server is on docker +IS_DOCKER = config.get("SpeosServerOnDocker") +if IS_DOCKER: + DOCKER_CONTAINER_NAME = config.get("SpeosContainerName") +SERVER_PORT = config.get("SpeosServerPort") @pytest.fixture(scope="session") def speos(): - """Pytest ficture to create Speos objects for all unit, integration and workflow tests. + """Pytest fixture to create Speos objects for all unit, integration and workflow tests. Yields ------ @@ -68,30 +83,22 @@ def speos(): log_file_path = Path(__file__).absolute().parent / "logs" / "integration_tests_logs.txt" Path(log_file_path).unlink(missing_ok=True) + message_size = MAX_CLIENT_MESSAGE_SIZE * 128 + if IS_DOCKER: + channel = default_docker_channel(port=SERVER_PORT, message_size=message_size) + else: + channel = default_local_channel(port=SERVER_PORT, message_size=message_size) speos = Speos( logging_level=logging.DEBUG, logging_file=log_file_path, - port=str(config.get("SpeosServerPort")), - message_size=MAX_CLIENT_MESSAGE_SIZE * 128, + channel=channel, ) yield speos - -local_path = Path(os.path.realpath(__file__)).parent - -# Load the local config file -local_config_file = local_path / "local_config.json" -if local_config_file.exists(): - with local_config_file.open() as f: - config = json.load(f) -else: - raise ValueError("Missing local_config.json file") - - # set test_path var depending on if we are using the servers in a docker container or not local_test_path = local_path / "assets" -if config.get("SpeosServerOnDocker"): +if IS_DOCKER: test_path = "/app/assets/" else: test_path = local_test_path diff --git a/tests/core/test_launcher.py b/tests/core/test_launcher.py index 0b9c96de0..094e7fb30 100644 --- a/tests/core/test_launcher.py +++ b/tests/core/test_launcher.py @@ -27,21 +27,28 @@ import subprocess import tempfile from unittest.mock import patch +from unittest import TestCase import psutil import pytest from ansys.speos.core.generic.constants import DEFAULT_VERSION -from ansys.speos.core.launcher import launch_local_speos_rpc_server -from tests.conftest import IS_WINDOWS, config +from ansys.speos.core.launcher import launch_local_speos_rpc_server, retrieve_speos_install_dir +from tests.conftest import IS_WINDOWS, IS_DOCKER, SERVER_PORT, config -IS_DOCKER = config.get("SpeosServerOnDocker") +# Check local installation +try: + _ = retrieve_speos_install_dir(None, DEFAULT_VERSION) + HAS_LOCAL_SPEOS_SERVER = True +except FileNotFoundError: + HAS_LOCAL_SPEOS_SERVER = False - -@pytest.mark.skipif(IS_DOCKER, reason="launcher only works without Docker image") +@pytest.mark.skipif( + not HAS_LOCAL_SPEOS_SERVER, + reason="requires Speos server to be installed locally") def test_local_session(*args): """Test local session launch and close.""" - port = config.get("SpeosServerPort") + 1 + port = SERVER_PORT + 1 if IS_WINDOWS: speos_loc = None name = "SpeosRPC_Server.exe" @@ -59,12 +66,14 @@ def test_local_session(*args): running = p_list.count(name) > nb_process assert running is not closed - @patch.object(subprocess, "Popen") @patch.object(subprocess, "run") +@pytest.mark.skipif( + not IS_DOCKER, + reason="requires Speos server to be installed locally") def test_coverage_launcher_speosdocker(*args): """Test local session launch on remote server to improve coverage.""" - port = config.get("SpeosServerPort") + port = SERVER_PORT tmp_file = tempfile.gettempdir() if IS_WINDOWS: name = "SpeosRPC_Server.exe" diff --git a/tests/core/test_simulation.py b/tests/core/test_simulation.py index 775233c81..ec0a5af9a 100644 --- a/tests/core/test_simulation.py +++ b/tests/core/test_simulation.py @@ -35,11 +35,9 @@ SimulationInverse, ) from ansys.speos.core.source import SourceLuminaire -from tests.conftest import config, test_path +from tests.conftest import config, test_path, IS_DOCKER from tests.helper import does_file_exist, remove_file -IS_DOCKER = config.get("SpeosServerOnDocker") - @pytest.mark.supported_speos_versions(min=251) def test_create_direct(speos: Speos): diff --git a/tests/helper.py b/tests/helper.py index d62b410a1..5206fb252 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -35,7 +35,7 @@ from ansys.speos.core.kernel.job import JobLink, messages as job_messages from ansys.speos.core.kernel.proto_message_utils import protobuf_message_to_str from ansys.speos.core.speos import SpeosClient -from tests.conftest import config +from tests.conftest import IS_DOCKER, DOCKER_CONTAINER_NAME def clean_all_dbs(speos_client: SpeosClient): @@ -105,11 +105,11 @@ def does_file_exist(path): ----------- bool """ - if config.get("SpeosServerOnDocker"): + if IS_DOCKER: return ( subprocess.call( "docker exec " - + config.get("SpeosContainerName") + + DOCKER_CONTAINER_NAME + ' test -f "' + Path(path).as_posix() + '"', @@ -137,10 +137,10 @@ def rmtree(f: Path): rmtree(child) f.rmdir() - if config.get("SpeosServerOnDocker"): + if IS_DOCKER: subprocess.call( "docker exec " - + config.get("SpeosContainerName") + + DOCKER_CONTAINER_NAME + ' rm -rf "' + Path(path).as_posix() + '"', diff --git a/tests/kernel/test_client.py b/tests/kernel/test_client.py index a4c6885c6..014c68a30 100644 --- a/tests/kernel/test_client.py +++ b/tests/kernel/test_client.py @@ -22,11 +22,11 @@ """Test basic client connection.""" -from grpc import insecure_channel +from ansys.speos.core.kernel.grpc.cyberchannel import create_channel -from ansys.speos.core.kernel.client import SpeosClient +from ansys.speos.core.kernel.client import SpeosClient, default_local_channel, default_docker_channel from ansys.speos.core.speos import Speos -from tests.conftest import config +from tests.conftest import SERVER_PORT, IS_DOCKER def test_client_init(speos: Speos): @@ -36,8 +36,11 @@ def test_client_init(speos: Speos): def test_client_through_channel(): """Test the instantiation of a client from a gRPC channel.""" - target = "dns:///localhost:" + str(config.get("SpeosServerPort")) - channel = insecure_channel(target) + target = "dns:///localhost:" + str(SERVER_PORT) + if IS_DOCKER: + channel = default_docker_channel(port=SERVER_PORT) + else: + channel = default_local_channel(port=SERVER_PORT) client = SpeosClient(channel=channel) client_repr = repr(client) assert "Target" in client_repr From 466371c106e541c4e3e6918631dc8268c9f4cefd Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Tue, 25 Nov 2025 12:38:27 +0100 Subject: [PATCH 02/16] apply precommit --- .github/workflows/ci_cd.yml | 2 +- examples/core/bsdf.py | 10 ++-- examples/core/lpf-preview.py | 6 ++- examples/core/opt-prop.py | 7 ++- examples/core/part.py | 6 ++- examples/core/prism-example.py | 6 ++- examples/core/project.py | 6 ++- examples/core/sensor.py | 6 ++- examples/core/simulation.py | 6 ++- examples/core/source.py | 6 ++- examples/kernel/modify-scene.py | 6 ++- examples/kernel/object-link.py | 6 ++- examples/kernel/scene-job.py | 6 ++- examples/workflow/combine-speos.py | 6 ++- examples/workflow/open-result.py | 6 ++- src/ansys/speos/core/kernel/client.py | 51 +++++++++++-------- .../speos/core/kernel/grpc/cyberchannel.py | 49 +++++++++++++++--- .../core/kernel/grpc/transportoptions.py | 20 ++++---- src/ansys/speos/core/launcher.py | 2 +- src/ansys/speos/core/speos.py | 3 -- tests/conftest.py | 4 +- tests/core/test_launcher.py | 13 +++-- tests/core/test_simulation.py | 2 +- tests/helper.py | 14 ++--- tests/kernel/test_client.py | 10 ++-- 25 files changed, 162 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 390f91c80..c2ca56fae 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -102,7 +102,7 @@ jobs: env: ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} run: | - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure --host 0.0.0.0 - name: "Run Ansys documentation building action" uses: ansys/actions/doc-build@c2fa7c93f6883114e0e643599431b33d29f0b13f # v10.1.4 with: diff --git a/examples/core/bsdf.py b/examples/core/bsdf.py index f80f96200..ed838e8bd 100644 --- a/examples/core/bsdf.py +++ b/examples/core/bsdf.py @@ -28,8 +28,12 @@ from ansys.speos.core import Speos from ansys.speos.core.bsdf import AnisotropicBSDF, BxdfDatapoint +from ansys.speos.core.kernel.client import ( + SpeosClient, + default_docker_channel, +) from ansys.speos.core.launcher import launch_local_speos_rpc_server -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel + # - # ### Define constants @@ -159,8 +163,8 @@ def create_spectrum(value, w_start=380.0, w_end=780.0, w_step=10): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) -else: + speos = Speos(channel=default_docker_channel()) +else: speos = launch_local_speos_rpc_server() # ### Create a BXDFDatapoint diff --git a/examples/core/lpf-preview.py b/examples/core/lpf-preview.py index 6dfe902d1..37236c6ce 100644 --- a/examples/core/lpf-preview.py +++ b/examples/core/lpf-preview.py @@ -10,8 +10,10 @@ from pathlib import Path from ansys.speos.core import LightPathFinder, Project, Speos, launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.simulation import SimulationInteractive -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -46,7 +48,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/opt-prop.py b/examples/core/opt-prop.py index a2f3f9d43..5f79c218b 100644 --- a/examples/core/opt-prop.py +++ b/examples/core/opt-prop.py @@ -17,7 +17,10 @@ from pathlib import Path from ansys.speos.core import Project, Speos, launcher -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) + # - # ### Define constants @@ -76,7 +79,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/part.py b/examples/core/part.py index d38999948..b4b4e7862 100644 --- a/examples/core/part.py +++ b/examples/core/part.py @@ -16,8 +16,10 @@ from pathlib import Path from ansys.speos.core import Body, Face, Part, Project, Speos +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.launcher import launch_local_speos_rpc_server -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -51,7 +53,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/prism-example.py b/examples/core/prism-example.py index ec4f05457..284b1c8c5 100644 --- a/examples/core/prism-example.py +++ b/examples/core/prism-example.py @@ -13,10 +13,12 @@ from pathlib import Path from ansys.speos.core import Body, Project, Speos +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.launcher import launch_local_speos_rpc_server from ansys.speos.core.sensor import Sensor3DIrradiance, SensorIrradiance from ansys.speos.core.simulation import SimulationDirect -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -51,7 +53,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/project.py b/examples/core/project.py index 2348c5c22..5bb2cc490 100644 --- a/examples/core/project.py +++ b/examples/core/project.py @@ -19,11 +19,13 @@ from pathlib import Path from ansys.speos.core import Project, Speos +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.launcher import launch_local_speos_rpc_server from ansys.speos.core.sensor import SensorIrradiance from ansys.speos.core.simulation import SimulationDirect from ansys.speos.core.source import SourceLuminaire, SourceSurface -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -58,7 +60,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/sensor.py b/examples/core/sensor.py index 1c95feae3..a9c19fc1c 100644 --- a/examples/core/sensor.py +++ b/examples/core/sensor.py @@ -11,13 +11,15 @@ from pathlib import Path from ansys.speos.core import GeoRef, Project, Speos, launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.sensor import ( Sensor3DIrradiance, SensorCamera, SensorIrradiance, SensorRadiance, ) -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # ### Define constants # @@ -74,7 +76,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/simulation.py b/examples/core/simulation.py index fb28fba9b..afac1db39 100644 --- a/examples/core/simulation.py +++ b/examples/core/simulation.py @@ -14,8 +14,10 @@ from pathlib import Path from ansys.speos.core import Project, Speos, launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.simulation import SimulationInteractive, SimulationInverse -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -51,7 +53,7 @@ # be used to start a local instance of the service.. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/core/source.py b/examples/core/source.py index 3c233ad62..ffd240d68 100644 --- a/examples/core/source.py +++ b/examples/core/source.py @@ -12,13 +12,15 @@ from pathlib import Path from ansys.speos.core import GeoRef, Project, Speos, launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.source import ( SourceAmbientNaturalLight, SourceLuminaire, SourceRayFile, SourceSurface, ) -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -76,7 +78,7 @@ def create_face(body): # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/modify-scene.py b/examples/kernel/modify-scene.py index 7d8d5192a..bd53a4d86 100644 --- a/examples/kernel/modify-scene.py +++ b/examples/kernel/modify-scene.py @@ -34,9 +34,11 @@ from ansys.api.speos.sensor.v1 import camera_sensor_pb2 from ansys.speos.core import Speos, launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.kernel.scene import ProtoScene from ansys.speos.core.kernel.sensor_template import ProtoSensorTemplate -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -68,7 +70,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/object-link.py b/examples/kernel/object-link.py index b2ef2abc5..2528d4405 100644 --- a/examples/kernel/object-link.py +++ b/examples/kernel/object-link.py @@ -1,5 +1,8 @@ # # How to use an ObjectLink from ansys.speos.core import launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) # This tutorial demonstrates how to use speos objects in layer core. # ## What is an ObjectLink? @@ -14,7 +17,6 @@ # + from ansys.speos.core.kernel.sop_template import ProtoSOPTemplate from ansys.speos.core.speos import Speos -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - # ### Define constants @@ -31,7 +33,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/kernel/scene-job.py b/examples/kernel/scene-job.py index 8d27e1a42..4a91ae3df 100644 --- a/examples/kernel/scene-job.py +++ b/examples/kernel/scene-job.py @@ -12,9 +12,11 @@ import time from ansys.speos.core import launcher +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.kernel.job import ProtoJob from ansys.speos.core.speos import Speos -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # ### Define constants # Constants help ensure consistency and avoid repetition throughout the example. @@ -44,7 +46,7 @@ # be used to start a local instance of the service. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/workflow/combine-speos.py b/examples/workflow/combine-speos.py index a4ab757aa..62b397c13 100644 --- a/examples/workflow/combine-speos.py +++ b/examples/workflow/combine-speos.py @@ -10,11 +10,13 @@ from pathlib import Path from ansys.speos.core import Part, Speos +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.sensor import SensorCamera from ansys.speos.core.simulation import SimulationInverse from ansys.speos.core.source import SourceLuminaire from ansys.speos.core.workflow.combine_speos import SpeosFileInstance, combine_speos -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -67,7 +69,7 @@ # ## Create connection with speos rpc server if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/examples/workflow/open-result.py b/examples/workflow/open-result.py index 8afc11901..53ba263cc 100644 --- a/examples/workflow/open-result.py +++ b/examples/workflow/open-result.py @@ -11,8 +11,10 @@ from pathlib import Path from ansys.speos.core import Project, Speos +from ansys.speos.core.kernel.client import ( + default_docker_channel, +) from ansys.speos.core.simulation import SimulationDirect -from ansys.speos.core.kernel.client import SpeosClient, default_docker_channel, default_local_channel # - @@ -48,7 +50,7 @@ # machine. if USE_DOCKER: - speos = Speos(channel = default_docker_channel()) + speos = Speos(channel=default_docker_channel()) else: speos = launcher.launch_local_speos_rpc_server(port=GRPC_PORT) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 24b947ef1..32dd64d1f 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -28,7 +28,6 @@ import subprocess # nosec import time from typing import TYPE_CHECKING, List, Optional, Union -import warnings from ansys.api.speos.part.v1 import body_pb2, face_pb2, part_pb2 import grpc @@ -40,11 +39,16 @@ DEFAULT_VERSION, MAX_CLIENT_MESSAGE_SIZE, ) -from ansys.speos.core.kernel.grpc.transportoptions import TransportOptions, TransportMode, UDSOptions, InsecureOptions, WNUAOptions -from ansys.speos.core.kernel.grpc.cyberchannel import create_channel from ansys.speos.core.generic.general_methods import retrieve_speos_install_dir from ansys.speos.core.kernel.body import BodyLink, BodyStub from ansys.speos.core.kernel.face import FaceLink, FaceStub +from ansys.speos.core.kernel.grpc.transportoptions import ( + InsecureOptions, + TransportMode, + TransportOptions, + UDSOptions, + WNUAOptions, +) from ansys.speos.core.kernel.intensity_template import ( IntensityTemplateLink, IntensityTemplateStub, @@ -109,33 +113,38 @@ def wait_until_healthy(channel: grpc.Channel, timeout: float): f"Channel health check to target '{target_str}' timed out after {timeout} seconds." ) + def default_docker_channel( - host: Optional[str] = DEFAULT_HOST, - port: Union[str, int] = DEFAULT_PORT, - message_size: int = MAX_CLIENT_MESSAGE_SIZE - ) -> grpc.Channel: + host: Optional[str] = DEFAULT_HOST, + port: Union[str, int] = DEFAULT_PORT, + message_size: int = MAX_CLIENT_MESSAGE_SIZE, +) -> grpc.Channel: return TransportOptions( - mode=TransportMode.INSECURE, - options=InsecureOptions(host=host, port=port, allow_remote_host=True) - ).create_channel(grpc_options=[("grpc.max_receive_message_length", message_size)]) + mode=TransportMode.INSECURE, + options=InsecureOptions(host=host, port=port, allow_remote_host=True), + ).create_channel(grpc_options=[("grpc.max_receive_message_length", message_size)]) + def default_local_channel( - port: Union[str, int] = DEFAULT_PORT, - message_size: int = MAX_CLIENT_MESSAGE_SIZE - ) -> grpc.Channel: + port: Union[str, int] = DEFAULT_PORT, message_size: int = MAX_CLIENT_MESSAGE_SIZE +) -> grpc.Channel: """Create default transport options, WNUA on Windows, UDS on Linux""" # Otherwise use default based on OS if os.name == "nt": transport = TransportOptions( - mode=TransportMode.WNUA, - options=WNUAOptions(host=DEFAULT_HOST, port=port) - ) + mode=TransportMode.WNUA, options=WNUAOptions(host=DEFAULT_HOST, port=port) + ) else: transport = TransportOptions( - mode=TransportMode.UDS, - options=UDSOptions(uds_dir=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer") - ) - return transport.create_channel(grpc_options=[("grpc.max_receive_message_length", message_size)]) + mode=TransportMode.UDS, + options=UDSOptions( + uds_dir=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer" + ), + ) + return transport.create_channel( + grpc_options=[("grpc.max_receive_message_length", message_size)] + ) + class SpeosClient: """ @@ -192,7 +201,7 @@ def __init__( self._channel = channel else: self._channel = default_local_channel() - + # do not finish initialization until channel is healthy wait_until_healthy(self._channel, timeout) diff --git a/src/ansys/speos/core/kernel/grpc/cyberchannel.py b/src/ansys/speos/core/kernel/grpc/cyberchannel.py index 94ee8647a..353f7fc85 100644 --- a/src/ansys/speos/core/kernel/grpc/cyberchannel.py +++ b/src/ansys/speos/core/kernel/grpc/cyberchannel.py @@ -1,3 +1,25 @@ +# Copyright (C) 2021 - 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 to create gRPC channels with different transport modes. This module provides functions to create gRPC channels based on the specified @@ -20,13 +42,12 @@ # Only the create_channel function is exposed for external use __all__ = ["create_channel", "verify_transport_mode", "verify_uds_socket"] +from dataclasses import dataclass import logging import os -from dataclasses import dataclass from pathlib import Path from typing import cast from warnings import warn -from typing import TypeGuard import grpc @@ -35,12 +56,14 @@ logger = logging.getLogger(__name__) + @dataclass class CertificateFiles: cert_file: str | Path | None = None key_file: str | Path | None = None ca_file: str | Path | None = None + def create_channel( transport_mode: str, host: str | None = None, @@ -98,11 +121,16 @@ def create_channel( The created gRPC channel """ + def check_host_port(transport_mode, host, port) -> tuple[str, str, str]: if host is None: - raise ValueError(f"When using {transport_mode.lower()} transport mode, 'host' must be provided.") + raise ValueError( + f"When using {transport_mode.lower()} transport mode, 'host' must be provided." + ) if port is None: - raise ValueError(f"When using {transport_mode.lower()} transport mode, 'port' must be provided.") + raise ValueError( + f"When using {transport_mode.lower()} transport mode, 'port' must be provided." + ) return transport_mode, host, port match transport_mode.lower(): @@ -295,7 +323,12 @@ def create_mtls_channel( """ certs_folder = None - if cert_files is not None and cert_files.cert_file is not None and cert_files.key_file is not None and cert_files.ca_file is not None: + if ( + cert_files is not None + and cert_files.cert_file is not None + and cert_files.key_file is not None + and cert_files.ca_file is not None + ): cert_file = Path(cert_files.cert_file).resolve() key_file = Path(cert_files.key_file).resolve() ca_file = Path(cert_files.ca_file).resolve() @@ -322,8 +355,10 @@ def create_mtls_channel( except FileNotFoundError as e: error_message = f"Certificate file not found: {e.filename}. " if certs_folder is not None: - error_message += f"Ensure that the certificates are present in the '{certs_folder}' folder or " \ - "set the 'ANSYS_GRPC_CERTIFICATES' environment variable." + error_message += ( + f"Ensure that the certificates are present in the '{certs_folder}' folder or " + "set the 'ANSYS_GRPC_CERTIFICATES' environment variable." + ) raise FileNotFoundError(error_message) from e # Create SSL credentials diff --git a/src/ansys/speos/core/kernel/grpc/transportoptions.py b/src/ansys/speos/core/kernel/grpc/transportoptions.py index 03543e771..473db5755 100644 --- a/src/ansys/speos/core/kernel/grpc/transportoptions.py +++ b/src/ansys/speos/core/kernel/grpc/transportoptions.py @@ -26,12 +26,13 @@ different transport modes (UDS, mTLS, Insecure) for the FileTransfer Tool. """ -from dataclasses import dataclass, field -import os -from pathlib import Path +from dataclasses import dataclass import enum +from pathlib import Path + from .cyberchannel import create_channel + class TransportMode(enum.Enum): """Enumeration of transport modes supported by the FileTransfer Tool.""" @@ -99,6 +100,7 @@ def _to_cyberchannel_kwargs(self): "port": self.port, } + @dataclass(kw_only=True) class WNUAOptions: """Options for Windows Named User Authentication transport mode.""" @@ -112,6 +114,7 @@ def _to_cyberchannel_kwargs(self): "port": self.port, } + @dataclass(kw_only=True) class TransportOptions: """Transport options for the FileTransfer Tool client.""" @@ -145,9 +148,7 @@ def __init__( ) elif mode == TransportMode.WNUA: if not isinstance(options, WNUAOptions): - raise TypeError( - "For WNUA transport mode, options must be of type WNUAOptions." - ) + raise TypeError("For WNUA transport mode, options must be of type WNUAOptions.") else: raise ValueError(f"Unsupported transport mode: {mode}") @@ -160,12 +161,9 @@ def _to_cyberchannel_kwargs(self): "transport_mode": self.mode.value, **self.options._to_cyberchannel_kwargs(), } - + def create_channel(self, grpc_options): """ Create a gRPC channel based on the transport options. """ - return create_channel( - **self._to_cyberchannel_kwargs(), - grpc_options=grpc_options - ) + return create_channel(**self._to_cyberchannel_kwargs(), grpc_options=grpc_options) diff --git a/src/ansys/speos/core/launcher.py b/src/ansys/speos/core/launcher.py index fec3f45e3..37ba8f840 100644 --- a/src/ansys/speos/core/launcher.py +++ b/src/ansys/speos/core/launcher.py @@ -36,8 +36,8 @@ MAX_SERVER_MESSAGE_LENGTH, ) from ansys.speos.core.generic.general_methods import retrieve_speos_install_dir -from ansys.speos.core.speos import Speos from ansys.speos.core.kernel.client import default_local_channel +from ansys.speos.core.speos import Speos try: import ansys.platform.instancemanagement as pypim diff --git a/src/ansys/speos/core/speos.py b/src/ansys/speos/core/speos.py index 1f4938710..53a10bc3f 100644 --- a/src/ansys/speos/core/speos.py +++ b/src/ansys/speos/core/speos.py @@ -29,10 +29,7 @@ from grpc import Channel from ansys.speos.core.generic.constants import ( - DEFAULT_HOST, - DEFAULT_PORT, DEFAULT_VERSION, - MAX_CLIENT_MESSAGE_SIZE, ) from ansys.speos.core.kernel.client import SpeosClient diff --git a/tests/conftest.py b/tests/conftest.py index bf05edfbf..1214342d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,8 +37,8 @@ from ansys.speos.core import LOG from ansys.speos.core.generic.constants import MAX_CLIENT_MESSAGE_SIZE +from ansys.speos.core.kernel.client import default_docker_channel, default_local_channel from ansys.speos.core.speos import Speos -from ansys.speos.core.kernel.client import default_local_channel, default_docker_channel try: import pyvista as pv @@ -66,6 +66,7 @@ DOCKER_CONTAINER_NAME = config.get("SpeosContainerName") SERVER_PORT = config.get("SpeosServerPort") + @pytest.fixture(scope="session") def speos(): """Pytest fixture to create Speos objects for all unit, integration and workflow tests. @@ -96,6 +97,7 @@ def speos(): yield speos + # set test_path var depending on if we are using the servers in a docker container or not local_test_path = local_path / "assets" if IS_DOCKER: diff --git a/tests/core/test_launcher.py b/tests/core/test_launcher.py index 094e7fb30..4d749697e 100644 --- a/tests/core/test_launcher.py +++ b/tests/core/test_launcher.py @@ -27,14 +27,13 @@ import subprocess import tempfile from unittest.mock import patch -from unittest import TestCase import psutil import pytest from ansys.speos.core.generic.constants import DEFAULT_VERSION from ansys.speos.core.launcher import launch_local_speos_rpc_server, retrieve_speos_install_dir -from tests.conftest import IS_WINDOWS, IS_DOCKER, SERVER_PORT, config +from tests.conftest import IS_DOCKER, IS_WINDOWS, SERVER_PORT # Check local installation try: @@ -43,9 +42,10 @@ except FileNotFoundError: HAS_LOCAL_SPEOS_SERVER = False + @pytest.mark.skipif( - not HAS_LOCAL_SPEOS_SERVER, - reason="requires Speos server to be installed locally") + not HAS_LOCAL_SPEOS_SERVER, reason="requires Speos server to be installed locally" +) def test_local_session(*args): """Test local session launch and close.""" port = SERVER_PORT + 1 @@ -66,11 +66,10 @@ def test_local_session(*args): running = p_list.count(name) > nb_process assert running is not closed + @patch.object(subprocess, "Popen") @patch.object(subprocess, "run") -@pytest.mark.skipif( - not IS_DOCKER, - reason="requires Speos server to be installed locally") +@pytest.mark.skipif(not IS_DOCKER, reason="requires Speos server to be installed locally") def test_coverage_launcher_speosdocker(*args): """Test local session launch on remote server to improve coverage.""" port = SERVER_PORT diff --git a/tests/core/test_simulation.py b/tests/core/test_simulation.py index ec0a5af9a..567fd941c 100644 --- a/tests/core/test_simulation.py +++ b/tests/core/test_simulation.py @@ -35,7 +35,7 @@ SimulationInverse, ) from ansys.speos.core.source import SourceLuminaire -from tests.conftest import config, test_path, IS_DOCKER +from tests.conftest import IS_DOCKER, test_path from tests.helper import does_file_exist, remove_file diff --git a/tests/helper.py b/tests/helper.py index 5206fb252..e0a17a379 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -35,7 +35,7 @@ from ansys.speos.core.kernel.job import JobLink, messages as job_messages from ansys.speos.core.kernel.proto_message_utils import protobuf_message_to_str from ansys.speos.core.speos import SpeosClient -from tests.conftest import IS_DOCKER, DOCKER_CONTAINER_NAME +from tests.conftest import DOCKER_CONTAINER_NAME, IS_DOCKER def clean_all_dbs(speos_client: SpeosClient): @@ -108,11 +108,7 @@ def does_file_exist(path): if IS_DOCKER: return ( subprocess.call( - "docker exec " - + DOCKER_CONTAINER_NAME - + ' test -f "' - + Path(path).as_posix() - + '"', + "docker exec " + DOCKER_CONTAINER_NAME + ' test -f "' + Path(path).as_posix() + '"', shell=True, ) == 0 @@ -139,11 +135,7 @@ def rmtree(f: Path): if IS_DOCKER: subprocess.call( - "docker exec " - + DOCKER_CONTAINER_NAME - + ' rm -rf "' - + Path(path).as_posix() - + '"', + "docker exec " + DOCKER_CONTAINER_NAME + ' rm -rf "' + Path(path).as_posix() + '"', shell=True, ) else: diff --git a/tests/kernel/test_client.py b/tests/kernel/test_client.py index 014c68a30..7a1f18d20 100644 --- a/tests/kernel/test_client.py +++ b/tests/kernel/test_client.py @@ -22,11 +22,13 @@ """Test basic client connection.""" -from ansys.speos.core.kernel.grpc.cyberchannel import create_channel - -from ansys.speos.core.kernel.client import SpeosClient, default_local_channel, default_docker_channel +from ansys.speos.core.kernel.client import ( + SpeosClient, + default_docker_channel, + default_local_channel, +) from ansys.speos.core.speos import Speos -from tests.conftest import SERVER_PORT, IS_DOCKER +from tests.conftest import IS_DOCKER, SERVER_PORT def test_client_init(speos: Speos): From 8ba51f3b1f371dd0c2bc3e1e316d4dac073ed21f Mon Sep 17 00:00:00 2001 From: jmadec Date: Wed, 26 Nov 2025 09:13:25 +0100 Subject: [PATCH 03/16] tests on local linux --- src/ansys/speos/core/kernel/client.py | 5 +- .../speos/core/kernel/grpc/cyberchannel.py | 87 +++++++++++++------ .../core/kernel/grpc/transportoptions.py | 9 +- src/ansys/speos/core/kernel/job.py | 38 +++++++- tests/conftest.py | 1 + tests/core/test_simulation.py | 5 +- tests/kernel/test_client.py | 7 +- 7 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 32dd64d1f..1ba345494 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -119,6 +119,7 @@ def default_docker_channel( port: Union[str, int] = DEFAULT_PORT, message_size: int = MAX_CLIENT_MESSAGE_SIZE, ) -> grpc.Channel: + """Create default transport options for docker on CI.""" return TransportOptions( mode=TransportMode.INSECURE, options=InsecureOptions(host=host, port=port, allow_remote_host=True), @@ -128,7 +129,7 @@ def default_docker_channel( def default_local_channel( port: Union[str, int] = DEFAULT_PORT, message_size: int = MAX_CLIENT_MESSAGE_SIZE ) -> grpc.Channel: - """Create default transport options, WNUA on Windows, UDS on Linux""" + """Create default transport options, WNUA on Windows, UDS on Linux.""" # Otherwise use default based on OS if os.name == "nt": transport = TransportOptions( @@ -138,7 +139,7 @@ def default_local_channel( transport = TransportOptions( mode=TransportMode.UDS, options=UDSOptions( - uds_dir=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer" + uds_fullpath=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer" ), ) return transport.create_channel( diff --git a/src/ansys/speos/core/kernel/grpc/cyberchannel.py b/src/ansys/speos/core/kernel/grpc/cyberchannel.py index 353f7fc85..1761a512c 100644 --- a/src/ansys/speos/core/kernel/grpc/cyberchannel.py +++ b/src/ansys/speos/core/kernel/grpc/cyberchannel.py @@ -28,12 +28,15 @@ Example ------- + +.. code-block:: python + channel = create_channel( host="localhost", port=50051, transport_mode="mtls", certs_dir="path/to/certs", - grpc_options=[('grpc.max_receive_message_length', 50 * 1024 * 1024)], + grpc_options=[("grpc.max_receive_message_length", 50 * 1024 * 1024)], ) stub = hello_pb2_grpc.GreeterStub(channel) @@ -49,7 +52,14 @@ from typing import cast from warnings import warn -import grpc +try: + import grpc +except ImportError: # pragma: no cover + warn( + "grpc module is not available - " + "reach out to the library maintainers to include it into their dependencies" + ) + _IS_WINDOWS = os.name == "nt" LOOPBACK_HOSTS = ("localhost", "127.0.0.1") @@ -68,6 +78,7 @@ def create_channel( transport_mode: str, host: str | None = None, port: int | str | None = None, + uds_fullpath: str | Path | None = None, uds_service: str | None = None, uds_dir: str | Path | None = None, uds_id: str | None = None, @@ -90,6 +101,9 @@ def create_channel( Port in which the server is running. By default `None` - however, if not using UDS transport mode, it will be requested. + uds_fullpath : str | Path | None + Full path to the UDS socket file. + By default `None` and thus it will use the `uds_service`, `uds_dir` and `uds_id` parameters. uds_service : str | None Optional service name for the UDS socket. By default `None` - however, if UDS is selected, it will @@ -138,7 +152,7 @@ def check_host_port(transport_mode, host, port) -> tuple[str, str, str]: transport_mode, host, port = check_host_port(transport_mode, host, port) return create_insecure_channel(host, port, grpc_options) case "uds": - return create_uds_channel(uds_service, uds_dir, uds_id, grpc_options) + return create_uds_channel(uds_fullpath, uds_service, uds_dir, uds_id, grpc_options) case "wnua": transport_mode, host, port = check_host_port(transport_mode, host, port) return create_wnua_channel(host, port, grpc_options) @@ -147,8 +161,8 @@ def check_host_port(transport_mode, host, port) -> tuple[str, str, str]: return create_mtls_channel(host, port, certs_dir, cert_files, grpc_options) case _: raise ValueError( - f"Unknown transport mode: {transport_mode}. " - "Valid options are: 'insecure', 'uds', 'wnua', 'mtls'." + f"Unknown transport mode: {transport_mode}." + " Valid options are: 'insecure', 'uds', 'wnua', 'mtls'." ) @@ -179,15 +193,16 @@ def create_insecure_channel( """ target = f"{host}:{port}" warn( - f"Starting gRPC client without TLS on {target}. This is INSECURE. " - "Consider using a secure connection." + f"Starting gRPC client without TLS on {target}." + " This is INSECURE. Consider using a secure connection." ) logger.info(f"Connecting using INSECURE -> {target}") return grpc.insecure_channel(target, options=grpc_options) def create_uds_channel( - uds_service: str | None, + uds_fullpath: str | Path | None = None, + uds_service: str | None = None, uds_dir: str | Path | None = None, uds_id: str | None = None, grpc_options: list[tuple[str, object]] | None = None, @@ -196,7 +211,10 @@ def create_uds_channel( Parameters ---------- - uds_service : str + uds_fullpath : str | Path | None + Full path to the UDS socket file. + By default `None` and thus it will use the `uds_service`, `uds_dir` and `uds_id` parameters. + uds_service : str | None Service name for the UDS socket. uds_dir : str | Path | None Directory to use for Unix Domain Sockets (UDS) transport mode. @@ -221,18 +239,24 @@ def create_uds_channel( "Unix Domain Sockets are not supported on this platform or gRPC version." ) - if not uds_service: - raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") + if uds_fullpath: + # Ensure the parent directory exists + Path(uds_fullpath).parent.mkdir(parents=True, exist_ok=True) + target = f"unix:{uds_fullpath}" + else: + if not uds_service: + raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") + + # Determine UDS folder + uds_folder = determine_uds_folder(uds_dir) - # Determine UDS folder - uds_folder = determine_uds_folder(uds_dir) + # Make sure the folder exists + uds_folder.mkdir(parents=True, exist_ok=True) - # Make sure the folder exists - uds_folder.mkdir(parents=True, exist_ok=True) + # Generate socket filename with optional ID + socket_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" + target = f"unix:{uds_folder / socket_filename}" - # Generate socket filename with optional ID - socket_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" - target = f"unix:{uds_folder / socket_filename}" # Set default authority to "localhost" for UDS connection # This is needed to avoid issues with some gRPC implementations, # see https://github.com/grpc/grpc/issues/34305 @@ -242,6 +266,7 @@ def create_uds_channel( if grpc_options: options.extend(grpc_options) logger.info(f"Connecting using UDS -> {target}") + return grpc.insecure_channel(target, options=options) @@ -478,12 +503,15 @@ def verify_transport_mode(transport_mode: str, mode: str | None = None) -> None: if transport_mode.lower() not in valid_modes: raise ValueError( f"Invalid transport mode: {transport_mode}. " - f"Valid options are: {', '.join(valid_modes)}." + f" Valid options are: {', '.join(valid_modes)}." ) def verify_uds_socket( - uds_service: str, uds_dir: Path | None = None, uds_id: str | None = None + uds_fullpath: str | Path | None = None, + uds_service: str | None = None, + uds_dir: Path | None = None, + uds_id: str | None = None, ) -> bool: """Verify that the UDS socket file has been created. @@ -504,11 +532,18 @@ def verify_uds_socket( bool True if the UDS socket file exists, False otherwise. """ - # Generate socket filename with optional ID - uds_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" + if uds_fullpath: + return Path(uds_fullpath).exists() + + else: + if not uds_service: + raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") + + # Generate socket filename with optional ID + uds_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" - # Full path to the UDS socket file - uds_socket_path = determine_uds_folder(uds_dir) / uds_filename + # Full path to the UDS socket file + uds_socket_path = determine_uds_folder(uds_dir) / uds_filename - # Check if the UDS socket file exists - return uds_socket_path.exists() + # Check if the UDS socket file exists + return uds_socket_path.exists() diff --git a/src/ansys/speos/core/kernel/grpc/transportoptions.py b/src/ansys/speos/core/kernel/grpc/transportoptions.py index 473db5755..dfab423a1 100644 --- a/src/ansys/speos/core/kernel/grpc/transportoptions.py +++ b/src/ansys/speos/core/kernel/grpc/transportoptions.py @@ -46,11 +46,13 @@ class TransportMode(enum.Enum): class UDSOptions: """Options for UDS transport mode.""" + uds_fullpath: str | Path | None = None uds_dir: str | Path | None = None uds_id: str | None = None def _to_cyberchannel_kwargs(self): return { + "uds_fullpath": self.uds_fullpath, "uds_dir": self.uds_dir, "uds_id": self.uds_id, "uds_service": "ansys_tools_filetransfer", @@ -132,7 +134,8 @@ def __init__( if options is None: if mode != TransportMode.UDS: raise RuntimeError("TransportOptions must be provided for modes other than UDS.") - # The default cannot be set in the constructor signature since '_get_uds_dir_default' may raise. + # The default cannot be set in the constructor signature + # since '_get_uds_dir_default' may raise. options = UDSOptions() if mode == TransportMode.UDS: @@ -163,7 +166,5 @@ def _to_cyberchannel_kwargs(self): } def create_channel(self, grpc_options): - """ - Create a gRPC channel based on the transport options. - """ + """Create a gRPC channel based on the transport options.""" return create_channel(**self._to_cyberchannel_kwargs(), grpc_options=grpc_options) diff --git a/src/ansys/speos/core/kernel/job.py b/src/ansys/speos/core/kernel/job.py index 4f18a938f..ac50f4cc2 100644 --- a/src/ansys/speos/core/kernel/job.py +++ b/src/ansys/speos/core/kernel/job.py @@ -22,6 +22,9 @@ """Provides a wrapped abstraction of the gRPC proto API definition and stubs.""" +from pathlib import Path +import tempfile +import time from typing import Iterator, List from ansys.api.speos.job.v2 import job_pb2 as messages, job_pb2_grpc as service @@ -35,6 +38,24 @@ ProtoJob.__str__ = lambda self: protobuf_message_to_str(self) +def _is_uds_channel(channel): + target = channel._channel.target().decode() + return target.startswith("unix:") + + +def _list_files_newer_than(folder, timestamp): + """ + Return files in `folder` newer than the given timestamp. + + :param folder: Path to the folder + :param timestamp: Reference timestamp (seconds since epoch) + :return: List of file names newer than timestamp + """ + files = [f for f in folder.iterdir() if f.is_file() and f.stat().st_mtime > timestamp] + + return files + + class JobLink(CrudItem): """Link object for job in database. @@ -49,6 +70,8 @@ class JobLink(CrudItem): def __init__(self, db, key: str): super().__init__(db, key) self._actions_stub = db._actions_stub + self._is_uds = db._is_uds + self._timestamp_start = None def __str__(self) -> str: """Return the string representation of the Job.""" @@ -92,6 +115,7 @@ def get_state(self) -> messages.GetState_Response: def start(self) -> None: """Start the job.""" + self._timestamp_start = time.time() self._actions_stub.Start(messages.Start_Request(guid=self.key)) def stop(self) -> None: @@ -118,7 +142,18 @@ def get_results(self) -> messages.GetResults_Response: ansys.api.speos.job.v2.job_pb2.GetResults_Response Results of the job. """ - return self._actions_stub.GetResults(messages.GetResults_Request(guid=self.key)) + if self._is_uds and self._timestamp_start is not None: + # Get results manually due to bug in uds GetResults implementation + results_folder = Path(tempfile.gettempdir()).joinpath("jobs", self.key) + files = _list_files_newer_than(results_folder, self._timestamp_start) + results = [] + for f in files: + r = messages.Result(path=str(f)) + results.append(r) + + return messages.GetResults_Response(results=results) + else: + return self._actions_stub.GetResults(messages.GetResults_Request(guid=self.key)) def get_progress_status(self) -> messages.GetProgressStatus_Response: """ @@ -170,6 +205,7 @@ class JobStub(CrudStub): def __init__(self, channel): super().__init__(stub=service.JobsManagerStub(channel=channel)) self._actions_stub = service.JobActionsStub(channel=channel) + self._is_uds = _is_uds_channel(channel) def create(self, message: ProtoJob) -> JobLink: """Create a new entry. diff --git a/tests/conftest.py b/tests/conftest.py index 1214342d4..84145118f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,7 @@ # Check if server is on docker IS_DOCKER = config.get("SpeosServerOnDocker") +DOCKER_CONTAINER_NAME = "" if IS_DOCKER: DOCKER_CONTAINER_NAME = config.get("SpeosContainerName") SERVER_PORT = config.get("SpeosServerPort") diff --git a/tests/core/test_simulation.py b/tests/core/test_simulation.py index 567fd941c..576d61e09 100644 --- a/tests/core/test_simulation.py +++ b/tests/core/test_simulation.py @@ -23,6 +23,7 @@ """Test basic using simulation.""" from pathlib import Path +import platform from ansys.api.speos.simulation.v1 import simulation_template_pb2 import pytest @@ -787,7 +788,9 @@ def test_export(speos: Speos): remove_file(str(Path(test_path) / "export_test")) -@pytest.mark.skipif(IS_DOCKER, reason="COM API is only available locally") +@pytest.mark.skipif( + IS_DOCKER or platform.system() == "Linux", reason="COM API is only available locally on Windows" +) @pytest.mark.supported_speos_versions(min=252) def test_export_vtp(speos: Speos): """Test export of xm3 and xmp as vtp files.""" diff --git a/tests/kernel/test_client.py b/tests/kernel/test_client.py index 7a1f18d20..ae28805fe 100644 --- a/tests/kernel/test_client.py +++ b/tests/kernel/test_client.py @@ -22,6 +22,8 @@ """Test basic client connection.""" +import platform + from ansys.speos.core.kernel.client import ( SpeosClient, default_docker_channel, @@ -38,7 +40,10 @@ def test_client_init(speos: Speos): def test_client_through_channel(): """Test the instantiation of a client from a gRPC channel.""" - target = "dns:///localhost:" + str(SERVER_PORT) + if platform.system() == "Linux" and not IS_DOCKER: + target = "unix:/tmp/speosrpc_sock_" + str(SERVER_PORT) + else: + target = "dns:///localhost:" + str(SERVER_PORT) if IS_DOCKER: channel = default_docker_channel(port=SERVER_PORT) else: From e002f9a78d31522c9d24c586419709fd57e5d225 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Wed, 26 Nov 2025 09:57:31 +0100 Subject: [PATCH 04/16] fix --- src/ansys/speos/core/kernel/client.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 1ba345494..083f78460 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -207,9 +207,8 @@ def __init__( wait_until_healthy(self._channel, timeout) # once connection with the client is established, create a logger - self._target = self._channel._channel.target().decode() self._log = LOGGER.add_instance_logger( - name=self._target, client_instance=self, level=logging_level + name=self.target(), client_instance=self, level=logging_level ) if logging_file: if isinstance(logging_file, Path): @@ -246,7 +245,7 @@ def healthy(self) -> bool: if self._closed: return False try: - grpc.channel_ready_future(self.channel).result(timeout=60) + grpc.channel_ready_future(self.channel).result(timeout=10) return True except BaseException: return False @@ -515,7 +514,7 @@ def __repr__(self) -> str: """Represent the client as a string.""" lines = [] lines.append(f"Ansys Speos client ({hex(id(self))})") - lines.append(f" Target: {self._target}") + lines.append(f" Target: {self.target()}") if self._closed: lines.append(" Connection: Closed") elif self.healthy: @@ -545,9 +544,9 @@ def close(self): wait_time = 0 if self._remote_instance: self._remote_instance.delete() - elif self._host in ["localhost", "0.0.0.0", "127.0.0.1"] and self.__speos_exec: # nosec + elif self.__speos_exec and any(d in self.target() for d in ["localhost", "0.0.0.0", "127.0.0.1"]): self.__close_local_speos_rpc_server() - while self.healthy and wait_time < 15: + while self.healthy() and wait_time < 15: time.sleep(1) wait_time += 1 # takes some seconds to close rpc server self._channel.close() @@ -581,7 +580,13 @@ def __close_local_speos_rpc_server(self): """ try: - int(self._port) + # Extract port number at end of target string + target = self.target() + if ":" in target: + port = target.split(":")[-1] + else: + port = target.split("_")[-1] + int(port) except ValueError: raise RuntimeError("The port of the local server is not a valid integer.") if ( @@ -590,5 +595,5 @@ def __close_local_speos_rpc_server(self): ): raise RuntimeError("Unexpected executable path for Speos rpc executable.") - command = [self.__speos_exec, f"-s{self._port}"] + command = [self.__speos_exec, f"-s{port}"] subprocess.run(command, check=True) # nosec From e9ee91fb1a01048e192b56f91f885895a089e3cc Mon Sep 17 00:00:00 2001 From: Elodie Chamblas Date: Wed, 26 Nov 2025 10:07:00 +0100 Subject: [PATCH 05/16] little fix --- src/ansys/speos/core/kernel/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 083f78460..fa81f7b5c 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -544,9 +544,11 @@ def close(self): wait_time = 0 if self._remote_instance: self._remote_instance.delete() - elif self.__speos_exec and any(d in self.target() for d in ["localhost", "0.0.0.0", "127.0.0.1"]): + elif self.__speos_exec and any( + d in self.target() for d in ["localhost", "0.0.0.0", "127.0.0.1"] + ): self.__close_local_speos_rpc_server() - while self.healthy() and wait_time < 15: + while self.healthy and wait_time < 15: time.sleep(1) wait_time += 1 # takes some seconds to close rpc server self._channel.close() @@ -580,7 +582,7 @@ def __close_local_speos_rpc_server(self): """ try: - # Extract port number at end of target string + # Extract port number at end of target string target = self.target() if ":" in target: port = target.split(":")[-1] From f35bfb1f2fdd473f1e713b105f831f7b53b76a35 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Wed, 26 Nov 2025 10:20:38 +0100 Subject: [PATCH 06/16] fix --- src/ansys/speos/core/kernel/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 083f78460..1158508ac 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -546,7 +546,7 @@ def close(self): self._remote_instance.delete() elif self.__speos_exec and any(d in self.target() for d in ["localhost", "0.0.0.0", "127.0.0.1"]): self.__close_local_speos_rpc_server() - while self.healthy() and wait_time < 15: + while self.healthy and wait_time < 15: time.sleep(1) wait_time += 1 # takes some seconds to close rpc server self._channel.close() From 6bad5d659982a6eab4679184e885db03e2b41d39 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Wed, 26 Nov 2025 10:27:01 +0100 Subject: [PATCH 07/16] precommit apply --- examples/workflow/combine-speos.py | 2 +- examples/workflow/open-result.py | 2 +- src/ansys/speos/core/speos.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/workflow/combine-speos.py b/examples/workflow/combine-speos.py index 62b397c13..fecf26455 100644 --- a/examples/workflow/combine-speos.py +++ b/examples/workflow/combine-speos.py @@ -9,7 +9,7 @@ import os from pathlib import Path -from ansys.speos.core import Part, Speos +from ansys.speos.core import Part, Speos, launcher from ansys.speos.core.kernel.client import ( default_docker_channel, ) diff --git a/examples/workflow/open-result.py b/examples/workflow/open-result.py index 53ba263cc..8d461840e 100644 --- a/examples/workflow/open-result.py +++ b/examples/workflow/open-result.py @@ -10,7 +10,7 @@ import os from pathlib import Path -from ansys.speos.core import Project, Speos +from ansys.speos.core import Project, Speos, launcher from ansys.speos.core.kernel.client import ( default_docker_channel, ) diff --git a/src/ansys/speos/core/speos.py b/src/ansys/speos/core/speos.py index 53a10bc3f..1e36358dc 100644 --- a/src/ansys/speos/core/speos.py +++ b/src/ansys/speos/core/speos.py @@ -48,7 +48,8 @@ class Speos: ``ansys.speos.core.kernel.client.LATEST_VERSION``. channel : grpc.Channel, optional gRPC channel for server communication. - Can be created with ``ansys.speos.core.kernel.grpc.transportoptions`` and ``ansys.speos.core.kernel.grpc.cyberchannel`` + Can be created with ``ansys.speos.core.kernel.grpc.transportoptions`` + and ``ansys.speos.core.kernel.grpc.cyberchannel`` By default, ``None``. remote_instance : ansys.platform.instancemanagement.Instance The corresponding remote instance when the Speos Service From cb69209a360da2f7b34cc3de82909a88a332e94f Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 26 Nov 2025 10:54:33 +0000 Subject: [PATCH 08/16] chore: adding changelog file 783.added.md [dependabot-skip] --- doc/changelog.d/783.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/783.added.md diff --git a/doc/changelog.d/783.added.md b/doc/changelog.d/783.added.md new file mode 100644 index 000000000..1ad045a12 --- /dev/null +++ b/doc/changelog.d/783.added.md @@ -0,0 +1 @@ +Use gRPC secure channel From 0b418cfc9de2066fba6fdfc90a3ce67ddfba8528 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:14:14 +0000 Subject: [PATCH 09/16] chore: adding changelog file 783.added.md [dependabot-skip] --- doc/changelog.d/783.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/783.added.md b/doc/changelog.d/783.added.md index 1ad045a12..27de508e9 100644 --- a/doc/changelog.d/783.added.md +++ b/doc/changelog.d/783.added.md @@ -1 +1 @@ -Use gRPC secure channel +Use grpc secure channel From a2b3e338f40685af6efe5ae9ecdeb1e9807e5de8 Mon Sep 17 00:00:00 2001 From: jmadec Date: Wed, 26 Nov 2025 15:14:26 +0100 Subject: [PATCH 10/16] add default transport options for server launch local --- src/ansys/speos/core/launcher.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ansys/speos/core/launcher.py b/src/ansys/speos/core/launcher.py index 37ba8f840..fb06a6155 100644 --- a/src/ansys/speos/core/launcher.py +++ b/src/ansys/speos/core/launcher.py @@ -177,7 +177,18 @@ def launch_local_speos_rpc_server( logfile = logfile_loc / "speos_rpc.log" if not logfile_loc.exists(): logfile_loc.mkdir() - command = [str(speos_exec), f"-p{port}", f"-m{server_message_size}", f"-l{str(logfile)}"] + + if os.name == "nt": + transport_option = "--transport_wnua" + else: + transport_option = "--transport_uds" + command = [ + str(speos_exec), + f"-p{port}", + f"-m{server_message_size}", + f"-l{str(logfile)}", + transport_option, + ] out, stdout_file = tempfile.mkstemp(suffix="speos_out.txt", dir=logfile_loc) err, stderr_file = tempfile.mkstemp(suffix="speos_err.txt", dir=logfile_loc) From 4c455a33d1780fbdd09bd307cb0c6a817079a6d7 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Thu, 27 Nov 2025 10:09:26 +0100 Subject: [PATCH 11/16] fix --- .github/dependabot.yml | 2 +- README.rst | 3 --- pyproject.toml | 2 +- src/ansys/speos/core/generic/general_methods.py | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 240e510fc..791328790 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,7 +20,7 @@ updates: groups: core-deps: patterns: - - "ansys-tools-path" + - "ansys-tools-common" - "numpy" - "comtypes" grpc-deps: diff --git a/README.rst b/README.rst index a04ae23b3..c0bc9a9e3 100644 --- a/README.rst +++ b/README.rst @@ -181,9 +181,6 @@ Launch unit tests pip install .[tests] pytest -vx -`-e` option allows to install the package in "editable" mode. -Can be very useful during new development and debugging. - Use jupyter notebook ~~~~~~~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index f0c6bf405..fa2cd31c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies=[ "grpcio>=1.50.0,<1.76", "grpcio-health-checking>=1.45.0,<1.68", "ansys-api-speos==0.15.3", - "ansys-tools-path>=0.3.1", + "ansys-tools-common>=0.3.0", "numpy>=1.20.3,<3", "comtypes>=1.4,<1.5; platform_system=='Windows'", ] diff --git a/src/ansys/speos/core/generic/general_methods.py b/src/ansys/speos/core/generic/general_methods.py index 5bc49fd55..05dc23a23 100644 --- a/src/ansys/speos/core/generic/general_methods.py +++ b/src/ansys/speos/core/generic/general_methods.py @@ -34,7 +34,7 @@ from typing import List, Optional, Union, cast import warnings -from ansys.tools.path import get_available_ansys_installations +from ansys.tools.common.path import get_available_ansys_installations import numpy as np from ansys.speos.core.generic.constants import DEFAULT_VERSION From b812460c83f656f4dbd957f32ef867364330c4c6 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Thu, 27 Nov 2025 16:32:05 +0100 Subject: [PATCH 12/16] fix-ci --- .github/workflows/ci_cd.yml | 4 ++-- .github/workflows/nightly.yml | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index c2ca56fae..96227add1 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -102,7 +102,7 @@ jobs: env: ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} run: | - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:2025.2.4.35476 --transport_insecure --host 0.0.0.0 - name: "Run Ansys documentation building action" uses: ansys/actions/doc-build@c2fa7c93f6883114e0e643599431b33d29f0b13f # v10.1.4 with: @@ -165,7 +165,7 @@ jobs: ANSYSLMD_LICENSE_FILE: 1055@${{ secrets.LICENSE_SERVER }} shell: bash run: | - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:dev --transport_insecure -m 25000000 --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:2025.2.4.35476 --transport_insecure -m 25000000 --host 0.0.0.0 - name: Run pytest shell: bash diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 47024190f..69c78d9ac 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] - speos-version: ["251", "252", "dev"] + speos-version: ["251", "252", "dev", "2025.2.4.35476"] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -60,9 +60,13 @@ jobs: speos_version=${{ matrix.speos-version }} if [[ "$speos_version" == "251" ]]; then - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version --transport_insecure -m 25000000 - else - docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version --transport_insecure -m 25000000 --host 0.0.0.0 + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 + elif [[ "$speos_version" == "252" ]]; then + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 --host 0.0.0.0 + elif [[ "$speos_version" == "dev" ]]; then + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 --host 0.0.0.0 + elif [[ "$speos_version" == "2025.2.4.35476" ]]; then + docker run --detach --name speos-rpc -p 127.0.0.1:50098:50098 -e SPEOS_LOG_LEVEL=2 -e ANSYSLMD_LICENSE_FILE=${{ env.ANSYSLMD_LICENSE_FILE }} -v "${{ github.workspace }}/tests/assets:/app/assets" --entrypoint /app/SpeosRPC_Server.x ghcr.io/ansys/speos-rpc:$speos_version -m 25000000 --transport_insecure--host 0.0.0.0 fi - name: Run pytest for Speos ${{ matrix.speos-version }} From 76876a280e4d282c38a35d8bfaebc01a53d3db97 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Fri, 28 Nov 2025 09:19:13 +0100 Subject: [PATCH 13/16] remove-ut-test_coverage_launcher_speosdocker --- tests/core/test_launcher.py | 43 +------------------------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/tests/core/test_launcher.py b/tests/core/test_launcher.py index 4d749697e..20ef7c44b 100644 --- a/tests/core/test_launcher.py +++ b/tests/core/test_launcher.py @@ -22,18 +22,12 @@ """Test launcher.""" -import os -from pathlib import Path -import subprocess -import tempfile -from unittest.mock import patch - import psutil import pytest from ansys.speos.core.generic.constants import DEFAULT_VERSION from ansys.speos.core.launcher import launch_local_speos_rpc_server, retrieve_speos_install_dir -from tests.conftest import IS_DOCKER, IS_WINDOWS, SERVER_PORT +from tests.conftest import IS_WINDOWS, SERVER_PORT # Check local installation try: @@ -65,38 +59,3 @@ def test_local_session(*args): p_list = [p.name() for p in psutil.process_iter()] running = p_list.count(name) > nb_process assert running is not closed - - -@patch.object(subprocess, "Popen") -@patch.object(subprocess, "run") -@pytest.mark.skipif(not IS_DOCKER, reason="requires Speos server to be installed locally") -def test_coverage_launcher_speosdocker(*args): - """Test local session launch on remote server to improve coverage.""" - port = SERVER_PORT - tmp_file = tempfile.gettempdir() - if IS_WINDOWS: - name = "SpeosRPC_Server.exe" - else: - name = "SpeosRPC_Server.x" - speos_loc = Path(tmp_file) / "Optical Products" / "SPEOS_RPC" / name - speos_loc.parent.parent.mkdir(exist_ok=True) - speos_loc.parent.mkdir(exist_ok=True) - if not speos_loc.exists(): - f = speos_loc.open("w") - f.write("speos_test_file") - f.close() - os.environ["AWP_ROOT{}".format(DEFAULT_VERSION)] = tmp_file - test_speos = launch_local_speos_rpc_server(port=port) - assert True is test_speos.client.healthy - assert True is test_speos.close() - assert False is test_speos.client.healthy - test_speos = launch_local_speos_rpc_server( - port=port, speos_rpc_path=speos_loc, logfile_loc=tmp_file - ) - assert True is test_speos.client.healthy - test_speos.client._closed = True - assert True is test_speos.close() - assert False is test_speos.client.healthy - speos_loc.unlink() - speos_loc.parent.rmdir() - speos_loc.parent.parent.rmdir() From 76a080c54bc9f94e280e3c4879c38b7c8170036d Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Fri, 28 Nov 2025 11:59:54 +0100 Subject: [PATCH 14/16] fix bandit --- src/ansys/speos/core/kernel/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index fa81f7b5c..8c8d8b6ec 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -26,6 +26,7 @@ import os from pathlib import Path import subprocess # nosec +import tempfile import time from typing import TYPE_CHECKING, List, Optional, Union @@ -136,11 +137,10 @@ def default_local_channel( mode=TransportMode.WNUA, options=WNUAOptions(host=DEFAULT_HOST, port=port) ) else: + sock_file = Path(tempfile.gettempdir()) / f"speosrpc_sock_{port}" transport = TransportOptions( mode=TransportMode.UDS, - options=UDSOptions( - uds_fullpath=f"/tmp/speosrpc_sock_{port}", uds_id="ansys_tools_filetransfer" - ), + options=UDSOptions(uds_fullpath=str(sock_file), uds_id="ansys_tools_filetransfer"), ) return transport.create_channel( grpc_options=[("grpc.max_receive_message_length", message_size)] @@ -544,9 +544,7 @@ def close(self): wait_time = 0 if self._remote_instance: self._remote_instance.delete() - elif self.__speos_exec and any( - d in self.target() for d in ["localhost", "0.0.0.0", "127.0.0.1"] - ): + elif self.__speos_exec: self.__close_local_speos_rpc_server() while self.healthy and wait_time < 15: time.sleep(1) From 90a2c7a0eaa7299cf8db3c1bc27b9bd1bf4f55c2 Mon Sep 17 00:00:00 2001 From: "etienne.arnal" Date: Fri, 28 Nov 2025 13:49:38 +0100 Subject: [PATCH 15/16] fix --- src/ansys/speos/core/kernel/client.py | 3 +- .../speos/core/kernel/grpc/cyberchannel.py | 549 ------------------ .../core/kernel/grpc/transportoptions.py | 9 +- 3 files changed, 6 insertions(+), 555 deletions(-) delete mode 100644 src/ansys/speos/core/kernel/grpc/cyberchannel.py diff --git a/src/ansys/speos/core/kernel/client.py b/src/ansys/speos/core/kernel/client.py index 8c8d8b6ec..f67a80449 100644 --- a/src/ansys/speos/core/kernel/client.py +++ b/src/ansys/speos/core/kernel/client.py @@ -139,8 +139,7 @@ def default_local_channel( else: sock_file = Path(tempfile.gettempdir()) / f"speosrpc_sock_{port}" transport = TransportOptions( - mode=TransportMode.UDS, - options=UDSOptions(uds_fullpath=str(sock_file), uds_id="ansys_tools_filetransfer"), + mode=TransportMode.UDS, options=UDSOptions(uds_fullpath=str(sock_file)) ) return transport.create_channel( grpc_options=[("grpc.max_receive_message_length", message_size)] diff --git a/src/ansys/speos/core/kernel/grpc/cyberchannel.py b/src/ansys/speos/core/kernel/grpc/cyberchannel.py deleted file mode 100644 index 1761a512c..000000000 --- a/src/ansys/speos/core/kernel/grpc/cyberchannel.py +++ /dev/null @@ -1,549 +0,0 @@ -# Copyright (C) 2021 - 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 to create gRPC channels with different transport modes. - -This module provides functions to create gRPC channels based on the specified -transport mode, including insecure, Unix Domain Sockets (UDS), Windows Named User -Authentication (WNUA), and Mutual TLS (mTLS). - -Example -------- - -.. code-block:: python - - channel = create_channel( - host="localhost", - port=50051, - transport_mode="mtls", - certs_dir="path/to/certs", - grpc_options=[("grpc.max_receive_message_length", 50 * 1024 * 1024)], - ) - stub = hello_pb2_grpc.GreeterStub(channel) - -""" - -# Only the create_channel function is exposed for external use -__all__ = ["create_channel", "verify_transport_mode", "verify_uds_socket"] - -from dataclasses import dataclass -import logging -import os -from pathlib import Path -from typing import cast -from warnings import warn - -try: - import grpc -except ImportError: # pragma: no cover - warn( - "grpc module is not available - " - "reach out to the library maintainers to include it into their dependencies" - ) - - -_IS_WINDOWS = os.name == "nt" -LOOPBACK_HOSTS = ("localhost", "127.0.0.1") - -logger = logging.getLogger(__name__) - - -@dataclass -class CertificateFiles: - cert_file: str | Path | None = None - key_file: str | Path | None = None - ca_file: str | Path | None = None - - -def create_channel( - transport_mode: str, - host: str | None = None, - port: int | str | None = None, - uds_fullpath: str | Path | None = None, - uds_service: str | None = None, - uds_dir: str | Path | None = None, - uds_id: str | None = None, - certs_dir: str | Path | None = None, - cert_files: CertificateFiles | None = None, - grpc_options: list[tuple[str, object]] | None = None, -) -> grpc.Channel: - """Create a gRPC channel based on the transport mode. - - Parameters - ---------- - transport_mode : str - Transport mode selected by the user. - Options are: "insecure", "uds", "wnua", "mtls" - host : str | None - Hostname or IP address of the server. - By default `None` - however, if not using UDS transport mode, - it will be requested. - port : int | str | None - Port in which the server is running. - By default `None` - however, if not using UDS transport mode, - it will be requested. - uds_fullpath : str | Path | None - Full path to the UDS socket file. - By default `None` and thus it will use the `uds_service`, `uds_dir` and `uds_id` parameters. - uds_service : str | None - Optional service name for the UDS socket. - By default `None` - however, if UDS is selected, it will - be requested. - uds_dir : str | Path | None - Directory to use for Unix Domain Sockets (UDS) transport mode. - By default `None` and thus it will use the "~/.conn" folder. - uds_id : str | None - Optional ID to use for the UDS socket filename. - By default `None` and thus it will use ".sock". - Otherwise, the socket filename will be "-.sock". - certs_dir : str | Path | None - Directory to use for TLS certificates. - By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. - If not found, it will use the "certs" folder assuming it is in the current working - directory. - cert_files: CertificateFiles | None = None - Path to the client certificate file, client key file, and issuing certificate authority. - By default `None`. - If all three file paths are not all provided, use the certs_dir parameter. - grpc_options: list[tuple[str, object]] | None - gRPC channel options to pass when creating the channel. - Each option is a tuple of the form ("option_name", value). - By default `None` and thus no extra options are added. - - Returns - ------- - grpc.Channel - The created gRPC channel - - """ - - def check_host_port(transport_mode, host, port) -> tuple[str, str, str]: - if host is None: - raise ValueError( - f"When using {transport_mode.lower()} transport mode, 'host' must be provided." - ) - if port is None: - raise ValueError( - f"When using {transport_mode.lower()} transport mode, 'port' must be provided." - ) - return transport_mode, host, port - - match transport_mode.lower(): - case "insecure": - transport_mode, host, port = check_host_port(transport_mode, host, port) - return create_insecure_channel(host, port, grpc_options) - case "uds": - return create_uds_channel(uds_fullpath, uds_service, uds_dir, uds_id, grpc_options) - case "wnua": - transport_mode, host, port = check_host_port(transport_mode, host, port) - return create_wnua_channel(host, port, grpc_options) - case "mtls": - transport_mode, host, port = check_host_port(transport_mode, host, port) - return create_mtls_channel(host, port, certs_dir, cert_files, grpc_options) - case _: - raise ValueError( - f"Unknown transport mode: {transport_mode}." - " Valid options are: 'insecure', 'uds', 'wnua', 'mtls'." - ) - - -##################################### TRANSPORT MODE CHANNELS ##################################### - - -def create_insecure_channel( - host: str, port: int | str, grpc_options: list[tuple[str, object]] | None = None -) -> grpc.Channel: - """Create an insecure gRPC channel without TLS. - - Parameters - ---------- - host : str - Hostname or IP address of the server. - port : int | str - Port in which the server is running. - grpc_options: list[tuple[str, object]] | None - gRPC channel options to pass when creating the channel. - Each option is a tuple of the form ("option_name", value). - By default `None` and thus no extra options are added. - - Returns - ------- - grpc.Channel - The created gRPC channel - - """ - target = f"{host}:{port}" - warn( - f"Starting gRPC client without TLS on {target}." - " This is INSECURE. Consider using a secure connection." - ) - logger.info(f"Connecting using INSECURE -> {target}") - return grpc.insecure_channel(target, options=grpc_options) - - -def create_uds_channel( - uds_fullpath: str | Path | None = None, - uds_service: str | None = None, - uds_dir: str | Path | None = None, - uds_id: str | None = None, - grpc_options: list[tuple[str, object]] | None = None, -) -> grpc.Channel: - """Create a gRPC channel using Unix Domain Sockets (UDS). - - Parameters - ---------- - uds_fullpath : str | Path | None - Full path to the UDS socket file. - By default `None` and thus it will use the `uds_service`, `uds_dir` and `uds_id` parameters. - uds_service : str | None - Service name for the UDS socket. - uds_dir : str | Path | None - Directory to use for Unix Domain Sockets (UDS) transport mode. - By default `None` and thus it will use the "~/.conn" folder. - uds_id : str | None - Optional ID to use for the UDS socket filename. - By default `None` and thus it will use ".sock". - Otherwise, the socket filename will be "-.sock". - grpc_options: list[tuple[str, object]] | None - gRPC channel options to pass when creating the channel. - Each option is a tuple of the form ("option_name", value). - By default `None` and thus only the default authority option is added. - - Returns - ------- - grpc.Channel - The created gRPC channel - - """ - if not is_uds_supported(): - raise RuntimeError( - "Unix Domain Sockets are not supported on this platform or gRPC version." - ) - - if uds_fullpath: - # Ensure the parent directory exists - Path(uds_fullpath).parent.mkdir(parents=True, exist_ok=True) - target = f"unix:{uds_fullpath}" - else: - if not uds_service: - raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") - - # Determine UDS folder - uds_folder = determine_uds_folder(uds_dir) - - # Make sure the folder exists - uds_folder.mkdir(parents=True, exist_ok=True) - - # Generate socket filename with optional ID - socket_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" - target = f"unix:{uds_folder / socket_filename}" - - # Set default authority to "localhost" for UDS connection - # This is needed to avoid issues with some gRPC implementations, - # see https://github.com/grpc/grpc/issues/34305 - options: list[tuple[str, object]] = [ - ("grpc.default_authority", "localhost"), - ] - if grpc_options: - options.extend(grpc_options) - logger.info(f"Connecting using UDS -> {target}") - - return grpc.insecure_channel(target, options=options) - - -def create_wnua_channel( - host: str, - port: int | str, - grpc_options: list[tuple[str, object]] | None = None, -) -> grpc.Channel: - """Create a gRPC channel using Windows Named User Authentication (WNUA). - - Parameters - ---------- - host : str - Hostname or IP address of the server. - port : int | str - Port in which the server is running. - grpc_options: list[tuple[str, object]] | None - gRPC channel options to pass when creating the channel. - Each option is a tuple of the form ("option_name", value). - By default `None` and thus only the default authority option is added. - - Returns - ------- - grpc.Channel - The created gRPC channel - - """ - if not _IS_WINDOWS: - raise ValueError("Windows Named User Authentication (WNUA) is only supported on Windows.") - if host not in LOOPBACK_HOSTS: - raise ValueError("Remote host connections are not supported with WNUA.") - - target = f"{host}:{port}" - # Set default authority to "localhost" for WNUA connection - # This is needed to avoid issues with some gRPC implementations, - # see https://github.com/grpc/grpc/issues/34305 - options: list[tuple[str, object]] = [ - ("grpc.default_authority", "localhost"), - ] - if grpc_options: - options.extend(grpc_options) - logger.info(f"Connecting using WNUA -> {target}") - return grpc.insecure_channel(target, options=options) - - -def create_mtls_channel( - host: str, - port: int | str, - certs_dir: str | Path | None = None, - cert_files: CertificateFiles | None = None, - grpc_options: list[tuple[str, object]] | None = None, -) -> grpc.Channel: - """Create a gRPC channel using Mutual TLS (mTLS). - - Parameters - ---------- - host : str - Hostname or IP address of the server. - port : int | str - Port in which the server is running. - certs_dir : str | Path | None - Directory to use for TLS certificates. - By default `None` and thus search for the "ANSYS_GRPC_CERTIFICATES" environment variable. - If not found, it will use the "certs" folder assuming it is in the current working - directory. - cert_files: CertificateFiles | None - Path to the client certificate file, client key file, and issuing certificate authority. - By default `None`. - If all three file paths are not all provided, use the certs_dir parameter. - grpc_options: list[tuple[str, object]] | None - gRPC channel options to pass when creating the channel. - Each option is a tuple of the form ("option_name", value). - By default `None` and thus no extra options are added. - - Returns - ------- - grpc.Channel - The created gRPC channel - - """ - certs_folder = None - if ( - cert_files is not None - and cert_files.cert_file is not None - and cert_files.key_file is not None - and cert_files.ca_file is not None - ): - cert_file = Path(cert_files.cert_file).resolve() - key_file = Path(cert_files.key_file).resolve() - ca_file = Path(cert_files.ca_file).resolve() - else: - # Determine certificates folder - if certs_dir: - certs_folder = Path(certs_dir) - elif os.environ.get("ANSYS_GRPC_CERTIFICATES"): - certs_folder = Path(cast(str, os.environ.get("ANSYS_GRPC_CERTIFICATES"))) - else: - certs_folder = Path("certs") - ca_file = certs_folder / "ca.crt" - cert_file = certs_folder / "client.crt" - key_file = certs_folder / "client.key" - - # Load certificates - try: - with (ca_file).open("rb") as f: - trusted_certs = f.read() - with (cert_file).open("rb") as f: - client_cert = f.read() - with (key_file).open("rb") as f: - client_key = f.read() - except FileNotFoundError as e: - error_message = f"Certificate file not found: {e.filename}. " - if certs_folder is not None: - error_message += ( - f"Ensure that the certificates are present in the '{certs_folder}' folder or " - "set the 'ANSYS_GRPC_CERTIFICATES' environment variable." - ) - raise FileNotFoundError(error_message) from e - - # Create SSL credentials - credentials = grpc.ssl_channel_credentials( - root_certificates=trusted_certs, private_key=client_key, certificate_chain=client_cert - ) - - target = f"{host}:{port}" - logger.info(f"Connecting using mTLS -> {target}") - return grpc.secure_channel(target, credentials, options=grpc_options) - - -######################################## HELPER FUNCTIONS ######################################## - - -def version_tuple(version_str: str) -> tuple[int, ...]: - """Convert a version string into a tuple of integers for comparison. - - Parameters - ---------- - version_str : str - The version string to convert. - - Returns - ------- - tuple[int, ...] - A tuple of integers representing the version. - - """ - return tuple(int(x) for x in version_str.split(".")) - - -def check_grpc_version(): - """Check if the installed gRPC version meets the minimum requirement. - - Returns - ------- - bool - True if the gRPC version is sufficient, False otherwise. - - """ - min_version = "1.63.0" - current_version = grpc.__version__ - - try: - return version_tuple(current_version) >= version_tuple(min_version) - except ValueError: - logger.warning("Unable to parse gRPC version.") - return False - - -def is_uds_supported(): - """Check if Unix Domain Sockets (UDS) are supported on the current platform. - - Returns - ------- - bool - True if UDS is supported, False otherwise. - - """ - is_grpc_version_ok = check_grpc_version() - return is_grpc_version_ok if _IS_WINDOWS else True - - -def determine_uds_folder(uds_dir: str | Path | None = None) -> Path: - """Determine the directory to use for Unix Domain Sockets (UDS). - - Parameters - ---------- - uds_dir : str | Path | None - Directory to use for Unix Domain Sockets (UDS) transport mode. - By default `None` and thus it will use the "~/.conn" folder. - - Returns - ------- - Path - The path to the UDS directory. - - """ - # If no directory is provided, use default based on OS - if uds_dir: - return uds_dir if isinstance(uds_dir, Path) else Path(uds_dir) - else: - if _IS_WINDOWS: - return Path(os.environ["USERPROFILE"]) / ".conn" - else: - # Linux/POSIX - return Path(os.environ["HOME"], ".conn") - - -def verify_transport_mode(transport_mode: str, mode: str | None = None) -> None: - """Verify that the provided transport mode is valid. - - Parameters - ---------- - transport_mode : str - The transport mode to verify. - mode : str | None - Can be one of "all", "local" or "remote" to restrict the valid transport modes. - By default `None` and thus all transport modes are accepted. - - Raises - ------ - ValueError - If the transport mode is not one of the accepted values. - - """ - if mode == "local": - valid_modes = {"insecure", "uds", "wnua"} - elif mode == "remote": - valid_modes = {"insecure", "mtls"} - elif mode == "all" or mode is None: - valid_modes = {"insecure", "uds", "wnua", "mtls"} - else: - raise ValueError(f"Invalid mode: {mode}. Valid options are: 'all', 'local', 'remote'.") - - if transport_mode.lower() not in valid_modes: - raise ValueError( - f"Invalid transport mode: {transport_mode}. " - f" Valid options are: {', '.join(valid_modes)}." - ) - - -def verify_uds_socket( - uds_fullpath: str | Path | None = None, - uds_service: str | None = None, - uds_dir: Path | None = None, - uds_id: str | None = None, -) -> bool: - """Verify that the UDS socket file has been created. - - Parameters - ---------- - uds_service : str - Service name for the UDS socket. - uds_dir : Path | None - Directory where the UDS socket file is expected to be (optional). - By default `None` and thus it will use the "~/.conn" folder. - uds_id : str | None - Unique identifier for the UDS socket (optional). - By default `None` and thus it will use ".sock". - Otherwise, the socket filename will be "-.sock". - - Returns - ------- - bool - True if the UDS socket file exists, False otherwise. - """ - if uds_fullpath: - return Path(uds_fullpath).exists() - - else: - if not uds_service: - raise ValueError("When using UDS transport mode, 'uds_service' must be provided.") - - # Generate socket filename with optional ID - uds_filename = f"{uds_service}-{uds_id}.sock" if uds_id else f"{uds_service}.sock" - - # Full path to the UDS socket file - uds_socket_path = determine_uds_folder(uds_dir) / uds_filename - - # Check if the UDS socket file exists - return uds_socket_path.exists() diff --git a/src/ansys/speos/core/kernel/grpc/transportoptions.py b/src/ansys/speos/core/kernel/grpc/transportoptions.py index dfab423a1..aeb779a35 100644 --- a/src/ansys/speos/core/kernel/grpc/transportoptions.py +++ b/src/ansys/speos/core/kernel/grpc/transportoptions.py @@ -30,7 +30,7 @@ import enum from pathlib import Path -from .cyberchannel import create_channel +from ansys.tools.common.cyberchannel import create_channel class TransportMode(enum.Enum): @@ -46,16 +46,17 @@ class TransportMode(enum.Enum): class UDSOptions: """Options for UDS transport mode.""" - uds_fullpath: str | Path | None = None + uds_service: str | None = None uds_dir: str | Path | None = None uds_id: str | None = None + uds_fullpath: str | Path | None = None def _to_cyberchannel_kwargs(self): return { - "uds_fullpath": self.uds_fullpath, + "uds_service": self.uds_service, "uds_dir": self.uds_dir, "uds_id": self.uds_id, - "uds_service": "ansys_tools_filetransfer", + "uds_fullpath": self.uds_fullpath, } From 988ec627e631b3766443638d3da0e3ced143851d Mon Sep 17 00:00:00 2001 From: plu Date: Sat, 29 Nov 2025 14:33:09 +0000 Subject: [PATCH 16/16] add examples to Speos initialization --- src/ansys/speos/core/launcher.py | 8 ++++++-- src/ansys/speos/core/speos.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/ansys/speos/core/launcher.py b/src/ansys/speos/core/launcher.py index fb06a6155..3a1b8280e 100644 --- a/src/ansys/speos/core/launcher.py +++ b/src/ansys/speos/core/launcher.py @@ -106,7 +106,7 @@ def launch_remote_speos( def launch_local_speos_rpc_server( - version: str = DEFAULT_VERSION, + version: Union[str, int] = DEFAULT_VERSION, port: Union[str, int] = DEFAULT_PORT, server_message_size: int = MAX_SERVER_MESSAGE_LENGTH, client_message_size: int = MAX_CLIENT_MESSAGE_SIZE, @@ -151,6 +151,10 @@ def launch_local_speos_rpc_server( ansys.speos.core.speos.Speos An instance of the Speos Service. """ + try: + int(version) + except ValueError: + raise ValueError("The version is not a valid integer.") try: int(port) except ValueError: @@ -160,7 +164,7 @@ def launch_local_speos_rpc_server( except ValueError: raise ValueError("The server message size is not a valid integer.") - speos_rpc_path = retrieve_speos_install_dir(speos_rpc_path, version) + speos_rpc_path = retrieve_speos_install_dir(speos_rpc_path, str(version)) if os.name == "nt": speos_exec = speos_rpc_path / "SpeosRPC_Server.exe" else: diff --git a/src/ansys/speos/core/speos.py b/src/ansys/speos/core/speos.py index 1e36358dc..f6b14e6f2 100644 --- a/src/ansys/speos/core/speos.py +++ b/src/ansys/speos/core/speos.py @@ -63,6 +63,30 @@ class Speos: By default, ``INFO``. logging_file : Optional[str, Path] The file to output the log, if requested. By default, ``None``. + + Examples + -------- + >>> # Create default channel (to use when server was started with `SpeosRPC_Server.exe`) + >>> speos = Speos() + >>> # which is also equivalent to: + >>> from ansys.speos.core.kernel.client import default_local_channel + >>> speos = Speos(channel=default_local_channel()) + >>> # Create channel with custom port and message size: + >>> # use when server was started with `SpeosRPC_Server.exe --port 53123` + >>> speos = Speos(channel=default_local_channel(port=53123, message_size=20000000)) + >>> # Create insecure channel, to use when server was started with: + >>> # `SpeosRPC_Server.exe --transport-insecure` + >>> from ansys.speos.core.kernel.grpc.transportoptions import ( + ... TransportOptions, + ... InsecureOptions, + ... TransportMode, + ... ) + >>> transport = TransportOptions( + ... mode=TransportMode.INSECURE, + ... options=InsecureOptions(host=host, port=port, allow_remote_host=True), + ... ) + >>> grpc_options = [("grpc.max_receive_message_length", message_size)] + >>> speos = Speos(channel=transport.create_channel(grpc_options)) """ def __init__(