From 52aa7f8ab799bdd058fcd352c01602c42550af86 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 26 Nov 2025 08:19:56 +0100 Subject: [PATCH 1/5] Launcher: implement gRPC transport option handling --- .../user_guide/launcher/plugin_creation.rst | 24 +-- pyproject.toml | 2 +- src/ansys/tools/common/launcher/__init__.py | 9 +- src/ansys/tools/common/launcher/_cli.py | 11 +- src/ansys/tools/common/launcher/_plugins.py | 2 +- .../tools/common/launcher/grpc_transport.py | 179 ++++++++++++++++++ .../tools/common/launcher/product_instance.py | 122 ++++++------ .../integration/simple_test_launcher.py | 33 +++- .../integration/simple_test_server.py | 25 ++- .../integration/test_simple_launcher.py | 14 +- .../pkg_with_entrypoint/pyproject.toml | 2 +- 11 files changed, 328 insertions(+), 95 deletions(-) create mode 100644 src/ansys/tools/common/launcher/grpc_transport.py diff --git a/doc/source/user_guide/launcher/plugin_creation.rst b/doc/source/user_guide/launcher/plugin_creation.rst index ecfda1f8..86093abe 100644 --- a/doc/source/user_guide/launcher/plugin_creation.rst +++ b/doc/source/user_guide/launcher/plugin_creation.rst @@ -43,9 +43,9 @@ there's quite a lot going on in this code, descriptions of each part are provide from typing import Optional import subprocess - from ansys.tools.local_product_launcher.interface import LauncherProtocol, ServerType - from ansys.tools.local_product_launcher.helpers.ports import find_free_ports - from ansys.tools.local_product_launcher.helpers.grpc import check_grpc_health + from ansys.tools.common.launcher.interface import LauncherProtocol, ServerType + from ansys.tools.common.launcher.helpers.ports import find_free_ports + from ansys.tools.common.launcher.helpers.grpc import check_grpc_health class DirectLauncher(LauncherProtocol[LauncherConfig]): @@ -190,7 +190,7 @@ You define the entrypoint in your package's build configuration. The exact synta .. code:: toml - [project.entry-points."ansys.tools.local_product_launcher.launcher"] + [project.entry-points."ansys.tools.common.launcher"] "ACP.direct" = ":DirectLauncher" In a ``setup.cfg`` file: @@ -198,7 +198,7 @@ You define the entrypoint in your package's build configuration. The exact synta .. code:: cfg [options.entry_points] - ansys.tools.local_product_launcher.launcher = + ansys.tools.common.launcher = ACP.direct = :DirectLauncher In a ``setup.py`` file: @@ -210,7 +210,7 @@ You define the entrypoint in your package's build configuration. The exact synta setup( # ..., entry_points={ - "ansys.tools.local_product_launcher.launcher": [ + "ansys.tools.common.launcher": [ "ACP.direct = :DirectLauncher" ] } @@ -225,7 +225,7 @@ You define the entrypoint in your package's build configuration. The exact synta .. code:: toml - [project.entry-points."ansys.tools.local_product_launcher.launcher"] + [project.entry-points."ansys.tools.common.launcher"] "ACP.direct" = ":DirectLauncher" For more information, see the `Entry points sections `_ in the Flit documentation. @@ -236,12 +236,12 @@ You define the entrypoint in your package's build configuration. The exact synta .. code:: toml - [tool.poetry.plugins."ansys.tools.local_product_launcher.launcher"] + [tool.poetry.plugins."ansys.tools.common.launcher"] "ACP.direct" = ":DirectLauncher" For more information, see the `plugins `_ in the Poetry documentation. -In all cases, ``ansys.tools.local_product_launcher.launcher`` is an identifier specifying that the entrypoint defines a local product launcher plugin. It must be kept the same. +In all cases, ``ansys.tools.common.launcher`` is an identifier specifying that the entrypoint defines a local product launcher plugin. It must be kept the same. The entrypoint itself has two parts: @@ -275,7 +275,7 @@ the ``binary_path``: from typing import Union from ansys.tools.path import get_available_ansys_installations - from ansys.tools.local_product_launcher.interface import METADATA_KEY_DOC + from ansys.tools.common.launcher.interface import METADATA_KEY_DOC def get_default_binary_path() -> str: @@ -316,7 +316,7 @@ For example, to make ``DirectLauncher`` the fallback for ACP, add this entry poi .. code:: toml - [project.entry-points."ansys.tools.local_product_launcher.launcher"] + [project.entry-points."ansys.tools.common.launcher"] "ACP.__fallback__" = ":DirectLauncher" The fallback launch mode is used with its default configuration. This means that the configuration class must have default values for all its fields. @@ -333,7 +333,7 @@ to ``True`` in the ``metadata`` dictionary: import dataclasses - from ansys.tools.local_product_launcher.interface import METADATA_KEY_NOPROMPT + from ansys.tools.common.launcher.interface import METADATA_KEY_NOPROMPT @dataclasses.dataclass diff --git a/pyproject.toml b/pyproject.toml index 9072742e..f2807fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ urls.Documentation = "https://ansys.tools.docs.pyansys.com/" urls.Issues = "https://github.com/ansys/ansys-tools-common/issues" urls.Releases = "https://github.com/ansys/ansys-tools-common/releases/" urls.Source = "https://github.com/ansys/ansys-tools-common" -scripts.ansys-launcher = "ansys.tools.local_product_launcher._cli:cli" +scripts.ansys-launcher = "ansys.tools.common.launcher._cli:cli" scripts.save-ansys-path = "ansys.tools.common.path.save:cli" [dependency-groups] diff --git a/src/ansys/tools/common/launcher/__init__.py b/src/ansys/tools/common/launcher/__init__.py index 7faade57..fddcb35d 100644 --- a/src/ansys/tools/common/launcher/__init__.py +++ b/src/ansys/tools/common/launcher/__init__.py @@ -22,13 +22,14 @@ """Local product launcher.""" -from . import config, helpers, interface, product_instance +from . import config, grpc_transport, helpers, interface, product_instance from .launch import launch_product __all__ = [ - "interface", - "helpers", "config", - "product_instance", + "grpc_transport", + "helpers", + "interface", "launch_product", + "product_instance", ] diff --git a/src/ansys/tools/common/launcher/_cli.py b/src/ansys/tools/common/launcher/_cli.py index e62b1d6e..53730e9f 100644 --- a/src/ansys/tools/common/launcher/_cli.py +++ b/src/ansys/tools/common/launcher/_cli.py @@ -253,12 +253,15 @@ def show_config() -> None: try: config = get_config_for(product_name=product_name, launch_mode=launch_mode) click.echo(" No configuration is set (uses defaults).") - except KeyError: + except (KeyError, RuntimeError): click.echo(" No configuration is set (no defaults available).") continue - config = get_config_for(product_name=product_name, launch_mode=launch_mode) - for field in dataclasses.fields(config): - click.echo(f" {field.name}: {getattr(config, field.name)}") + try: + config = get_config_for(product_name=product_name, launch_mode=launch_mode) + for field in dataclasses.fields(config): + click.echo(f" {field.name}: {getattr(config, field.name)}") + except TypeError: + click.echo(" No configuration is set (invalid configuration).") except KeyError: click.echo(" No configuration is set.") click.echo("") diff --git a/src/ansys/tools/common/launcher/_plugins.py b/src/ansys/tools/common/launcher/_plugins.py index ef968f52..9aa0c3d4 100644 --- a/src/ansys/tools/common/launcher/_plugins.py +++ b/src/ansys/tools/common/launcher/_plugins.py @@ -30,7 +30,7 @@ from .interface import FALLBACK_LAUNCH_MODE_NAME, DataclassProtocol, LauncherProtocol -LAUNCHER_ENTRY_POINT = "ansys.tools.local_product_launcher.launcher" +LAUNCHER_ENTRY_POINT = "ansys.tools.common.launcher" def get_launcher(*, product_name: str, launch_mode: str) -> type[LauncherProtocol[DataclassProtocol]]: diff --git a/src/ansys/tools/common/launcher/grpc_transport.py b/src/ansys/tools/common/launcher/grpc_transport.py new file mode 100644 index 00000000..b8e5bf5f --- /dev/null +++ b/src/ansys/tools/common/launcher/grpc_transport.py @@ -0,0 +1,179 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Defines options for connecting to a gRPC server.""" + +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +import enum +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +import grpc + +from .. import cyberchannel + +__all__ = [ + "TransportMode", + "UDSOptions", + "WNUAOptions", + "MTLSOptions", + "InsecureOptions", + "TransportOptionsType", +] + +# For Python 3.10 and below, emulate the behavior of StrEnum by +# inheriting from str and enum.Enum. +# Note that this does *not* work on Python 3.11+, since the default +# Enum format method has changed and will not return the value of +# the enum member. +# When type checking, always use the Python 3.10 workaround, otherwise +# the StrEnum resolves as 'Any'. +if TYPE_CHECKING: # pragma: no cover + + class StrEnum(str, enum.Enum): + """String enum.""" + +else: + try: + from enum import StrEnum + except ImportError: + import enum + + class StrEnum(str, enum.Enum): + """String enum.""" + + pass + + +class TransportMode(StrEnum): + """Enumeration of transport modes supported by the FileTransfer Tool.""" + + UDS = "uds" + WNUA = "wnua" + MTLS = "mtls" + INSECURE = "insecure" + + +class TransportOptionsBase(ABC): + """Base class for transport options.""" + + _MODE: ClassVar[TransportMode] + + @property + def mode(self) -> TransportMode: + """Transport mode.""" + return self._MODE + + def create_channel(self, **extra_kwargs: Any) -> grpc.Channel: + """Create a gRPC channel using the transport options. + + Parameters + ---------- + extra_kwargs : + Extra keyword arguments to pass to the channel creation function. + + Returns + ------- + : + gRPC channel created using the transport options. + """ + return cyberchannel.create_channel(**self._to_cyberchannel_kwargs(), **extra_kwargs) + + @abstractmethod + def _to_cyberchannel_kwargs(self) -> dict[str, Any]: + """Convert transport options to cyberchannel keyword arguments. + + Returns + ------- + : + Dictionary of keyword arguments for cyberchannel. + """ + pass + + +@dataclass(kw_only=True) +class UDSOptions(TransportOptionsBase): + """Options for UDS transport mode.""" + + _MODE = TransportMode.UDS + + uds_service: str + uds_dir: str | Path | None = None + uds_id: str | None = None + + def _to_cyberchannel_kwargs(self) -> dict[str, Any]: + return asdict(self) | {"transport_mode": self.mode.value} + + +@dataclass(kw_only=True) +class WNUAOptions(TransportOptionsBase): + """Options for WNUA transport mode.""" + + _MODE = TransportMode.WNUA + + port: int + + def _to_cyberchannel_kwargs(self) -> dict[str, Any]: + return asdict(self) | {"transport_mode": self.mode.value, "host": "localhost"} + + +@dataclass(kw_only=True) +class MTLSOptions(TransportOptionsBase): + """Options for mTLS transport mode.""" + + _MODE = TransportMode.MTLS + + certs_dir: str | Path | None = None + host: str = "localhost" + port: int + allow_remote_host: bool = False + + def _to_cyberchannel_kwargs(self) -> dict[str, Any]: + if not self.allow_remote_host: + if self.host not in ("localhost", "127.0.0.1"): + raise ValueError(f"Remote host '{self.host}' specified without setting 'allow_remote_host=True'.") + res = asdict(self) + res.pop("allow_remote_host", None) + return res | {"transport_mode": self.mode.value} + + +@dataclass(kw_only=True) +class InsecureOptions(TransportOptionsBase): + """Options for insecure transport mode.""" + + _MODE = TransportMode.INSECURE + + host: str = "localhost" + port: int + allow_remote_host: bool = False + + def _to_cyberchannel_kwargs(self) -> dict[str, Any]: + if not self.allow_remote_host: + if self.host not in ("localhost", "127.0.0.1"): + raise ValueError(f"Remote host '{self.host}' specified without setting 'allow_remote_host=True'.") + res = asdict(self) + res.pop("allow_remote_host", None) + return res | {"transport_mode": self.mode.value} + + +TransportOptionsType = UDSOptions | WNUAOptions | MTLSOptions | InsecureOptions diff --git a/src/ansys/tools/common/launcher/product_instance.py b/src/ansys/tools/common/launcher/product_instance.py index 383d101d..7e662f9b 100644 --- a/src/ansys/tools/common/launcher/product_instance.py +++ b/src/ansys/tools/common/launcher/product_instance.py @@ -20,20 +20,17 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Provides a wrapper for interacting with launched product instances.""" +"""Defines a wrapper for interacting with launched product instances.""" from __future__ import annotations import time -from types import MappingProxyType -from typing import Any, Mapping +from typing import Any import weakref import grpc from typing_extensions import Self -from ansys.tools.common.exceptions import ProductInstanceError - from .interface import LAUNCHER_CONFIG_T, LauncherProtocol, ServerType __all__ = ["ProductInstance"] @@ -45,7 +42,7 @@ class ProductInstance: """Provides a wrapper for interacting with the launched product instance. This class allows stopping and starting of the product instance. It also - provides access to its server URLs and gRPC channels. + provides access to its server URLs/channels. The :class:`ProductInstance` class can be used as a context manager, stopping the instance when exiting the context. @@ -53,15 +50,15 @@ class ProductInstance: def __init__(self, *, launcher: LauncherProtocol[LAUNCHER_CONFIG_T]): self._launcher = launcher - self._finalizer: weakref.finalize[Any, Self] | None = None - self._urls: Mapping[str, str] | None = None - self._channels: Mapping[str, grpc.Channel] | None = None + self._finalizer: weakref.finalize[Any, Self] + self._urls: dict[str, str] + self._channels: dict[str, grpc.Channel] self.start() def __enter__(self) -> ProductInstance: """Enter the context manager defined by the product instance.""" if self.stopped: - raise ProductInstanceError("The product instance is stopped. Cannot enter context.") + raise RuntimeError("The product instance is stopped. Cannot enter context.") return self def __exit__(self, *exc: Any) -> None: @@ -73,65 +70,72 @@ def start(self: Self) -> None: Raises ------ - ProductInstanceError - If the instance is already started or the URLs do not match - the launcher's SERVER_SPEC. + RuntimeError + If the instance is already in the started state. + RuntimeError + If the URLs exposed by the started instance do not match + the expected ones defined in the launcher's + :attr:`.LauncherProtocol.SERVER_SPEC` attribute. """ if not self.stopped: - raise ProductInstanceError("Cannot start the server. It has already been started.") - + raise RuntimeError("Cannot start the server. It has already been started.") self._finalizer = weakref.finalize(self, self._launcher.stop, timeout=None) self._launcher.start() + self._channels = dict() + urls = self.urls - self._channels = {} - self._urls = MappingProxyType(self._launcher.urls) - - if self._urls.keys() != self._launcher.SERVER_SPEC.keys(): - raise ProductInstanceError( - f"The URL keys '{self._urls.keys()}' provided by the launcher " - f"do not match the SERVER_SPEC keys '{self._launcher.SERVER_SPEC.keys()}'" - ) - + transport_options_map = self._launcher.transport_options for key, server_type in self._launcher.SERVER_SPEC.items(): if server_type == ServerType.GRPC: - self._channels[key] = grpc.insecure_channel( - self._urls[key], - options=[("grpc.max_receive_message_length", _GRPC_MAX_MESSAGE_LENGTH)], + self._channels[key] = transport_options_map[key].create_channel( + grpc_options=[("grpc.max_receive_message_length", _GRPC_MAX_MESSAGE_LENGTH)], ) + elif server_type == ServerType.GENERIC: + if key not in urls: + raise RuntimeError( + f"The URL for the generic server with key '{key}' was not provided by the launcher." + ) + else: + raise RuntimeError(f"Unsupported server type: {server_type}") def stop(self, *, timeout: float | None = None) -> None: """Stop the product instance. Parameters ---------- - timeout : float, default: None + timeout : Time in seconds after which the instance is forcefully stopped. - Not all launch methods implement this parameter. + Not all launch methods implement this parameter. If the parameter + is not implemented, it is ignored. Raises ------ - ProductInstanceError - If the instance is already stopped. + RuntimeError + If the instance is already in the stopped state. """ if self.stopped: - raise ProductInstanceError("Cannot stop the server. It has already been stopped.") - + raise RuntimeError("Cannot stop the server. It has already been stopped.") self._launcher.stop(timeout=timeout) - if self._finalizer is not None: - self._finalizer.detach() + self._finalizer.detach() def restart(self, stop_timeout: float | None = None) -> None: """Stop and then start the product instance. Parameters ---------- - stop_timeout : float, default: None + stop_timeout : Time in seconds after which the instance is forcefully stopped. + Not all launch methods implement this parameter. If the parameter + is not implemented, it is ignored. Raises ------ - ProductInstanceError - If the instance is already stopped or URL keys mismatch. + RuntimeError + If the instance is already in the stopped state. + RuntimeError + If the URLs exposed by the started instance do not match + the expected ones defined in the launcher's + :attr:`.LauncherProtocol.SERVER_SPEC` attribute. """ self.stop(timeout=stop_timeout) self.start() @@ -141,7 +145,7 @@ def check(self, timeout: float | None = None) -> bool: Parameters ---------- - timeout : float, default: None + timeout : Time in seconds to wait for the servers to respond. There is no guarantee that the ``check()`` method returns within this time. Instead, this parameter is used as a hint to the launcher implementation. @@ -156,37 +160,45 @@ def wait(self, timeout: float) -> None: Parameters ---------- - timeout : float, default: None + timeout : Wait time in seconds before raising an exception. Raises ------ - ProductInstanceError - If the server still has not responded after `timeout` seconds. + RuntimeError + If the server still has not responded after ``timeout`` seconds. """ start_time = time.time() while time.time() - start_time <= timeout: if self.check(timeout=timeout / 3): break - time.sleep(max(timeout / 100, 0.01)) # minimum sleep to avoid busy waiting + else: + # Try again until the timeout is reached. We add a small + # delay s.t. the server isn't bombarded with requests. + time.sleep(timeout / 100) else: - raise ProductInstanceError(f"The product is not running after {timeout}s.") + raise RuntimeError(f"The product is not running after {timeout}s.") @property - def urls(self) -> Mapping[str, str]: - """Read-only mapping of server keys to their URLs.""" - if self._urls is None: - return MappingProxyType({}) - return self._urls + def urls(self) -> dict[str, str]: + """URL and port for the servers of the product instance. + + Only generic server types are listed, gRPC servers should be accessed + via the :attr:`.channels` property. + """ + return self._launcher.urls @property def stopped(self) -> bool: """Flag indicating if the product instance is currently stopped.""" - return self._finalizer is None or not self._finalizer.alive + try: + return not self._finalizer.alive + # If the server has never been started, the '_finalizer' attribute + # may not be defined. + except AttributeError: + return True @property - def channels(self) -> Mapping[str, grpc.Channel]: - """Read-only mapping of server keys to gRPC channels.""" - if self._channels is None: - return MappingProxyType({}) - return MappingProxyType(self._channels) + def channels(self) -> dict[str, grpc.Channel]: + """Channels to the gRPC servers of the product instance.""" + return self._channels diff --git a/tests/launcher/integration/simple_test_launcher.py b/tests/launcher/integration/simple_test_launcher.py index 450221f5..ac1b6052 100644 --- a/tests/launcher/integration/simple_test_launcher.py +++ b/tests/launcher/integration/simple_test_launcher.py @@ -25,11 +25,10 @@ import pathlib import subprocess import sys +import tempfile -import grpc - +from ansys.tools.common.launcher.grpc_transport import UDSOptions from ansys.tools.common.launcher.helpers.grpc import check_grpc_health -from ansys.tools.common.launcher.helpers.ports import find_free_ports from ansys.tools.common.launcher.interface import ( METADATA_KEY_DOC, LauncherProtocol, @@ -48,6 +47,9 @@ class SimpleLauncherConfig: default=str(SCRIPT_PATH), metadata={METADATA_KEY_DOC: "Location of the server Python script."}, ) + transport_options = UDSOptions( + uds_service="simple_test_service", + ) class SimpleLauncher(LauncherProtocol[SimpleLauncherConfig]): @@ -59,18 +61,26 @@ class SimpleLauncher(LauncherProtocol[SimpleLauncherConfig]): def __init__(self, *, config: SimpleLauncherConfig): """Initialize the SimpleLauncher with the given configuration.""" self._script_path = config.script_path + self._transport_options = config.transport_options + if self._transport_options.mode != "uds": + raise ValueError("Only UDS transport mode is supported by SimpleLauncher.") self._process: subprocess.Popen[str] self._url: str + if self._transport_options.uds_dir is None: + self._tmp_dir = tempfile.TemporaryDirectory() + self._transport_options.uds_dir = self._tmp_dir.name + + self._uds_dir = self._transport_options.uds_dir + self._uds_file = pathlib.Path(self._transport_options.uds_dir) / "simple_test_service.sock" + self._url = f"unix:{self._uds_file}" def start(self): """Start the service.""" - port = find_free_ports()[0] - self._url = f"localhost:{port}" self._process = subprocess.Popen( [ sys.executable, self._script_path, - str(port), + str(self._uds_dir), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -85,13 +95,16 @@ def stop(self, *, timeout=None): except subprocess.TimeoutExpired: self._process.kill() self._process.wait() + # If the server failed to so, remove the UDS file. Graceful + # shutdown on Windows does not appear to work reliably. + self._uds_file.unlink(missing_ok=True) def check(self, *, timeout: float | None = None) -> bool: """Check if the server is responding to requests.""" - channel = grpc.insecure_channel(self.urls[SERVER_KEY]) + channel = self._transport_options.create_channel() return check_grpc_health(channel, timeout=timeout) @property - def urls(self): - """Return the URLs of the server.""" - return {SERVER_KEY: self._url} + def transport_options(self): + """Return the transport options of the server.""" + return {SERVER_KEY: self._transport_options} diff --git a/tests/launcher/integration/simple_test_server.py b/tests/launcher/integration/simple_test_server.py index dcd510f4..75079ed1 100644 --- a/tests/launcher/integration/simple_test_server.py +++ b/tests/launcher/integration/simple_test_server.py @@ -22,15 +22,30 @@ """Module for test server.""" from concurrent import futures +import pathlib import sys import grpc from grpc_health.v1 import health, health_pb2_grpc -if __name__ == "__main__": - port = sys.argv[1] + +def main(uds_dir: str): + """Run the simple test server.""" + uds_file = pathlib.Path(uds_dir) / "simple_test_service.sock" + if uds_file.exists(): + print(f"UDS file {uds_file} already exists.") + sys.exit(1) server = grpc.server(futures.ThreadPoolExecutor(max_workers=2)) health_pb2_grpc.add_HealthServicer_to_server(health.HealthServicer(), server) - server.add_insecure_port(f"[::]:{port}") - server.start() - server.wait_for_termination() + server.add_insecure_port(f"unix:{uds_file}") + print(f"Starting gRPC server with UDS file {uds_file}...") + try: + server.start() + server.wait_for_termination() + finally: + print("Shutting down gRPC server...") + uds_file.unlink(missing_ok=True) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/tests/launcher/integration/test_simple_launcher.py b/tests/launcher/integration/test_simple_launcher.py index 6881b11d..7f099446 100644 --- a/tests/launcher/integration/test_simple_launcher.py +++ b/tests/launcher/integration/test_simple_launcher.py @@ -22,11 +22,11 @@ """Test module for launcher.""" from dataclasses import dataclass +import pathlib import pytest -from ansys.tools.common.launcher import config -from ansys.tools.common.launcher.launch import launch_product +from ansys.tools.common.launcher import config, launch_product from .simple_test_launcher import SimpleLauncher, SimpleLauncherConfig @@ -47,6 +47,12 @@ def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): monkeypatch_entrypoints_from_plugins({PRODUCT_NAME: {"direct": SimpleLauncher}}) +def check_uds_file_removed(server): + """Check that the UDS file has been removed after stopping the server.""" + uds_file = pathlib.Path(server._launcher.transport_options["main"].uds_dir) / "simple_test_service.sock" + assert not pathlib.Path(uds_file).exists() + + def test_default_config(): """Test the default configuration.""" config.set_config_for(product_name=PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) @@ -54,6 +60,7 @@ def test_default_config(): server.wait(timeout=10) server.stop() assert not server.check() + check_uds_file_removed(server) def test_explicit_config(): @@ -62,6 +69,7 @@ def test_explicit_config(): server.wait(timeout=10) server.stop() assert not server.check() + check_uds_file_removed(server) def test_stop_with_timeout(): @@ -70,6 +78,7 @@ def test_stop_with_timeout(): server.wait(timeout=10) server.stop(timeout=1.0) assert not server.check() + check_uds_file_removed(server) def test_invalid_launch_mode_raises(): @@ -90,3 +99,4 @@ def test_contextmanager(): server.wait(timeout=10) assert server.check() assert not server.check() + check_uds_file_removed(server) diff --git a/tests/launcher/pkg_with_entrypoint/pyproject.toml b/tests/launcher/pkg_with_entrypoint/pyproject.toml index 65ba4f9f..785101ec 100644 --- a/tests/launcher/pkg_with_entrypoint/pyproject.toml +++ b/tests/launcher/pkg_with_entrypoint/pyproject.toml @@ -16,6 +16,6 @@ packages = [ [tool.poetry.dependencies] python = ">=3.10,<4.0" -[tool.poetry.plugins."ansys.tools.local_product_launcher.launcher"] +[tool.poetry.plugins."ansys.tools.common.launcher"] "pkg_with_entrypoint.test_entry_point" = "pkg_with_entrypoint:Launcher" "pkg_with_entrypoint.__fallback__" = "pkg_with_entrypoint:Launcher" From 1c3d25f6a7958fb074afc8cc86e3375167f47bdb Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:46:12 +0000 Subject: [PATCH 2/5] chore: adding changelog file 116.added.md [dependabot-skip] --- doc/source/changelog/116.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/source/changelog/116.added.md diff --git a/doc/source/changelog/116.added.md b/doc/source/changelog/116.added.md new file mode 100644 index 00000000..f0fc6d46 --- /dev/null +++ b/doc/source/changelog/116.added.md @@ -0,0 +1 @@ +Launcher: implement gRPC transport option handling From 1983c97133aef1a79520a27d8c80fda1d5d5772a Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 26 Nov 2025 18:02:19 +0100 Subject: [PATCH 3/5] Use ProductInstanceError --- .../tools/common/launcher/product_instance.py | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/ansys/tools/common/launcher/product_instance.py b/src/ansys/tools/common/launcher/product_instance.py index 7e662f9b..ef61bc10 100644 --- a/src/ansys/tools/common/launcher/product_instance.py +++ b/src/ansys/tools/common/launcher/product_instance.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Defines a wrapper for interacting with launched product instances.""" +"""Provides a wrapper for interacting with launched product instances.""" from __future__ import annotations @@ -31,6 +31,8 @@ import grpc from typing_extensions import Self +from ansys.tools.common.exceptions import ProductInstanceError + from .interface import LAUNCHER_CONFIG_T, LauncherProtocol, ServerType __all__ = ["ProductInstance"] @@ -42,7 +44,7 @@ class ProductInstance: """Provides a wrapper for interacting with the launched product instance. This class allows stopping and starting of the product instance. It also - provides access to its server URLs/channels. + provides access to its server URLs and gRPC channels. The :class:`ProductInstance` class can be used as a context manager, stopping the instance when exiting the context. @@ -58,7 +60,7 @@ def __init__(self, *, launcher: LauncherProtocol[LAUNCHER_CONFIG_T]): def __enter__(self) -> ProductInstance: """Enter the context manager defined by the product instance.""" if self.stopped: - raise RuntimeError("The product instance is stopped. Cannot enter context.") + raise ProductInstanceError("The product instance is stopped. Cannot enter context.") return self def __exit__(self, *exc: Any) -> None: @@ -70,15 +72,13 @@ def start(self: Self) -> None: Raises ------ - RuntimeError - If the instance is already in the started state. - RuntimeError - If the URLs exposed by the started instance do not match - the expected ones defined in the launcher's - :attr:`.LauncherProtocol.SERVER_SPEC` attribute. + ProductInstanceError + If the instance is already started or the URLs do not match + the launcher's SERVER_SPEC. """ if not self.stopped: - raise RuntimeError("Cannot start the server. It has already been started.") + raise ProductInstanceError("Cannot start the server. It has already been started.") + self._finalizer = weakref.finalize(self, self._launcher.stop, timeout=None) self._launcher.start() self._channels = dict() @@ -92,11 +92,11 @@ def start(self: Self) -> None: ) elif server_type == ServerType.GENERIC: if key not in urls: - raise RuntimeError( + raise ProductInstanceError( f"The URL for the generic server with key '{key}' was not provided by the launcher." ) else: - raise RuntimeError(f"Unsupported server type: {server_type}") + raise ProductInstanceError(f"Unsupported server type: {server_type}") def stop(self, *, timeout: float | None = None) -> None: """Stop the product instance. @@ -110,11 +110,11 @@ def stop(self, *, timeout: float | None = None) -> None: Raises ------ - RuntimeError + ProductInstanceError If the instance is already in the stopped state. """ if self.stopped: - raise RuntimeError("Cannot stop the server. It has already been stopped.") + raise ProductInstanceError("Cannot stop the server. It has already been stopped.") self._launcher.stop(timeout=timeout) self._finalizer.detach() @@ -130,12 +130,8 @@ def restart(self, stop_timeout: float | None = None) -> None: Raises ------ - RuntimeError - If the instance is already in the stopped state. - RuntimeError - If the URLs exposed by the started instance do not match - the expected ones defined in the launcher's - :attr:`.LauncherProtocol.SERVER_SPEC` attribute. + ProductInstanceError + If the instance is already stopped or URL keys mismatch. """ self.stop(timeout=stop_timeout) self.start() @@ -165,7 +161,7 @@ def wait(self, timeout: float) -> None: Raises ------ - RuntimeError + ProductInstanceError If the server still has not responded after ``timeout`` seconds. """ start_time = time.time() @@ -177,11 +173,11 @@ def wait(self, timeout: float) -> None: # delay s.t. the server isn't bombarded with requests. time.sleep(timeout / 100) else: - raise RuntimeError(f"The product is not running after {timeout}s.") + raise ProductInstanceError(f"The product is not running after {timeout}s.") @property def urls(self) -> dict[str, str]: - """URL and port for the servers of the product instance. + """Read-only mapping of server keys to their URLs. Only generic server types are listed, gRPC servers should be accessed via the :attr:`.channels` property. @@ -200,5 +196,5 @@ def stopped(self) -> bool: @property def channels(self) -> dict[str, grpc.Channel]: - """Channels to the gRPC servers of the product instance.""" + """Read-only mapping of server keys to gRPC channels.""" return self._channels From 7b68a2bdbdba4b0bde26e660847a0751354d7525 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 26 Nov 2025 21:23:41 +0100 Subject: [PATCH 4/5] Update LauncherProtocol --- src/ansys/tools/common/launcher/interface.py | 33 ++++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/ansys/tools/common/launcher/interface.py b/src/ansys/tools/common/launcher/interface.py index 591780d7..41386076 100644 --- a/src/ansys/tools/common/launcher/interface.py +++ b/src/ansys/tools/common/launcher/interface.py @@ -22,14 +22,15 @@ """Interface definitions for implementing a local product launcher. -A plugin for the local product launcher must implement the :class:`LauncherProtocol` +A plugin for the Local Product Launcher must implement the :class:`LauncherProtocol` class and register it. """ from enum import Enum, auto -from types import MappingProxyType from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable +from .grpc_transport import TransportOptionsType + __all__ = [ "DataclassProtocol", "FALLBACK_LAUNCH_MODE_NAME", @@ -109,7 +110,7 @@ class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): must be an instance of ``CONFIG_MODEL``. """ - CONFIG_MODEL: ClassVar[type[LAUNCHER_CONFIG_T]] + CONFIG_MODEL: type[LAUNCHER_CONFIG_T] """Defines the configuration options for the launcher. The configuration options that this launcher accepts, specified @@ -118,7 +119,7 @@ class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): used in the configuration CLI, if available. """ - SERVER_SPEC: ClassVar[dict[str, ServerType]] = MappingProxyType({}) + SERVER_SPEC: dict[str, ServerType] """Defines the server types that are started. Examples @@ -129,12 +130,7 @@ class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): .. code:: python - SERVER_SPEC = MappingProxyType( - { - "MAIN": ServerType.GENERIC, - "FILE_TRANSFER": ServerType.GRPC, - } - ) + SERVER_SPEC = {"MAIN": ServerType.GENERIC, "FILE_TRANSFER": ServerType.GRPC} The :attr:`.ProductInstance.urls` attribute then has keys ``{"MAIN", "FILE_TRANSFER"}``, whereas the @@ -191,3 +187,20 @@ def urls(self) -> dict[str, str]: >>> launcher.urls {"MAIN": "http://127.0.0.1:8080", "FILE_TRANSFER": "grpc://127.0.0.1:50051"} """ + if any(val == ServerType.GENERIC for val in self.SERVER_SPEC.values()): + raise NotImplementedError("LauncherProtocol.urls must be implemented if any generic servers are started.") + return {} + + @property + def transport_options(self) -> dict[str, TransportOptionsType]: + """Dictionary of transport options for the gRPC servers started. + + The keys of the returned dictionary must correspond to the keys + defined in the :attr:`.LauncherProtocol.SERVER_SPEC` attribute + which have the value :attr:`ServerType.GRPC`. + """ + if any(val == ServerType.GRPC for val in self.SERVER_SPEC.values()): + raise NotImplementedError( + "LauncherProtocol.transport_options must be implemented if any gRPC servers are started." + ) + return {} From 2f8e4c0c957f3101873ad2a278a0794a92967e90 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 27 Nov 2025 22:58:21 +0100 Subject: [PATCH 5/5] Allow using the deprecated entry point --- src/ansys/tools/common/launcher/_plugins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ansys/tools/common/launcher/_plugins.py b/src/ansys/tools/common/launcher/_plugins.py index 9aa0c3d4..2733dd44 100644 --- a/src/ansys/tools/common/launcher/_plugins.py +++ b/src/ansys/tools/common/launcher/_plugins.py @@ -31,6 +31,7 @@ from .interface import FALLBACK_LAUNCH_MODE_NAME, DataclassProtocol, LauncherProtocol LAUNCHER_ENTRY_POINT = "ansys.tools.common.launcher" +DEPRECATED_LAUNCHER_ENTRY_POINT = "ansys.tools.local_product_launcher.launcher" def get_launcher(*, product_name: str, launch_mode: str) -> type[LauncherProtocol[DataclassProtocol]]: @@ -110,6 +111,6 @@ def get_fallback_launcher(product_name: str) -> type[LauncherProtocol[DataclassP def _get_entry_points() -> tuple[importlib.metadata.EntryPoint, ...]: """Get all Local Product Launcher plugin entrypoints for launchers.""" try: - return entry_points(group=LAUNCHER_ENTRY_POINT) # type: ignore + return entry_points(group=LAUNCHER_ENTRY_POINT) + entry_points(group=DEPRECATED_LAUNCHER_ENTRY_POINT) # type: ignore except KeyError: return tuple()