From 0ff999e309c6633d97ef8f031a0dba8004b531fe Mon Sep 17 00:00:00 2001 From: afernand Date: Wed, 2 Jul 2025 13:29:23 +0200 Subject: [PATCH 01/17] feat: Migrate launcher --- pyproject.toml | 1 + .../tools/common/abstractions/launcher.py | 168 +++++++++-- src/ansys/tools/common/launcher/__init__.py | 42 +++ src/ansys/tools/common/launcher/_cli.py | 279 ++++++++++++++++++ src/ansys/tools/common/launcher/_plugins.py | 85 ++++++ src/ansys/tools/common/launcher/config.py | 265 +++++++++++++++++ .../tools/common/launcher/helpers/__init__.py | 27 ++ .../tools/common/launcher/helpers/grpc.py | 54 ++++ .../tools/common/launcher/helpers/ports.py | 49 +++ src/ansys/tools/common/launcher/interface.py | 180 +++++++++++ src/ansys/tools/common/launcher/launch.py | 84 ++++++ .../tools/common/launcher/product_instance.py | 197 +++++++++++++ tests/launcher/conftest.py | 61 ++++ .../launcher/pkg_with_entrypoint/poetry.lock | 7 + .../pkg_with_entrypoint/pyproject.toml | 22 ++ .../src/pkg_with_entrypoint/__init__.py | 25 ++ .../src/pkg_with_entrypoint/launcher.py | 34 +++ tests/launcher/test_cli/__init__.py | 21 ++ tests/launcher/test_cli/common.py | 29 ++ tests/launcher/test_cli/conftest.py | 38 +++ .../test_cli/test_multiple_plugins.py | 201 +++++++++++++ tests/launcher/test_cli/test_single_plugin.py | 157 ++++++++++ tests/launcher/test_entry_point.py | 59 ++++ tests/launcher/test_integration/__init__.py | 21 ++ .../test_integration/simple_test_launcher.py | 87 ++++++ .../test_integration/simple_test_server.py | 35 +++ .../test_integration/test_simple_launcher.py | 81 +++++ tests/launcher/test_plugins/conftest.py | 21 ++ tests/launcher/test_plugins/test_plugins.py | 110 +++++++ 29 files changed, 2417 insertions(+), 23 deletions(-) create mode 100644 src/ansys/tools/common/launcher/__init__.py create mode 100644 src/ansys/tools/common/launcher/_cli.py create mode 100644 src/ansys/tools/common/launcher/_plugins.py create mode 100644 src/ansys/tools/common/launcher/config.py create mode 100644 src/ansys/tools/common/launcher/helpers/__init__.py create mode 100644 src/ansys/tools/common/launcher/helpers/grpc.py create mode 100644 src/ansys/tools/common/launcher/helpers/ports.py create mode 100644 src/ansys/tools/common/launcher/interface.py create mode 100644 src/ansys/tools/common/launcher/launch.py create mode 100644 src/ansys/tools/common/launcher/product_instance.py create mode 100644 tests/launcher/conftest.py create mode 100644 tests/launcher/pkg_with_entrypoint/poetry.lock create mode 100644 tests/launcher/pkg_with_entrypoint/pyproject.toml create mode 100644 tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py create mode 100644 tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py create mode 100644 tests/launcher/test_cli/__init__.py create mode 100644 tests/launcher/test_cli/common.py create mode 100644 tests/launcher/test_cli/conftest.py create mode 100644 tests/launcher/test_cli/test_multiple_plugins.py create mode 100644 tests/launcher/test_cli/test_single_plugin.py create mode 100644 tests/launcher/test_entry_point.py create mode 100644 tests/launcher/test_integration/__init__.py create mode 100644 tests/launcher/test_integration/simple_test_launcher.py create mode 100644 tests/launcher/test_integration/simple_test_server.py create mode 100644 tests/launcher/test_integration/test_simple_launcher.py create mode 100644 tests/launcher/test_plugins/conftest.py create mode 100644 tests/launcher/test_plugins/test_plugins.py diff --git a/pyproject.toml b/pyproject.toml index c38bd3db..daebd23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ doc = [ [project.scripts] save-ansys-path = "ansys.tools.common.path.save:cli" +ansys-launcher = "ansys.tools.local_product_launcher._cli:cli" [project.urls] Source = "https://github.com/ansys/ansys-tools-common" diff --git a/src/ansys/tools/common/abstractions/launcher.py b/src/ansys/tools/common/abstractions/launcher.py index 6b2607d5..a7464b53 100644 --- a/src/ansys/tools/common/abstractions/launcher.py +++ b/src/ansys/tools/common/abstractions/launcher.py @@ -19,40 +19,162 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Module for abstract launcher.""" -from abc import ABC, abstractmethod +"""Interface definitions for implementing a local product launcher. +A plugin for the Local Product Launcher must implement the :class:`LauncherProtocol` +class and register it. +""" -class AbstractServiceLauncher(ABC): - """Abstract class for launching services. +from enum import Enum, auto +from typing import Any, ClassVar, Protocol, TypeVar + +__all__ = [ + "DataclassProtocol", + "FALLBACK_LAUNCH_MODE_NAME", + "LAUNCHER_CONFIG_T", + "LauncherProtocol", + "METADATA_KEY_DOC", + "METADATA_KEY_NOPROMPT", + "ServerType", +] + +METADATA_KEY_DOC = "launcher_doc" +"""Key used in the :py:class:`dataclasses.Field` ``metadata`` for the option description.""" + +METADATA_KEY_NOPROMPT = "launcher_noprompt" +""" +Key used in the :py:class:`dataclasses.Field` ``metadata`` to skip prompting for +the option by default. +""" + +FALLBACK_LAUNCH_MODE_NAME = "__fallback__" + + +class DataclassProtocol(Protocol): + """Provides the ``Protocol`` class for Python dataclasses.""" + + __dataclass_fields__: ClassVar[dict[str, Any]] + + +LAUNCHER_CONFIG_T = TypeVar("LAUNCHER_CONFIG_T", bound=DataclassProtocol) +# This docstring is commented-out because numpydoc causes the documentation build to fail when +# it is included. +# """Type variable for launcher configuration objects.""" + + +class ServerType(Enum): + """Defines which protocols the server supports. + + The ``ServerType`` class is used as values in the :attr:`LauncherProtocol.SERVER_SPEC` + attribute to define the capabilities of the servers started with a given product and + launch method. + """ + + GENERIC = auto() + """Generic server, which responds at a given URL and port. + + The generic server type can be used for any server. It does not + include information about which protocol should be used. + """ + + GRPC = auto() + """Server that can be accessed via gRPC. + + Servers of this type are accessible via the :attr:`.ProductInstance.channels` + attribute. + """ + + +class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): + """Interface for managing a local product instance. + + A plugin to the Local Product Launcher must implement the interface + defined in this class. + + To check for compatibility, it is recommended to derive from this + class, for example ``MyLauncher(LauncherProtocol[MyConfigModel])``, and + check the resulting code with `mypy `_. + + The ``__init__`` method should accept exactly one keyword-only + parameter: ``config``. Note that this is `not enforced by mypy + `_. Parameters ---------- - host : str - The host where the service will be launched. - port : str - The port where the service will be launched. + config : + Configuration options used to start the product. This parameter + must be an instance of ``CONFIG_MODEL``. + """ + + CONFIG_MODEL: type[LAUNCHER_CONFIG_T] + """Defines the configuration options for the launcher. + + The configuration options which this launcher accepts, specified + as a :py:func:`dataclass `. Note that the + ``default`` and ``metadata[METADATA_KEY_DOC]`` of the fields are + used in the configuration CLI, if available. """ - @abstractmethod - def __init__(self, host: str, port: str) -> None: - """Initialize the service launcher with host and port.""" - self._host = host - self._port = port + SERVER_SPEC: dict[str, ServerType] + """Defines the server types that are started. + + Examples + -------- + This code defines a server that is accessible via a URL at the + ``"MAIN"`` key and a server accessible via gRPC at the + ``"FILE_TRANSFER"`` key. + + .. code:: python + + SERVER_SPEC = {"MAIN": ServerType.GENERIC, "FILE_TRANSFER": ServerType.GRPC} + + The :attr:`.ProductInstance.urls` attribute then has keys + ``{"MAIN", "FILE_TRANSFER"}``, whereas the + :attr:`.ProductInstance.channels` attribute has only the + key ``"FILE_TRANSFER"``. + """ + + def __init__(self, *, config: LAUNCHER_CONFIG_T): + pass + + def start(self) -> None: + """Start the product instance.""" - @abstractmethod - def launch(self, use_docker: bool = False) -> None: - """Launch the service. + def stop(self, *, timeout: float | None = None) -> None: + """Stop the product instance. Parameters ---------- - use_docker : bool - Whether to launch the service using Docker. + timeout : + Time after which the instance can be forcefully stopped. + The timeout should be interpreted as a hint to the implementation. + It is *not required* to trigger a force-shutdown, but the stop + *must* return within a finite time. """ - pass - @abstractmethod - def stop(self) -> None: - """Stop the service.""" - pass + def check(self, *, timeout: float | None = None) -> bool: + """Check if the product instance is responding to requests. + + Parameters + ---------- + timeout : + Timeout in seconds for the check. + The timeout should be interpreted as a hint to the implementation. + It is *not required* to return within the given time, but the + check *must* return within a finite time, meaning it must not + hang indefinitely. + + Returns + ------- + : + Whether the product instance is responding. + """ + + @property + def urls(self) -> dict[str, str]: + """Dictionary of URLs that the server is listening on. + + The keys of the returned dictionary must correspond to the keys + defined in the :attr:`.LauncherProtocol.SERVER_SPEC` attribute. + """ diff --git a/src/ansys/tools/common/launcher/__init__.py b/src/ansys/tools/common/launcher/__init__.py new file mode 100644 index 00000000..07f17ee9 --- /dev/null +++ b/src/ansys/tools/common/launcher/__init__.py @@ -0,0 +1,42 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tools. + +local_product_launcher +""" + +import importlib.metadata + +from . import config, helpers, interface, product_instance +from .launch import launch_product + +__version__ = importlib.metadata.version(__name__.replace(".", "-")) + +__all__ = [ + "interface", + "helpers", + "config", + "product_instance", + "launch_product", +] diff --git a/src/ansys/tools/common/launcher/_cli.py b/src/ansys/tools/common/launcher/_cli.py new file mode 100644 index 00000000..e62b1d6e --- /dev/null +++ b/src/ansys/tools/common/launcher/_cli.py @@ -0,0 +1,279 @@ +# 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. + +from collections.abc import Callable, Sequence +import dataclasses +import json +import textwrap +from typing import Any, cast + +import click + +from ._plugins import get_all_plugins +from .config import ( + _get_config_path, + get_config_for, + get_launch_mode_for, + is_configured, + save_config, + set_config_for, +) +from .interface import LAUNCHER_CONFIG_T, METADATA_KEY_DOC, METADATA_KEY_NOPROMPT, LauncherProtocol + + +def format_prompt(*, field_name: str, description: str | None) -> str: + """Get the formatted prompt string from its field name and description.""" + prompt = f"\n{field_name}:" + if description is not None: + prompt += "\n" + textwrap.indent(description, " " * 4) + prompt += "\n" + return prompt + + +_OVERWRITE_DEFAULT_FLAG_NAME = "overwrite_default" +_DEFAULT_STR = "default" + + +def get_subcommands_from_plugins( + *, plugins: dict[str, dict[str, LauncherProtocol[LAUNCHER_CONFIG_T]]] +) -> Sequence[click.Command]: + """Construct ``configure`` subcommands from the plugins.""" + all_product_commands: list[click.Group] = [] + for product_name, launch_mode_configs in plugins.items(): + product_command = click.Group(product_name) + all_product_commands.append(product_command) + for launch_mode, launcher_kls in launch_mode_configs.items(): + launcher_config_kls = launcher_kls.CONFIG_MODEL + + _config_writer_callback = config_writer_callback_factory(launcher_config_kls, product_name, launch_mode) + launch_mode_command = click.Command(launch_mode, callback=_config_writer_callback) + for field in dataclasses.fields(launcher_config_kls): + option = get_option_from_field(field) + launch_mode_command.params.append(option) + + extra_kwargs_overwrite_option = dict() + if is_configured(product_name=product_name): + current_launch_mode = get_launch_mode_for(product_name=product_name) + if current_launch_mode != launch_mode: + extra_kwargs_overwrite_option = dict( + prompt=( + f"\nOverwrite default launch mode for {product_name} " + f"(currently set to '{current_launch_mode}')?" + ), + show_default=True, + ) + + launch_mode_command.params.append( + click.Option( + [f"--{_OVERWRITE_DEFAULT_FLAG_NAME}"], + is_flag=True, + **extra_kwargs_overwrite_option, # type: ignore + ) + ) + + product_command.add_command(launch_mode_command) + + return all_product_commands + + +class JSONParamType(click.ParamType): + """Implements interpreting options as JSON. + + An empty string is interpreted as ``None``. + """ + + name = "json" + + def convert(self, value: Any, param: Any, ctx: Any) -> Any: + """Convert the string to a dictionary.""" + if value is None: + return None + if not isinstance(value, str): + return value + if value == _DEFAULT_STR: + return None + try: + return json.loads(value) + except json.JSONDecodeError as e: + raise ValueError(f"Cannot decode JSON value '{value}'") from e + + +def get_option_from_field(field: "dataclasses.Field[Any]") -> click.Option: + """Construct a click.Option from a dataclass field. + + Convert the field type, default, and metadata to the corresponding + click.Option. + """ + # The annotations can be either a type or a string, depending on whether + # deferred evaluation is used. + type_ = { + int: int, + str: str, + bool: bool, + "int": int, + "str": str, + "bool": bool, + }.get(field.type, JSONParamType()) + + if field.default is not dataclasses.MISSING: + default = field.default + if default is None: + default = _DEFAULT_STR + if not isinstance(type_, JSONParamType): + raise ValueError(f"Invalid default value 'None' for {type_} type.") + elif field.default_factory is not dataclasses.MISSING: + default = field.default_factory() + else: + default = None + + description = field.metadata.get(METADATA_KEY_DOC, None) + prompt_required = not field.metadata.get(METADATA_KEY_NOPROMPT, False) + return click.Option( + [f"--{field.name}"], + prompt=format_prompt( + field_name=field.name, + description=description, + ), + help=description, + type=type_, + default=default, + prompt_required=prompt_required, + ) + + +def config_writer_callback_factory( + launcher_config_kls: type[LAUNCHER_CONFIG_T], product_name: str, launch_mode: str +) -> Callable[..., None]: + """Construct the callback for updating the configuration file.""" + + def _config_writer_callback(**kwargs: dict[str, Any]) -> None: + overwrite_default = cast(bool, kwargs.pop(_OVERWRITE_DEFAULT_FLAG_NAME, False)) + config = launcher_config_kls(**kwargs) + set_config_for( + product_name=product_name, + launch_mode=launch_mode, + config=config, + overwrite_default=overwrite_default, + ) + save_config() + click.echo(f"\nUpdated {_get_config_path()}") + + return _config_writer_callback + + +def build_cli(plugins: dict[str, dict[str, LauncherProtocol[LAUNCHER_CONFIG_T]]]) -> click.Group: + """Build the CLI from the plugins.""" + _cli = click.Group() + + all_subcommands = get_subcommands_from_plugins(plugins=plugins) + + @_cli.group(invoke_without_command=True) + @click.pass_context + def configure(ctx: click.Context) -> None: + """ + Configure the options for a specific product and launch mode. + + The available products and launch modes are determined dynamically + from the installed plugins. + + To get a list of products: + + .. code:: bash + + ansys-launcher configure + + To get a list of launch modes for a given product: + + .. code:: bash + + ansys-launcher configure + + To configure a product launch mode: + + .. code:: bash + + ansys-launcher configure + + """ + if ctx.invoked_subcommand is None: + if not plugins: + click.echo("No plugins are configured.") + else: + click.echo(ctx.get_help()) + + for subcommand in all_subcommands: + configure.add_command(subcommand) + + @_cli.command() + # @click.pass_context + def list_plugins() -> None: + """List the possible product/launch mode combinations.""" + if not plugins: + click.echo("No plugins are configured.") + return + for product_name, launch_mode_configs in sorted(plugins.items()): + click.echo(f"{product_name}") + for launch_mode in sorted(launch_mode_configs.keys()): + click.echo(f" {launch_mode}") + click.echo("") + + @_cli.command() + def show_config() -> None: + """Show the current configuration.""" + for product_name, launch_mode_configs in sorted(plugins.items()): + click.echo(f"{product_name}") + try: + default_launch_mode = get_launch_mode_for(product_name=product_name) + for launch_mode in sorted(launch_mode_configs.keys()): + if launch_mode == default_launch_mode: + click.echo(f" {launch_mode} (default)") + else: + click.echo(f" {launch_mode}") + + if not is_configured(product_name=product_name, launch_mode=launch_mode): + try: + config = get_config_for(product_name=product_name, launch_mode=launch_mode) + click.echo(" No configuration is set (uses defaults).") + except KeyError: + 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)}") + except KeyError: + click.echo(" No configuration is set.") + click.echo("") + + @_cli.command() + def show_config_path() -> None: + """Show the path to the configuration file.""" + click.echo(_get_config_path()) + + return _cli + + +# Needs to be defined at the module level, since this is what the [tool.poetry.scripts] +# entrypoint refers to. +cli = build_cli(plugins=get_all_plugins()) + +if __name__ == "__main__": + cli() diff --git a/src/ansys/tools/common/launcher/_plugins.py b/src/ansys/tools/common/launcher/_plugins.py new file mode 100644 index 00000000..ca26287f --- /dev/null +++ b/src/ansys/tools/common/launcher/_plugins.py @@ -0,0 +1,85 @@ +# 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. + +from __future__ import annotations + +import importlib.metadata +from importlib.metadata import entry_points +from typing import Any + +from .interface import FALLBACK_LAUNCH_MODE_NAME, DataclassProtocol, LauncherProtocol + +LAUNCHER_ENTRY_POINT = "ansys.tools.local_product_launcher.launcher" + + +def get_launcher(*, product_name: str, launch_mode: str) -> type[LauncherProtocol[DataclassProtocol]]: + """Get the launcher plugin for a given product and launch mode.""" + ep_name = f"{product_name}.{launch_mode}" + for entrypoint in _get_entry_points(): + if entrypoint.name == ep_name: + return entrypoint.load() # type: ignore + else: + raise KeyError(f"No plugin found for '{ep_name}'.") + + +def get_config_model(*, product_name: str, launch_mode: str) -> type[DataclassProtocol]: + """Get the configuration model for a given product and launch mode.""" + return get_launcher(product_name=product_name, launch_mode=launch_mode).CONFIG_MODEL + + +def get_all_plugins(hide_fallback: bool = True) -> dict[str, dict[str, LauncherProtocol[Any]]]: + """Get mapping {"": {"": Launcher}} containing all plugins.""" + res: dict[str, dict[str, LauncherProtocol[Any]]] = dict() + for entry_point in _get_entry_points(): + product_name, launch_mode = entry_point.name.split(".") + if hide_fallback and launch_mode == FALLBACK_LAUNCH_MODE_NAME: + continue + res.setdefault(product_name, dict()) + res[product_name][launch_mode] = entry_point.load() + return res + + +def has_fallback(product_name: str) -> bool: + """Return ``True`` if the given product has a fallback launcher.""" + for entry_point in _get_entry_points(): + ep_product_name, ep_launch_mode = entry_point.name.split(".") + if product_name == ep_product_name and ep_launch_mode == FALLBACK_LAUNCH_MODE_NAME: + return True + return False + + +def get_fallback_launcher(product_name: str) -> type[LauncherProtocol[DataclassProtocol]]: + """Get the fallback launcher for a given product.""" + ep_name = f"{product_name}.{FALLBACK_LAUNCH_MODE_NAME}" + for entrypoint in _get_entry_points(): + if entrypoint.name == ep_name: + return entrypoint.load() # type: ignore + else: + raise KeyError(f"No fallback plugin found for '{product_name}'.") + + +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 + except KeyError: + return tuple() diff --git a/src/ansys/tools/common/launcher/config.py b/src/ansys/tools/common/launcher/config.py new file mode 100644 index 00000000..432808d4 --- /dev/null +++ b/src/ansys/tools/common/launcher/config.py @@ -0,0 +1,265 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Tools for managing Local Product Launcher configuration. + +The methods in the ``config`` class manage the default configuration +for launching products. The configuration is loaded from and stored to a +``config.json`` file. By default, this file is located in the user configuration +directory (platform-dependent). Its location can be specified explicitly +with the ``ANSYS_LAUNCHER_CONFIG_PATH`` environment variable. +""" + +import dataclasses +import json +import os +import pathlib +from typing import Any, cast + +import appdirs + +from ._plugins import get_config_model, get_fallback_launcher, has_fallback +from .interface import FALLBACK_LAUNCH_MODE_NAME, LAUNCHER_CONFIG_T, DataclassProtocol + +__all__ = [ + "get_config_for", + "set_config_for", + "is_configured", + "get_launch_mode_for", + "save_config", +] + + +_CONFIG_PATH_ENV_VAR_NAME = "ANSYS_LAUNCHER_CONFIG_PATH" + + +@dataclasses.dataclass +class _ProductConfig: + launch_mode: str + configs: dict[str, Any] + + +@dataclasses.dataclass +class _LauncherConfiguration: + __root__: dict[str, _ProductConfig] + + +_CONFIG: _LauncherConfiguration | None = None + + +def get_launch_mode_for(*, product_name: str, launch_mode: str | None = None) -> str: + """Get the default launch mode configured for a product. + + Parameters + ---------- + product_name : + Product to retrieve the launch mode for. + launch_mode : + Launch mode to use. The default is ``None``, in which case the default + launch mode is used. If a launch mode is specified, this value is returned. + + Returns + ------- + str or None + Launch mode for the product. + """ + if launch_mode is not None: + return launch_mode + try: + return _get_config()[product_name].launch_mode + except KeyError as exc: + if has_fallback(product_name=product_name): + return FALLBACK_LAUNCH_MODE_NAME + raise KeyError(f"No configuration is defined for product name '{product_name}'.") from exc + + +def get_config_for(*, product_name: str, launch_mode: str | None) -> DataclassProtocol: + """Get the configuration object for a (product, launch_mode) combination. + + Get the default configuration object for the product. If a + ``launch_mode`` parameter is given, the configuration for this mode is returned. + Otherwise, the configuration for the default launch mode is returned. + + Parameters + ---------- + product_name : + Product to get the configuration for. + launch_mode : + Launch mode for the configuration. + + Returns + ------- + : + Configuration object. + + Raises + ------ + KeyError + If the requested configuration does not exist. + TypeError + If the configuration type does not match the type specified by + the launcher plugin. + """ + launch_mode = get_launch_mode_for(product_name=product_name, launch_mode=launch_mode) + + # Handle the case where the fallback launcher is used + if launch_mode == FALLBACK_LAUNCH_MODE_NAME: + return get_fallback_launcher(product_name=product_name).CONFIG_MODEL() + config_class: type[DataclassProtocol] = get_config_model(product_name=product_name, launch_mode=launch_mode) + # Handle the case where the launch mode is specified, but not configured + if not is_configured(product_name=product_name, launch_mode=launch_mode): + try: + config_entry = config_class() + except TypeError as exc: + raise RuntimeError( + f"Launch mode '{launch_mode}' for product '{product_name}' " + f"does not have a default configuration, and is not configured." + ) from exc + return config_entry + + # Handle the regular (configured) case + config_entry = _get_config()[product_name].configs[launch_mode] + if isinstance(config_entry, dict): + _get_config()[product_name].configs[launch_mode] = config_class(**config_entry) + else: + if not isinstance(config_entry, config_class): + raise TypeError(f"Configuration is wrong type '{type(config_entry)}'. Should be '{config_class}'.") + return cast(DataclassProtocol, _get_config()[product_name].configs[launch_mode]) + + +def is_configured(*, product_name: str, launch_mode: str | None = None) -> bool: + """Check if a configuration exists for the product/launch mode. + + Note that if only the fallback launcher/configuration is available, this + method returns ``False``. + + Parameters + ---------- + product_name : + Product whose configuration is checked. + launch_mode : + Launch mode whose configuration is checked. The default is ``None``, + in which case the default launch mode is used. + """ + try: + launch_mode = get_launch_mode_for(product_name=product_name, launch_mode=launch_mode) + if launch_mode == FALLBACK_LAUNCH_MODE_NAME: + return False + _get_config()[product_name].configs[launch_mode] + return True + except KeyError: + return False + + +def set_config_for( + *, + product_name: str, + launch_mode: str, + config: LAUNCHER_CONFIG_T, + overwrite_default: bool = False, +) -> None: + """Set the configuration for a given product and launch mode. + + Update the configuration by setting the configuration for the + given product and launch mode. + + This method only updates the in-memory configuration, and + it does not store it to a file. + + Parameters + ---------- + product_name : + Name of the product whose configuration to update. + launch_mode : + Launch mode that the configuration applies to. + config : + Configuration object. + overwrite_default : + Whether to change the default launch mode for the product + to the value specified for the ``launch_mode`` parameter. + """ + if is_configured(product_name=product_name): + product_config = _get_config()[product_name] + product_config.configs[launch_mode] = config + if overwrite_default: + product_config.launch_mode = launch_mode + else: + _get_config()[product_name] = _ProductConfig(launch_mode=launch_mode, configs={launch_mode: config}) + + +def save_config() -> None: + """Save the configuration to a file on disk. + + This method saves the current in-memory configuration to the ``config.json`` file. + """ + if _CONFIG is not None: + file_path = _get_config_path() + # Convert to JSON before saving; in this way, errors during + # JSON encoding will not clobber the config file. + config_json = json.dumps(dataclasses.asdict(_CONFIG)["__root__"], indent=2) + with open(file_path, "w") as out_f: + out_f.write(config_json) + + +def _get_config() -> dict[str, _ProductConfig]: + global _CONFIG + if _CONFIG is None: + _CONFIG = _load_config() + return _CONFIG.__root__ + + +def _load_config() -> _LauncherConfiguration: + config_path = _get_config_path() + if not config_path.exists(): + return _LauncherConfiguration(__root__={}) + with open(config_path) as in_f: + return _LauncherConfiguration(__root__={key: _ProductConfig(**val) for key, val in json.load(in_f).items()}) + + +def _reset_config() -> None: + global _CONFIG + _CONFIG = None + + +def _get_config_path() -> pathlib.Path: + if _CONFIG_PATH_ENV_VAR_NAME in os.environ: + config_path = pathlib.Path(os.environ[_CONFIG_PATH_ENV_VAR_NAME]) + if not config_path.parent.exists(): + raise FileNotFoundError( + f"The directory {config_path.parent} specified in the " + f"{_CONFIG_PATH_ENV_VAR_NAME} environment variable does not exist." + ) + + else: + config_path_dir = pathlib.Path(appdirs.user_config_dir("ansys_tools_local_product_launcher")) + config_path = config_path_dir / "config.json" + try: + # Set up data directory + config_path_dir.mkdir(exist_ok=True, parents=True) + except OSError as exc: + raise type(exc)( + f"Unable to create config directory '{config_path_dir}'.\n" + f"Error:\n{exc}\n\n" + "Override the default config file path by setting the environment " + f"variable '{_CONFIG_PATH_ENV_VAR_NAME}'." + ) from exc + return config_path diff --git a/src/ansys/tools/common/launcher/helpers/__init__.py b/src/ansys/tools/common/launcher/helpers/__init__.py new file mode 100644 index 00000000..0d68a1a9 --- /dev/null +++ b/src/ansys/tools/common/launcher/helpers/__init__.py @@ -0,0 +1,27 @@ +# 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. + +"""Helper modules for implementing Local Product Launcher plugins.""" + +from . import grpc, ports + +__all__ = ["grpc", "ports"] diff --git a/src/ansys/tools/common/launcher/helpers/grpc.py b/src/ansys/tools/common/launcher/helpers/grpc.py new file mode 100644 index 00000000..916cde95 --- /dev/null +++ b/src/ansys/tools/common/launcher/helpers/grpc.py @@ -0,0 +1,54 @@ +# 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. + +"""Helpers for interacting with gRPC servers.""" + +import grpc +from grpc_health.v1.health_pb2 import HealthCheckRequest, HealthCheckResponse +from grpc_health.v1.health_pb2_grpc import HealthStub + + +def check_grpc_health(channel: grpc.Channel, timeout: float | None = None) -> bool: + """Check that a gRPC server is responding to health check requests. + + Parameters + ---------- + channel : + Channel to the gRPC server. + timeout : + Timeout in seconds for the gRPC health check request. + + Returns + ------- + : + ``True`` if the health check succeeds, ``False`` otherwise. + """ + try: + res = HealthStub(channel).Check( + request=HealthCheckRequest(), + timeout=timeout, + ) + if res.status == HealthCheckResponse.ServingStatus.SERVING: + return True + except grpc.RpcError: + pass + return False diff --git a/src/ansys/tools/common/launcher/helpers/ports.py b/src/ansys/tools/common/launcher/helpers/ports.py new file mode 100644 index 00000000..5cc300ef --- /dev/null +++ b/src/ansys/tools/common/launcher/helpers/ports.py @@ -0,0 +1,49 @@ +# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Helpers for managing port assignment.""" + +from contextlib import ExitStack, closing +import socket + + +def find_free_ports(num_ports: int = 1) -> list[int]: + """Find free ports on the localhost. + + .. note:: + + Because there is no way to reserve a port that would still allow + a server to connect to it, there is no guarantee that the ports + are *still* free when eventually used. + + Parameters + ---------- + num_ports : + Number of free ports to obtain. + """ + port_list = [] + with ExitStack() as context_stack: + for _ in range(num_ports): + sock = context_stack.enter_context(closing(socket.socket())) + sock.bind(("", 0)) + port_list.append(sock.getsockname()[1]) + return port_list diff --git a/src/ansys/tools/common/launcher/interface.py b/src/ansys/tools/common/launcher/interface.py new file mode 100644 index 00000000..a7464b53 --- /dev/null +++ b/src/ansys/tools/common/launcher/interface.py @@ -0,0 +1,180 @@ +# 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. + +"""Interface definitions for implementing a local product launcher. + +A plugin for the Local Product Launcher must implement the :class:`LauncherProtocol` +class and register it. +""" + +from enum import Enum, auto +from typing import Any, ClassVar, Protocol, TypeVar + +__all__ = [ + "DataclassProtocol", + "FALLBACK_LAUNCH_MODE_NAME", + "LAUNCHER_CONFIG_T", + "LauncherProtocol", + "METADATA_KEY_DOC", + "METADATA_KEY_NOPROMPT", + "ServerType", +] + +METADATA_KEY_DOC = "launcher_doc" +"""Key used in the :py:class:`dataclasses.Field` ``metadata`` for the option description.""" + +METADATA_KEY_NOPROMPT = "launcher_noprompt" +""" +Key used in the :py:class:`dataclasses.Field` ``metadata`` to skip prompting for +the option by default. +""" + +FALLBACK_LAUNCH_MODE_NAME = "__fallback__" + + +class DataclassProtocol(Protocol): + """Provides the ``Protocol`` class for Python dataclasses.""" + + __dataclass_fields__: ClassVar[dict[str, Any]] + + +LAUNCHER_CONFIG_T = TypeVar("LAUNCHER_CONFIG_T", bound=DataclassProtocol) +# This docstring is commented-out because numpydoc causes the documentation build to fail when +# it is included. +# """Type variable for launcher configuration objects.""" + + +class ServerType(Enum): + """Defines which protocols the server supports. + + The ``ServerType`` class is used as values in the :attr:`LauncherProtocol.SERVER_SPEC` + attribute to define the capabilities of the servers started with a given product and + launch method. + """ + + GENERIC = auto() + """Generic server, which responds at a given URL and port. + + The generic server type can be used for any server. It does not + include information about which protocol should be used. + """ + + GRPC = auto() + """Server that can be accessed via gRPC. + + Servers of this type are accessible via the :attr:`.ProductInstance.channels` + attribute. + """ + + +class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): + """Interface for managing a local product instance. + + A plugin to the Local Product Launcher must implement the interface + defined in this class. + + To check for compatibility, it is recommended to derive from this + class, for example ``MyLauncher(LauncherProtocol[MyConfigModel])``, and + check the resulting code with `mypy `_. + + The ``__init__`` method should accept exactly one keyword-only + parameter: ``config``. Note that this is `not enforced by mypy + `_. + + Parameters + ---------- + config : + Configuration options used to start the product. This parameter + must be an instance of ``CONFIG_MODEL``. + """ + + CONFIG_MODEL: type[LAUNCHER_CONFIG_T] + """Defines the configuration options for the launcher. + + The configuration options which this launcher accepts, specified + as a :py:func:`dataclass `. Note that the + ``default`` and ``metadata[METADATA_KEY_DOC]`` of the fields are + used in the configuration CLI, if available. + """ + + SERVER_SPEC: dict[str, ServerType] + """Defines the server types that are started. + + Examples + -------- + This code defines a server that is accessible via a URL at the + ``"MAIN"`` key and a server accessible via gRPC at the + ``"FILE_TRANSFER"`` key. + + .. code:: python + + SERVER_SPEC = {"MAIN": ServerType.GENERIC, "FILE_TRANSFER": ServerType.GRPC} + + The :attr:`.ProductInstance.urls` attribute then has keys + ``{"MAIN", "FILE_TRANSFER"}``, whereas the + :attr:`.ProductInstance.channels` attribute has only the + key ``"FILE_TRANSFER"``. + """ + + def __init__(self, *, config: LAUNCHER_CONFIG_T): + pass + + def start(self) -> None: + """Start the product instance.""" + + def stop(self, *, timeout: float | None = None) -> None: + """Stop the product instance. + + Parameters + ---------- + timeout : + Time after which the instance can be forcefully stopped. + The timeout should be interpreted as a hint to the implementation. + It is *not required* to trigger a force-shutdown, but the stop + *must* return within a finite time. + """ + + def check(self, *, timeout: float | None = None) -> bool: + """Check if the product instance is responding to requests. + + Parameters + ---------- + timeout : + Timeout in seconds for the check. + The timeout should be interpreted as a hint to the implementation. + It is *not required* to return within the given time, but the + check *must* return within a finite time, meaning it must not + hang indefinitely. + + Returns + ------- + : + Whether the product instance is responding. + """ + + @property + def urls(self) -> dict[str, str]: + """Dictionary of URLs that the server is listening on. + + The keys of the returned dictionary must correspond to the keys + defined in the :attr:`.LauncherProtocol.SERVER_SPEC` attribute. + """ diff --git a/src/ansys/tools/common/launcher/launch.py b/src/ansys/tools/common/launcher/launch.py new file mode 100644 index 00000000..85bf45d4 --- /dev/null +++ b/src/ansys/tools/common/launcher/launch.py @@ -0,0 +1,84 @@ +# 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 a function for launching Ansys products.""" + +from __future__ import annotations + +from typing import cast + +from ._plugins import get_launcher +from .config import get_config_for, get_launch_mode_for +from .interface import LAUNCHER_CONFIG_T, LauncherProtocol +from .product_instance import ProductInstance + + +def launch_product( + product_name: str, + *, + launch_mode: str | None = None, + config: LAUNCHER_CONFIG_T | None = None, +) -> ProductInstance: + """Launch a product instance. + + Parameters + ---------- + product_name : + Name of the product to launch. + launch_mode : + Launch mode to use. The default is ``None``, in which case + the default launched mode is used. Options available + depend on the launcher plugin. + config : + Configuration to use for launching the product. The default is + ``None``, in which case the default configuration is used. + + Returns + ------- + : + Object that can be used to interact with the started product. + + Raises + ------ + TypeError + If the type of the configuration object does not match the type + requested by the launcher plugin. + """ + launch_mode = get_launch_mode_for(product_name=product_name, launch_mode=launch_mode) + + # The type of the CONFIG_MODEL is checked below, so here we can cast + # from type[LauncherProtocol[DataclassProtocol]] to type[LauncherProtocol[LAUNCHER_CONFIG_T]]. + launcher_klass = cast( + type[LauncherProtocol[LAUNCHER_CONFIG_T]], + get_launcher( + product_name=product_name, + launch_mode=launch_mode, + ), + ) + + if config is None: + config = get_config_for(product_name=product_name, launch_mode=launch_mode) # type: ignore + if not isinstance(config, launcher_klass.CONFIG_MODEL): + raise TypeError( + f"Incompatible config of type '{type(config)} is supplied. It needs '{launcher_klass.CONFIG_MODEL}'." + ) + return ProductInstance(launcher=launcher_klass(config=config)) diff --git a/src/ansys/tools/common/launcher/product_instance.py b/src/ansys/tools/common/launcher/product_instance.py new file mode 100644 index 00000000..1fc62bce --- /dev/null +++ b/src/ansys/tools/common/launcher/product_instance.py @@ -0,0 +1,197 @@ +# 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 a wrapper for interacting with launched product instances.""" + +from __future__ import annotations + +import time +from typing import Any +import weakref + +import grpc +from typing_extensions import Self + +from .interface import LAUNCHER_CONFIG_T, LauncherProtocol, ServerType + +__all__ = ["ProductInstance"] + +_GRPC_MAX_MESSAGE_LENGTH = 256 * 1024**2 # 256 MB + + +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. + + The :class:`ProductInstance` class can be used as a context manager, stopping + the instance when exiting the context. + """ + + def __init__(self, *, launcher: LauncherProtocol[LAUNCHER_CONFIG_T]): + self._launcher = launcher + 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 RuntimeError("The product instance is stopped. Cannot enter context.") + return self + + def __exit__(self, *exc: Any) -> None: + """Stop the product instance when exiting a context manager.""" + self.stop() + + def start(self: Self) -> None: + """Start the product instance. + + 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. + """ + if not self.stopped: + 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 + if urls.keys() != self._launcher.SERVER_SPEC.keys(): + raise RuntimeError( + f"The URL keys '{urls.keys()}' provided by the launcher " + f"do not match the SERVER_SPEC keys '{self._launcher.SERVER_SPEC.keys()}'." + ) + for key, server_type in self._launcher.SERVER_SPEC.items(): + if server_type == ServerType.GRPC: + self._channels[key] = grpc.insecure_channel( + urls[key], + options=[("grpc.max_receive_message_length", _GRPC_MAX_MESSAGE_LENGTH)], + ) + + def stop(self, *, timeout: float | None = None) -> None: + """Stop the product instance. + + Parameters + ---------- + 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 + ------ + RuntimeError + If the instance is already in the stopped state. + """ + if self.stopped: + raise RuntimeError("Cannot stop the server. It has already been stopped.") + self._launcher.stop(timeout=timeout) + self._finalizer.detach() + + def restart(self, stop_timeout: float | None = None) -> None: + """Stop and then start the product instance. + + Parameters + ---------- + 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 + ------ + 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() + + def check(self, timeout: float | None = None) -> bool: + """Check if all servers are responding to requests. + + Parameters + ---------- + 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. + """ + return self._launcher.check(timeout=timeout) + + def wait(self, timeout: float) -> None: + """Wait for all servers to respond. + + This method repeatedly checks if the servers are running, returning as soon + as they are all ready. + + Parameters + ---------- + timeout : + Wait time in seconds before raising an exception. + + Raises + ------ + 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 + 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 RuntimeError(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.""" + return self._launcher.urls + + @property + def stopped(self) -> bool: + """Flag indicating if the product instance is currently stopped.""" + 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) -> dict[str, grpc.Channel]: + """Channels to the gRPC servers of the product instance.""" + return self._channels diff --git a/tests/launcher/conftest.py b/tests/launcher/conftest.py new file mode 100644 index 00000000..e5ecdad9 --- /dev/null +++ b/tests/launcher/conftest.py @@ -0,0 +1,61 @@ +# 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. + +from functools import partial +import importlib.metadata +from unittest.mock import Mock + +import pytest + +from ansys.tools.common.launcher import _plugins, config +from ansys.tools.common.launcher.interface import LAUNCHER_CONFIG_T, LauncherProtocol + + +@pytest.fixture(autouse=True) +def reset_config(): + """Reset the configuration at the start of each test.""" + config._reset_config() + + +def get_mock_entrypoints_from_plugins( + target_plugins: dict[str, dict[str, LauncherProtocol[LAUNCHER_CONFIG_T]]], +): + res = [] + for product_name, launchers in target_plugins.items(): + for launch_mode, launcher_kls in launchers.items(): + mock_entrypoint = Mock(spec=importlib.metadata.EntryPoint) + mock_entrypoint.name = f"{product_name}.{launch_mode}" + mock_entrypoint.load = Mock(return_value=launcher_kls) + res.append(mock_entrypoint) + return res + + +@pytest.fixture +def monkeypatch_entrypoints_from_plugins(monkeypatch): + def inner(target_plugins): + monkeypatch.setattr( + _plugins, + "_get_entry_points", + partial(get_mock_entrypoints_from_plugins, target_plugins=target_plugins), + ) + + return inner diff --git a/tests/launcher/pkg_with_entrypoint/poetry.lock b/tests/launcher/pkg_with_entrypoint/poetry.lock new file mode 100644 index 00000000..00a0d287 --- /dev/null +++ b/tests/launcher/pkg_with_entrypoint/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9,<4.0" +content-hash = "0e29db935d270e672861caea84b94b362c1c75faf8382d9d782ce47578fb739d" diff --git a/tests/launcher/pkg_with_entrypoint/pyproject.toml b/tests/launcher/pkg_with_entrypoint/pyproject.toml new file mode 100644 index 00000000..0de3065a --- /dev/null +++ b/tests/launcher/pkg_with_entrypoint/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry] +name = "pkg_with_entrypoint" +version = "0.1.0" + +description = "Test package with entrypoint for ansys-tools-local-product-launcher" +authors = ["Ansys, Inc."] + +packages = [ + { include = "pkg_with_entrypoint", from = "src" }, +] + +[tool.poetry.dependencies] +python = ">=3.10,<4.0" + +[tool.poetry.plugins."ansys.tools.local_product_launcher.launcher"] +"pkg_with_entrypoint.test_entry_point" = "pkg_with_entrypoint:Launcher" +"pkg_with_entrypoint.__fallback__" = "pkg_with_entrypoint:Launcher" diff --git a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py new file mode 100644 index 00000000..d96bc985 --- /dev/null +++ b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py @@ -0,0 +1,25 @@ +# 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. + +from .launcher import Launcher, LauncherConfig + +__all__ = ["LauncherConfig", "Launcher"] diff --git a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py new file mode 100644 index 00000000..8c8184bd --- /dev/null +++ b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py @@ -0,0 +1,34 @@ +# 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. + +from dataclasses import dataclass + +from ansys.tools.common.launcher import interface + + +@dataclass +class LauncherConfig: + pass + + +class Launcher(interface.LauncherProtocol[LauncherConfig]): + CONFIG_MODEL = LauncherConfig diff --git a/tests/launcher/test_cli/__init__.py b/tests/launcher/test_cli/__init__.py new file mode 100644 index 00000000..dfb9d4b8 --- /dev/null +++ b/tests/launcher/test_cli/__init__.py @@ -0,0 +1,21 @@ +# 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. diff --git a/tests/launcher/test_cli/common.py b/tests/launcher/test_cli/common.py new file mode 100644 index 00000000..d5df20dd --- /dev/null +++ b/tests/launcher/test_cli/common.py @@ -0,0 +1,29 @@ +# 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. + +import json + + +def check_result_config(path, expected): + with open(path) as f: + config = json.load(f) + assert config == expected diff --git a/tests/launcher/test_cli/conftest.py b/tests/launcher/test_cli/conftest.py new file mode 100644 index 00000000..0595f875 --- /dev/null +++ b/tests/launcher/test_cli/conftest.py @@ -0,0 +1,38 @@ +# 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. + +import pytest + +from ansys.tools.common.launcher import config + +pytest.register_assert_rewrite("test_cli.common") + + +@pytest.fixture +def temp_config_file(monkeypatch, tmp_path): + output_path = tmp_path / "config.json" + + def get_config_path_patched(): + return output_path + + monkeypatch.setattr(config, "_get_config_path", get_config_path_patched) + yield output_path diff --git a/tests/launcher/test_cli/test_multiple_plugins.py b/tests/launcher/test_cli/test_multiple_plugins.py new file mode 100644 index 00000000..b9d8344d --- /dev/null +++ b/tests/launcher/test_cli/test_multiple_plugins.py @@ -0,0 +1,201 @@ +# 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. + +from dataclasses import dataclass + +from click.testing import CliRunner +import pytest + +from ansys.tools.common.launcher import _cli, _plugins, config, interface + +from .common import check_result_config + +TEST_PRODUCT_A = "PRODUCT_A" +TEST_PRODUCT_B = "PRODUCT_B" +TEST_LAUNCH_MODE_A1 = "LAUNCHER_A1" +TEST_LAUNCH_MODE_A2 = "LAUNCHER_A2" +TEST_LAUNCH_MODE_B1 = "LAUNCHER_B1" + + +@dataclass +class MockConfigA1: + field_a1: int + + +class MockLauncherA1(interface.LauncherProtocol[MockConfigA1]): + CONFIG_MODEL = MockConfigA1 + + +@dataclass +class MockConfigA2: + field_a2: int + + +class MockLauncherA2(interface.LauncherProtocol[MockConfigA2]): + CONFIG_MODEL = MockConfigA2 + + +@dataclass +class MockConfigB1: + field_b1: int + + +class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): + CONFIG_MODEL = MockConfigB1 + + +PLUGINS = { + TEST_PRODUCT_A: { + TEST_LAUNCH_MODE_A1: MockLauncherA1, + TEST_LAUNCH_MODE_A2: MockLauncherA2, + }, + TEST_PRODUCT_B: {TEST_LAUNCH_MODE_B1: MockLauncherB1}, +} + + +@pytest.fixture(autouse=True) +def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + monkeypatch_entrypoints_from_plugins(PLUGINS) + + +def test_cli_structure(): + command = _cli.build_cli(_plugins.get_all_plugins()) + assert "configure" in command.commands + configure_group = command.commands["configure"] + + assert TEST_PRODUCT_A in configure_group.commands + assert TEST_PRODUCT_B in configure_group.commands + + assert TEST_LAUNCH_MODE_A1 in configure_group.commands[TEST_PRODUCT_A].commands + assert TEST_LAUNCH_MODE_A2 in configure_group.commands[TEST_PRODUCT_A].commands + assert TEST_LAUNCH_MODE_B1 in configure_group.commands[TEST_PRODUCT_B].commands + + assert configure_group.commands[TEST_PRODUCT_A].commands[TEST_LAUNCH_MODE_A1].params[0].name == "field_a1" + + +def test_configure_single_product_launcher(temp_config_file): + cli_command = _cli.build_cli(_plugins.get_all_plugins()) + runner = CliRunner() + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, "--field_a1=1"], + ) + + assert result.exit_code == 0 + expected_config = { + TEST_PRODUCT_A: { + "configs": { + TEST_LAUNCH_MODE_A1: {"field_a1": 1}, + }, + "launch_mode": TEST_LAUNCH_MODE_A1, + } + } + check_result_config(temp_config_file, expected_config) + + +def test_configure_two_product_launchers(temp_config_file): + cli_command = _cli.build_cli(_plugins.get_all_plugins()) + runner = CliRunner() + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, "--field_a1=1"], + ) + assert result.exit_code == 0 + config._reset_config() + + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A2, "--field_a2=2"], + ) + assert result.exit_code == 0 + + expected_config = { + TEST_PRODUCT_A: { + "configs": { + TEST_LAUNCH_MODE_A1: {"field_a1": 1}, + TEST_LAUNCH_MODE_A2: {"field_a2": 2}, + }, + "launch_mode": TEST_LAUNCH_MODE_A1, + } + } + check_result_config(temp_config_file, expected_config) + + +def test_configure_two_product_launchers_overwrite(temp_config_file): + cli_command = _cli.build_cli(_plugins.get_all_plugins()) + runner = CliRunner() + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, "--field_a1=1"], + ) + assert result.exit_code == 0 + config._reset_config() + + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A2, "--field_a2=2", "--overwrite_default"], + ) + assert result.exit_code == 0 + + expected_config = { + TEST_PRODUCT_A: { + "configs": { + TEST_LAUNCH_MODE_A1: {"field_a1": 1}, + TEST_LAUNCH_MODE_A2: {"field_a2": 2}, + }, + "launch_mode": TEST_LAUNCH_MODE_A2, + } + } + check_result_config(temp_config_file, expected_config) + + +def test_configure_two_products(temp_config_file): + cli_command = _cli.build_cli(_plugins.get_all_plugins()) + runner = CliRunner() + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, "--field_a1=1"], + ) + assert result.exit_code == 0 + config._reset_config() + + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT_B, TEST_LAUNCH_MODE_B1, "--field_b1=3"], + ) + assert result.exit_code == 0 + + expected_config = { + TEST_PRODUCT_A: { + "configs": { + TEST_LAUNCH_MODE_A1: {"field_a1": 1}, + }, + "launch_mode": TEST_LAUNCH_MODE_A1, + }, + TEST_PRODUCT_B: { + "configs": { + TEST_LAUNCH_MODE_B1: {"field_b1": 3}, + }, + "launch_mode": TEST_LAUNCH_MODE_B1, + }, + } + check_result_config(temp_config_file, expected_config) diff --git a/tests/launcher/test_cli/test_single_plugin.py b/tests/launcher/test_cli/test_single_plugin.py new file mode 100644 index 00000000..26728eb0 --- /dev/null +++ b/tests/launcher/test_cli/test_single_plugin.py @@ -0,0 +1,157 @@ +# 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. + +from dataclasses import dataclass, field + +from click.testing import CliRunner +import pytest + +from ansys.tools.common.launcher import _cli, interface + +from .common import check_result_config + + +@dataclass +class MockConfig: + int_field: int + str_field: str + json_field: dict[str, str] + optional_field: str | None = None + noprompt_field: str = field(metadata={interface.METADATA_KEY_NOPROMPT: True}, default="noprompt_value") + + +class MockLauncher(interface.LauncherProtocol[MockConfig]): + CONFIG_MODEL = MockConfig + + +TEST_PRODUCT = "my_product" +TEST_LAUNCH_MODE = "my_launcher" + +EXPECTED_CONFIG = { + TEST_PRODUCT: { + "configs": { + TEST_LAUNCH_MODE: { + "int_field": 1, + "str_field": "value", + "json_field": {"a": "b"}, + "optional_field": None, + "noprompt_field": "noprompt_value", + } + }, + "launch_mode": TEST_LAUNCH_MODE, + } +} + + +@pytest.fixture +def mock_plugins(): + return {TEST_PRODUCT: {TEST_LAUNCH_MODE: MockLauncher}} + + +def test_cli_no_plugins(): + command = _cli.build_cli(dict()) + runner = CliRunner() + result = runner.invoke(command, ["configure"]) + assert result.exit_code == 0 + assert "No plugins" in result.output + + +def test_cli_mock_plugin(mock_plugins): + command = _cli.build_cli(mock_plugins) + assert "configure" in command.commands + configure_group = command.commands["configure"] + + assert len(configure_group.commands) == 1 + assert TEST_PRODUCT in configure_group.commands + + product_group = configure_group.commands[TEST_PRODUCT] + assert TEST_LAUNCH_MODE in product_group.commands + + launcher_command = product_group.commands[TEST_LAUNCH_MODE] + assert len(launcher_command.params) == 6 + assert [p.name for p in launcher_command.params] == [ + "int_field", + "str_field", + "json_field", + "optional_field", + "noprompt_field", + "overwrite_default", + ] + + +@pytest.mark.parametrize( + ["commands", "prompts"], + [ + ( + [ + "configure", + TEST_PRODUCT, + TEST_LAUNCH_MODE, + "--int_field=1", + "--str_field=value", + '--json_field={"a": "b"}', + ], + [], + ), + (["configure", "my_product", "my_launcher", "--str_field=value"], ["1", '{"a": "b"}', ""]), + (["configure", "my_product", "my_launcher", "--int_field=1"], ["value", '{"a": "b"}', ""]), + (["configure", "my_product", "my_launcher", '--json_field={"a": "b"}'], ["1", "value", ""]), + ( + [ + "configure", + "my_product", + "my_launcher", + ], + ["1", "value", '{"a": "b"}', ""], + ), + (["configure", "my_product", "my_launcher"], ["1", "value", '{"a": "b"}', ""]), + ( + ["configure", "my_product", "my_launcher", "--noprompt_field"], + ["noprompt_value", "1", "value", '{"a": "b"}', ""], + ), + ( + ["configure", "my_product", "my_launcher", "--noprompt_field=noprompt_value"], + ["1", "value", '{"a": "b"}', ""], + ), + ], +) +def test_run_cli(temp_config_file, mock_plugins, commands, prompts): + cli_command = _cli.build_cli(mock_plugins) + runner = CliRunner() + result = runner.invoke( + cli_command, + commands, + input=("\n".join(prompts) + "\n") if prompts else None, + ) + + assert result.exit_code == 0, result.output + check_result_config(temp_config_file, EXPECTED_CONFIG) + + +def test_run_cli_throws_on_incorrect_type(temp_config_file, mock_plugins): + cli_command = _cli.build_cli(mock_plugins) + runner = CliRunner() + result = runner.invoke( + cli_command, + ["configure", TEST_PRODUCT, TEST_LAUNCH_MODE, "--int_field=TEXT", "--str_field=value"], + ) + assert result.exit_code != 0 diff --git a/tests/launcher/test_entry_point.py b/tests/launcher/test_entry_point.py new file mode 100644 index 00000000..f2132b5d --- /dev/null +++ b/tests/launcher/test_entry_point.py @@ -0,0 +1,59 @@ +# 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. +import pkg_with_entrypoint + +from ansys.tools.common.launcher._plugins import ( + get_all_plugins, + get_config_model, + get_launcher, +) +from ansys.tools.common.launcher.config import get_config_for, get_launch_mode_for + + +def test_plugin_found(): + plugin_dict = get_all_plugins() + assert "pkg_with_entrypoint" in plugin_dict + assert "test_entry_point" in plugin_dict["pkg_with_entrypoint"] + + +def test_get_launcher(): + launcher = get_launcher(product_name="pkg_with_entrypoint", launch_mode="test_entry_point") + assert launcher.__name__ == "Launcher" + + +def test_fallback(): + assert get_launch_mode_for(product_name="pkg_with_entrypoint") == "__fallback__" + assert ( + get_config_for(product_name="pkg_with_entrypoint", launch_mode="__fallback__") + == pkg_with_entrypoint.LauncherConfig() + ) + assert get_launcher(product_name="pkg_with_entrypoint", launch_mode="__fallback__").__name__ == "Launcher" + + +def test_get_config_model(): + config_model = get_config_model(product_name="pkg_with_entrypoint", launch_mode="test_entry_point") + assert config_model.__name__ == "LauncherConfig" + + +def test_get_config_for_default(): + """Test that get_config_for returns the default configuration when given a launch_mode.""" + get_config_for(product_name="pkg_with_entrypoint", launch_mode="test_entry_point") diff --git a/tests/launcher/test_integration/__init__.py b/tests/launcher/test_integration/__init__.py new file mode 100644 index 00000000..dfb9d4b8 --- /dev/null +++ b/tests/launcher/test_integration/__init__.py @@ -0,0 +1,21 @@ +# 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. diff --git a/tests/launcher/test_integration/simple_test_launcher.py b/tests/launcher/test_integration/simple_test_launcher.py new file mode 100644 index 00000000..22ea4010 --- /dev/null +++ b/tests/launcher/test_integration/simple_test_launcher.py @@ -0,0 +1,87 @@ +# 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. + +import dataclasses +import pathlib +import subprocess +import sys + +import grpc + +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, + ServerType, +) + +SCRIPT_PATH = pathlib.Path(__file__).parent / "simple_test_server.py" +SERVER_KEY = "main" + + +@dataclasses.dataclass +class SimpleLauncherConfig: + script_path: str = dataclasses.field( + default=str(SCRIPT_PATH), + metadata={METADATA_KEY_DOC: "Location of the server Python script."}, + ) + + +class SimpleLauncher(LauncherProtocol[SimpleLauncherConfig]): + CONFIG_MODEL = SimpleLauncherConfig + SERVER_SPEC = {SERVER_KEY: ServerType.GRPC} + + def __init__(self, *, config: SimpleLauncherConfig): + self._script_path = config.script_path + self._process: subprocess.Popen[str] + self._url: str + + def start(self): + port = find_free_ports()[0] + self._url = f"localhost:{port}" + self._process = subprocess.Popen( + [ + sys.executable, + self._script_path, + str(port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + + def stop(self, *, timeout=None): + self._process.terminate() + try: + self._process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def check(self, *, timeout: float | None = None) -> bool: + channel = grpc.insecure_channel(self.urls[SERVER_KEY]) + return check_grpc_health(channel, timeout=timeout) + + @property + def urls(self): + return {SERVER_KEY: self._url} diff --git a/tests/launcher/test_integration/simple_test_server.py b/tests/launcher/test_integration/simple_test_server.py new file mode 100644 index 00000000..2e97e9da --- /dev/null +++ b/tests/launcher/test_integration/simple_test_server.py @@ -0,0 +1,35 @@ +# 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. + +from concurrent import futures +import sys + +import grpc +from grpc_health.v1 import health, health_pb2_grpc + +if __name__ == "__main__": + port = sys.argv[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() diff --git a/tests/launcher/test_integration/test_simple_launcher.py b/tests/launcher/test_integration/test_simple_launcher.py new file mode 100644 index 00000000..e020728d --- /dev/null +++ b/tests/launcher/test_integration/test_simple_launcher.py @@ -0,0 +1,81 @@ +# 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. + +from dataclasses import dataclass + +import pytest + +from ansys.tools.local_product_launcher import config, launch_product + +from .simple_test_launcher import SimpleLauncher, SimpleLauncherConfig + +PRODUCT_NAME = "TestProduct" +LAUNCH_MODE = "direct" + + +@dataclass +class OtherConfig: + int_attr: int + + +@pytest.fixture(autouse=True) +def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + monkeypatch_entrypoints_from_plugins({PRODUCT_NAME: {"direct": SimpleLauncher}}) + + +def test_default_config(): + config.set_config_for(product_name=PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) + server = launch_product(PRODUCT_NAME) + server.wait(timeout=10) + server.stop() + assert not server.check() + + +def test_explicit_config(): + server = launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) + server.wait(timeout=10) + server.stop() + assert not server.check() + + +def test_stop_with_timeout(): + server = launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) + server.wait(timeout=10) + server.stop(timeout=1.0) + assert not server.check() + + +def test_invalid_launch_mode_raises(): + with pytest.raises(KeyError): + launch_product(PRODUCT_NAME, launch_mode="invalid_launch_mode", config=SimpleLauncherConfig()) + + +def test_invalid_config_raises(): + with pytest.raises(TypeError): + launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=OtherConfig(int_attr=3)) + + +def test_contextmanager(): + with launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) as server: + server.wait(timeout=10) + assert server.check() + assert not server.check() diff --git a/tests/launcher/test_plugins/conftest.py b/tests/launcher/test_plugins/conftest.py new file mode 100644 index 00000000..dfb9d4b8 --- /dev/null +++ b/tests/launcher/test_plugins/conftest.py @@ -0,0 +1,21 @@ +# 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. diff --git a/tests/launcher/test_plugins/test_plugins.py b/tests/launcher/test_plugins/test_plugins.py new file mode 100644 index 00000000..03cb28ed --- /dev/null +++ b/tests/launcher/test_plugins/test_plugins.py @@ -0,0 +1,110 @@ +# 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. + +"""Tests for the 'plugins' module.""" + +from dataclasses import dataclass + +import pytest + +from ansys.tools.common.launcher import _plugins, interface + +TEST_PRODUCT_A = "PRODUCT_A" +TEST_PRODUCT_B = "PRODUCT_B" +TEST_LAUNCH_MODE_A1 = "LAUNCHER_A1" +TEST_LAUNCH_MODE_A2 = "LAUNCHER_A2" +TEST_LAUNCH_MODE_B1 = "LAUNCHER_B1" + + +@dataclass +class MockConfigA1: + pass + + +class MockLauncherA1(interface.LauncherProtocol[MockConfigA1]): + CONFIG_MODEL = MockConfigA1 + + +@dataclass +class MockConfigA2: + pass + + +class MockLauncherA2(interface.LauncherProtocol[MockConfigA2]): + CONFIG_MODEL = MockConfigA2 + + +@dataclass +class MockConfigB1: + pass + + +class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): + CONFIG_MODEL = MockConfigB1 + + +PLUGINS = { + TEST_PRODUCT_A: { + TEST_LAUNCH_MODE_A1: MockLauncherA1, + TEST_LAUNCH_MODE_A2: MockLauncherA2, + }, + TEST_PRODUCT_B: {TEST_LAUNCH_MODE_B1: MockLauncherB1}, +} + + +@pytest.fixture +def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + monkeypatch_entrypoints_from_plugins(PLUGINS) + + +def test_get_all_plugins(monkeypatch_entrypoints): + assert _plugins.get_all_plugins() == PLUGINS + + +@pytest.mark.parametrize( + "product_name,launch_mode,expected_config_model", + [ + (TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, MockConfigA1), + (TEST_PRODUCT_A, TEST_LAUNCH_MODE_A2, MockConfigA2), + (TEST_PRODUCT_B, TEST_LAUNCH_MODE_B1, MockConfigB1), + ], +) +def test_get_config_model(monkeypatch_entrypoints, product_name, launch_mode, expected_config_model): + assert _plugins.get_config_model(product_name=product_name, launch_mode=launch_mode) == expected_config_model + + +@pytest.mark.parametrize( + "product_name,launch_mode,expected_launcher", + [ + (TEST_PRODUCT_A, TEST_LAUNCH_MODE_A1, MockLauncherA1), + (TEST_PRODUCT_A, TEST_LAUNCH_MODE_A2, MockLauncherA2), + (TEST_PRODUCT_B, TEST_LAUNCH_MODE_B1, MockLauncherB1), + ], +) +def test_get_launcher(monkeypatch_entrypoints, product_name, launch_mode, expected_launcher): + assert _plugins.get_launcher(product_name=product_name, launch_mode=launch_mode) == expected_launcher + + +def test_get_launcher_inexistent(): + with pytest.raises(KeyError) as exc: + _plugins.get_launcher(product_name="does_not_exist", launch_mode="does_not_exist") + assert "No plugin found" in str(exc.value) From a303efa97c871022bf88bd358f67bdacd15d573c Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 11:41:55 +0200 Subject: [PATCH 02/17] feat: Migrate launcher --- doc/source/user_guide/index.rst | 7 + doc/source/user_guide/launcher/cli.rst | 13 + doc/source/user_guide/launcher/index.rst | 18 + .../user_guide/launcher/plugin_creation.rst | 361 ++++++++++++++++++ doc/source/user_guide/launcher/rationale.rst | 79 ++++ pyproject.toml | 1 + src/ansys/tools/common/launcher/__init__.py | 4 - .../test_integration/test_simple_launcher.py | 13 +- 8 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 doc/source/user_guide/launcher/cli.rst create mode 100644 doc/source/user_guide/launcher/index.rst create mode 100644 doc/source/user_guide/launcher/plugin_creation.rst create mode 100644 doc/source/user_guide/launcher/rationale.rst diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 54d8e565..29219c23 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -45,6 +45,13 @@ your own code. Learn how to use the report tool. + .. grid-item-card:: Ansys local launcher tool + :padding: 2 2 2 2 + :link: launcher/index + :link-type: doc + + Learn how to use the launcher tool. + .. toctree:: :hidden: :maxdepth: 3 diff --git a/doc/source/user_guide/launcher/cli.rst b/doc/source/user_guide/launcher/cli.rst new file mode 100644 index 00000000..ce436cca --- /dev/null +++ b/doc/source/user_guide/launcher/cli.rst @@ -0,0 +1,13 @@ +.. _cli: + +Command-line interface +====================== + +You use the ``ansys-launcher`` command-line interface to edit the default +launch configuration. + +Configuration options for products are defined by each product plugin. + +.. click:: ansys.tools.local_product_launcher._cli:cli + :prog: ansys-launcher + :nested: full diff --git a/doc/source/user_guide/launcher/index.rst b/doc/source/user_guide/launcher/index.rst new file mode 100644 index 00000000..21c19322 --- /dev/null +++ b/doc/source/user_guide/launcher/index.rst @@ -0,0 +1,18 @@ +User guide +---------- + +This section provides an overview of the Local Product Launcher and how to use it. + +- The :ref:`rationale` page provides a high-level overview of the problem that the + Local Product Launcher solves. +- The :ref:`cli` page describes the command-line interface. +- The :ref:`plugin_creation` page describes how to create a launcher plugin to extend + the Local Product Launcher for use with another Ansys product. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + rationale + cli + plugin_creation diff --git a/doc/source/user_guide/launcher/plugin_creation.rst b/doc/source/user_guide/launcher/plugin_creation.rst new file mode 100644 index 00000000..450944e9 --- /dev/null +++ b/doc/source/user_guide/launcher/plugin_creation.rst @@ -0,0 +1,361 @@ +.. _plugin_creation: + +Launcher plugin creation +------------------------ + +This page explains how to create a plugin for the Local Product Launcher. The plugin +in the example launches Ansys Composite PrepPost (ACP) as a subprocess. + +The Local Product Launcher defines the interface that a plugin must satisfy in the :mod:`.interface` module. + +.. note:: + + To simplify the example, the plugin business logic is kept minimal. + +.. TODO: once merged to main, link to some real plugins in the preceding note. + +Create configuration +'''''''''''''''''''' + +To start, you must create the user-definable configuration for the launcher. Because +ACP should be run as a subprocess, the path to the server binary must be defined. + +This configuration is defined as a :py:func:`dataclass `: + +.. code:: python + + from dataclasses import dataclass + + + @dataclass + class DirectLauncherConfig: + binary_path: str + +The configuration class defines a single ``binary_path`` option of type :py:class:`str`. + +Define launcher +''''''''''''''' + +Next, you must define the launcher itself. The full launcher code follows. Because +there's quite a lot going on in this code, descriptions of each part are provided. + +.. code:: python + + 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 + + + class DirectLauncher(LauncherProtocol[LauncherConfig]): + CONFIG_MODEL = DirectLauncherConfig + SERVER_SPEC = {"main": ServerType.GRPC} + + def __init__(self, *, config: DirectLaunchConfig): + self._config = config + self._url: str + self._process: subprocess.Popen[str] + + def start(self) -> None: + port = find_free_ports()[0] + self._url = f"localhost:{port}" + self._process = subprocess.Popen( + [ + self._config.binary_path, + f"--server-address=0.0.0.0:{port}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + + def stop(self, *, timeout: Optional[float] = None) -> None: + self._process.terminate() + try: + self._process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def check(self, timeout: Optional[float] = None) -> bool: + channel = grpc.insecure_channel(self.urls[ServerKey.MAIN]) + return check_grpc_health(channel=channel, timeout=timeout) + + @property + def urls(self) -> dict[str, str]: + return {"main": self._url} + + +The launcher class inherits from ``LauncherProtocol[LauncherConfig]``. This isn't a requirement, but it means a type checker like `mypy `_ can verify that the :class:`.LauncherProtocol` interface is fulfilled. + +Next, setting ``CONFIG_MODEL = DirectLauncherConfig`` connects the launcher to the configuration class. + +The subsequent line, ``SERVER_SPEC = {"main": ServerType.GRPC}``, defines which kind of servers the +product starts. Here, there's only a single server, which is accessible via gRPC. The keys in this +dictionary can be chosen arbitrarily, but they should be consistent across the launcher implementation. +Ideally, you use the key to convey some meaning. For example, ``"main"`` could refer to the main interface +to your product and ``file_transfer`` could refer to an additional service for file upload and download. + +The ``__init__`` method must accept exactly one keyword-only argument, ``config``, which contains the +configuration instance. In this example, the configuration is stored in the ``_config`` attribute. +For the ``_url`` and ``_process`` attributes, only the type is declared for the benefits of the type checker + +.. code:: python + + def __init__(self, *, config: DirectLaunchConfig): + self._config = config + self._url: str + self._process: subprocess.Popen[str] + +The core of the launcher implementation is in the ``start()`` and ``stop()`` methods: + +.. code:: python + + def start(self) -> None: + port = find_free_ports()[0] + self._url = f"localhost:{port}" + self._process = subprocess.Popen( + [ + self._config.binary_path, + f"--server-address=0.0.0.0:{port}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + +This :meth:`start()<.LauncherProtocol.start>` method selects an available port using the +:func:`.find_free_ports` function. It then starts the server as a subprocess. Note that here, the server output is simply discarded. In a real launcher, the option to redirect it (for example to a file) should be added. +The ``_url`` attribute keeps track of the URL and port that the server should be accessible on. + +The :meth:`start()<.LauncherProtocol.stop>` method terminates the subprocess: + +.. code:: python + + def stop(self, *, timeout: Optional[float] = None) -> None: + self._process.terminate() + try: + self._process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + +If your product is prone to ignoring ``SIGTERM``, you might want to add a timeout to the +:py:meth:`.wait() ` method and retry with the +:py:meth:`.kill() ` method instead of the +:py:meth:`.terminate() ` method. + +Next, you must provide a way to verify that the product has successfully launched. This is implemented +in the :meth:`check <.LauncherProtocol.check>`. Because the server implements gRPC health checking, the +:func:`.check_grpc_health` helper can be used for this purpose: + +.. code:: python + + def check(self, timeout: Optional[float] = None) -> bool: + channel = grpc.insecure_channel(self.urls["main"]) + return check_grpc_health(channel=channel, timeout=timeout) + + +Finally, the ``_url`` attribute stored in the :meth:`start() <.LauncherProtocol.start>` method must +be made available in the :attr:`urls <.LauncherProtocol.urls>` property: + +.. code:: python + + @property + def urls(self) -> dict[str, str]: + return {"main": self._url} + +Note that the return value for the ``urls`` property should adhere to the schema defined in ``SERVER_SPEC``. + +.. _entrypoint: + +Register entrypoint +''''''''''''''''''' + +Having defined all the necessary components for a Local Product Launcher plugin, you can now register the +plugin, which makes it available. You do this through the Python `entrypoints `_ +mechanism. + +You define the entrypoint in your package's build configuration. The exact syntax depends on which +packaging tool you use: + +.. .. grid:: 1 +.. :gutter: 3 + +.. tab-set:: + + .. tab-item:: setuptools + + Setuptools can accept its configuration in one of three ways. Choose the one that applies to your project: + + In a ``pyproject.toml`` file: + + .. code:: toml + + [project.entry-points."ansys.tools.local_product_launcher.launcher"] + "ACP.direct" = ":DirectLauncher" + + In a ``setup.cfg`` file: + + .. code:: cfg + + [options.entry_points] + ansys.tools.local_product_launcher.launcher = + ACP.direct = :DirectLauncher + + In a ``setup.py`` file: + + .. code:: python + + from setuptools import setup + + setup( + # ..., + entry_points={ + "ansys.tools.local_product_launcher.launcher": [ + "ACP.direct = :DirectLauncher" + ] + } + ) + + + For more information, see the `setuptools documentation `_. + + .. tab-item:: flit + + In a ``pyproject.toml`` file: + + .. code:: toml + + [project.entry-points."ansys.tools.local_product_launcher.launcher"] + "ACP.direct" = ":DirectLauncher" + + For more information, see the `flit documentation `_. + + .. tab-item:: poetry + + In a ``pyproject.toml`` file: + + .. code:: toml + + [tool.poetry.plugins."ansys.tools.local_product_launcher.launcher"] + "ACP.direct" = ":DirectLauncher" + + For more information, see 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. + +The entrypoint itself has two parts: + +- The entrypoint name ``ACP.direct`` consists of two parts: ``ACP`` is the product name, and + ``direct`` is the launch mode identifier. The name must be of this format and contain exactly + one dot ``.`` separating the two parts. +- The entrypoint value ``:DirectLauncher`` defines where the launcher + implementation is located. In other words, it must load the launcher class: + + .. code:: + + from import DirectLauncher + + +For the entrypoints to update, you must re-install your package (even if it was installed with ``pip install -e``). + +Add command-line default and description +'''''''''''''''''''''''''''''''''''''''' + +With the three preceding parts, you've successfully created a Local Product Launcher plugin. :octicon:`rocket` + +You can now improve the usability of the command line by adding a default and description to the configuration class. + +To do so, edit the ``DirectLaunchConfig`` class, using the :py:func:`dataclasses.field` function to enrich +the ``binary_path``: + +* The default value is specified as the ``default`` argument. +* The description is given in the ``metadata`` dictionary, using the special key :py:obj:`METADATA_KEY_DOC <.interface.METADATA_KEY_DOC>`. + + +.. code:: python + + import os + import dataclasses + from typing import Union + + from ansys.tools.path import get_available_ansys_installations + from ansys.tools.local_product_launcher.interface import METADATA_KEY_DOC + + + def get_default_binary_path() -> str: + try: + installations = get_available_ansys_installations() + ans_root = installations[max(installations)] + binary_path = os.path.join(ans_root, "ACP", "acp_grpcserver") + if os.name == "nt": + binary_path += ".exe" + return binary_path + except (RuntimeError, FileNotFoundError): + return "" + + + @dataclasses.dataclass + class DirectLaunchConfig: + binary_path: str = dataclasses.field( + default=get_default_binary_path(), + metadata={METADATA_KEY_DOC: "Path to the ACP gRPC server executable."}, + ) + + +For the default value, use the :py:func:`get_available_ansys_installations ` +helper to find the Ansys installation directory. + +Now, when running ``ansys-launcher configure ACP direct``, users can see and accept the default +value if they want. + +.. note:: + + If the default value is ``None``, it is converted to the string ``default`` for the + command-line interface. This allows implementing more complicated default behaviors + that may not be expressible when the command-line interface is run. + +Add a fallback launch mode +'''''''''''''''''''''''''' + +If you want to provide a fallback launch mode that can be used without any configuration, you can add +an entrypoint with the special name ``.__fallback__``. + +For example, if you wanted the ``DirectLauncher`` to be the fallback for ACP, you could add this +entrypoint: + +.. code:: toml + + [project.entry-points."ansys.tools.local_product_launcher.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. + + +Hide advanced options +''''''''''''''''''''' + +If your launcher plugin has advanced options, you can skip prompting the user for them by default. +This is done by setting the special key :py:obj:`METADATA_KEY_NOPROMPT <.interface.METADATA_KEY_NOPROMPT>` +to ``True`` in the ``metadata`` dictionary: + +.. code:: python + + import dataclasses + + from ansys.tools.local_product_launcher.interface import METADATA_KEY_NOPROMPT + + + @dataclasses.dataclass + class DirectLaunchConfig: + # <...> + environment_variables: dict[str, str] = field( + default={}, + metadata={ + METADATA_KEY_DOC: "Extra environment variables to define when launching the server.", + METADATA_KEY_NOPROMPT: True, + }, + ) diff --git a/doc/source/user_guide/launcher/rationale.rst b/doc/source/user_guide/launcher/rationale.rst new file mode 100644 index 00000000..ab04ec82 --- /dev/null +++ b/doc/source/user_guide/launcher/rationale.rst @@ -0,0 +1,79 @@ +.. _rationale: + +Rationale +--------- +This page provides a high-level overview of the problem that the +Local Product Launcher solves. + +Improvements over the status quo +'''''''''''''''''''''''''''''''' + +Currently, many PyAnsys libraries implement a launch function, which looks +something like this: + +.. code:: + + def launch_myproduct( + # + ): + # based on the arguments, figure out which launch mode should + # be used (sub-process, docker, remote, ...) + # and launch the product. + +While this approach is reasonably simple to use, it has some disadvantages: + +- It can be difficult to tell from the keyword arguments how the server is launched. +- Non-standard launch parameters must *always* be passed along to the ``launch_myproduct()`` + method. This makes examples that are generated on a continuous integration machine + non-tranferable. Users must replace the launch parameters with what is applicable to + their setups. +- Each product implements the local launcher separately, with some accidental differences. + This limits code reuse. + +Here's how the Local Product Launcher improves on the status quo: + +- The ``launch_mode()`` method is passed as an explicit argument, and all other configuration + is collected into a single object. The available configuration options **explicitly** depend + on the launch mode. +- The Local Product Launcher separates **configuration** from the **launching code** by default. + To still enable cases where multiple *different* configurations must be available at run time, + this separation is **optional**. The full configuration can still be passed to the launching code. +- The Local Product Launcher provides a common interface for implementing the launching task + and handles common tasks like ensuring that the product is closed when the Python process exits. + + It doesn't attempt to remove the *inherent* differences between launching different products. + Ultimately, control over the launch is with each specific PyAnsys library through a plugin + system. + + +Potential pitfalls +'''''''''''''''''' + +As with any attempt to standardize, there are potential pitfalls: + +.. only:: html + + .. image:: https://imgs.xkcd.com/comics/standards.png + :alt: Standards (xkcd comic) + + +.. only:: latex + + See https://xkcd.com/927/ + + +Future avenues +'''''''''''''' + +Here are some ideas for how the Local Product Launcher could evolve: + +* Add a server/daemon component that can be controlled: + + * Via the PIM API + * From the command line + +* Extend the ``helpers`` module to further ease implementing launcher plugins. + +* Implement launcher plugins separate from the product PyAnsys libraries. For + example, a ``docker-compose`` setup where all launched products share a mounted + volume is possible. diff --git a/pyproject.toml b/pyproject.toml index daebd23c..dd97f3ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "platformdirs>=3.6.0", "click>=8.1.3", # for CLI interface "scooby>=0.5.12", + "appdirs>=1.4.4", ] [project.optional-dependencies] diff --git a/src/ansys/tools/common/launcher/__init__.py b/src/ansys/tools/common/launcher/__init__.py index 07f17ee9..68d3793c 100644 --- a/src/ansys/tools/common/launcher/__init__.py +++ b/src/ansys/tools/common/launcher/__init__.py @@ -26,13 +26,9 @@ local_product_launcher """ -import importlib.metadata - from . import config, helpers, interface, product_instance from .launch import launch_product -__version__ = importlib.metadata.version(__name__.replace(".", "-")) - __all__ = [ "interface", "helpers", diff --git a/tests/launcher/test_integration/test_simple_launcher.py b/tests/launcher/test_integration/test_simple_launcher.py index e020728d..6881b11d 100644 --- a/tests/launcher/test_integration/test_simple_launcher.py +++ b/tests/launcher/test_integration/test_simple_launcher.py @@ -19,12 +19,14 @@ # 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. +"""Test module for launcher.""" from dataclasses import dataclass import pytest -from ansys.tools.local_product_launcher import config, launch_product +from ansys.tools.common.launcher import config +from ansys.tools.common.launcher.launch import launch_product from .simple_test_launcher import SimpleLauncher, SimpleLauncherConfig @@ -34,15 +36,19 @@ @dataclass class OtherConfig: + """Mock configuration class for testing.""" + int_attr: int @pytest.fixture(autouse=True) def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + """Mock the entry points for the launcher plugins.""" monkeypatch_entrypoints_from_plugins({PRODUCT_NAME: {"direct": SimpleLauncher}}) def test_default_config(): + """Test the default configuration.""" config.set_config_for(product_name=PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) server = launch_product(PRODUCT_NAME) server.wait(timeout=10) @@ -51,6 +57,7 @@ def test_default_config(): def test_explicit_config(): + """Test that the server can be launched with an explicit configuration.""" server = launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) server.wait(timeout=10) server.stop() @@ -58,6 +65,7 @@ def test_explicit_config(): def test_stop_with_timeout(): + """Test that the server can be stopped with a timeout and that it stops correctly.""" server = launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) server.wait(timeout=10) server.stop(timeout=1.0) @@ -65,16 +73,19 @@ def test_stop_with_timeout(): def test_invalid_launch_mode_raises(): + """Test that passing an invalid launch mode raises a KeyError.""" with pytest.raises(KeyError): launch_product(PRODUCT_NAME, launch_mode="invalid_launch_mode", config=SimpleLauncherConfig()) def test_invalid_config_raises(): + """Test that passing an invalid configuration type raises a TypeError.""" with pytest.raises(TypeError): launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=OtherConfig(int_attr=3)) def test_contextmanager(): + """Test that the context manager works correctly, starting and stopping the server.""" with launch_product(PRODUCT_NAME, launch_mode=LAUNCH_MODE, config=SimpleLauncherConfig()) as server: server.wait(timeout=10) assert server.check() From 584689f2ec51b86c48ab3574891736305681bb88 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 12:04:56 +0200 Subject: [PATCH 03/17] fix: Pre-commit --- src/ansys/tools/common/launcher/config.py | 4 ++-- tests/launcher/conftest.py | 4 ++++ .../src/pkg_with_entrypoint/__init__.py | 1 + .../src/pkg_with_entrypoint/launcher.py | 5 +++++ tests/launcher/test_cli/__init__.py | 1 + tests/launcher/test_cli/common.py | 5 ++++- tests/launcher/test_cli/conftest.py | 2 ++ .../test_cli/test_multiple_plugins.py | 19 +++++++++++++++++++ tests/launcher/test_cli/test_single_plugin.py | 10 ++++++++++ tests/launcher/test_entry_point.py | 6 ++++++ tests/launcher/test_integration/__init__.py | 1 + .../test_integration/simple_test_launcher.py | 10 ++++++++++ .../test_integration/simple_test_server.py | 1 + tests/launcher/test_plugins/conftest.py | 1 + tests/launcher/test_plugins/test_plugins.py | 17 +++++++++++++++++ 15 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/ansys/tools/common/launcher/config.py b/src/ansys/tools/common/launcher/config.py index 432808d4..0da3d4a0 100644 --- a/src/ansys/tools/common/launcher/config.py +++ b/src/ansys/tools/common/launcher/config.py @@ -216,7 +216,7 @@ def save_config() -> None: # Convert to JSON before saving; in this way, errors during # JSON encoding will not clobber the config file. config_json = json.dumps(dataclasses.asdict(_CONFIG)["__root__"], indent=2) - with open(file_path, "w") as out_f: + with file_path.open("w") as out_f: out_f.write(config_json) @@ -231,7 +231,7 @@ def _load_config() -> _LauncherConfiguration: config_path = _get_config_path() if not config_path.exists(): return _LauncherConfiguration(__root__={}) - with open(config_path) as in_f: + with config_path.open() as in_f: return _LauncherConfiguration(__root__={key: _ProductConfig(**val) for key, val in json.load(in_f).items()}) diff --git a/tests/launcher/conftest.py b/tests/launcher/conftest.py index e5ecdad9..fbfe57af 100644 --- a/tests/launcher/conftest.py +++ b/tests/launcher/conftest.py @@ -19,6 +19,7 @@ # 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. +"""Conftest module.""" from functools import partial import importlib.metadata @@ -39,6 +40,7 @@ def reset_config(): def get_mock_entrypoints_from_plugins( target_plugins: dict[str, dict[str, LauncherProtocol[LAUNCHER_CONFIG_T]]], ): + """Get mock entrypoints from plugins.""" res = [] for product_name, launchers in target_plugins.items(): for launch_mode, launcher_kls in launchers.items(): @@ -51,6 +53,8 @@ def get_mock_entrypoints_from_plugins( @pytest.fixture def monkeypatch_entrypoints_from_plugins(monkeypatch): + """Mock entrypoints.""" + def inner(target_plugins): monkeypatch.setattr( _plugins, diff --git a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py index d96bc985..b65f39a5 100644 --- a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py +++ b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/__init__.py @@ -19,6 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for entrypoint package.""" from .launcher import Launcher, LauncherConfig diff --git a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py index 8c8184bd..db252fff 100644 --- a/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py +++ b/tests/launcher/pkg_with_entrypoint/src/pkg_with_entrypoint/launcher.py @@ -19,6 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for package.""" from dataclasses import dataclass @@ -27,8 +28,12 @@ @dataclass class LauncherConfig: + """Launcher configuration for the package with an entry point.""" + pass class Launcher(interface.LauncherProtocol[LauncherConfig]): + """Launcher for the package with an entry point.""" + CONFIG_MODEL = LauncherConfig diff --git a/tests/launcher/test_cli/__init__.py b/tests/launcher/test_cli/__init__.py index dfb9d4b8..6d4c2eb6 100644 --- a/tests/launcher/test_cli/__init__.py +++ b/tests/launcher/test_cli/__init__.py @@ -19,3 +19,4 @@ # 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. +"""Test CLI module.""" diff --git a/tests/launcher/test_cli/common.py b/tests/launcher/test_cli/common.py index d5df20dd..0242dbb3 100644 --- a/tests/launcher/test_cli/common.py +++ b/tests/launcher/test_cli/common.py @@ -19,11 +19,14 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for common testing functionalities.""" import json +from pathlib import Path def check_result_config(path, expected): - with open(path) as f: + """Check result from configuration.""" + with Path(path).open() as f: config = json.load(f) assert config == expected diff --git a/tests/launcher/test_cli/conftest.py b/tests/launcher/test_cli/conftest.py index 0595f875..3fe6d3a9 100644 --- a/tests/launcher/test_cli/conftest.py +++ b/tests/launcher/test_cli/conftest.py @@ -19,6 +19,7 @@ # 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. +"""Conftest module.""" import pytest @@ -29,6 +30,7 @@ @pytest.fixture def temp_config_file(monkeypatch, tmp_path): + """Fixture to create a temporary configuration file for testing.""" output_path = tmp_path / "config.json" def get_config_path_patched(): diff --git a/tests/launcher/test_cli/test_multiple_plugins.py b/tests/launcher/test_cli/test_multiple_plugins.py index b9d8344d..ca511322 100644 --- a/tests/launcher/test_cli/test_multiple_plugins.py +++ b/tests/launcher/test_cli/test_multiple_plugins.py @@ -19,6 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for multiple plugins test.""" from dataclasses import dataclass @@ -38,28 +39,40 @@ @dataclass class MockConfigA1: + """Mock config.""" + field_a1: int class MockLauncherA1(interface.LauncherProtocol[MockConfigA1]): + """Mock launcher.""" + CONFIG_MODEL = MockConfigA1 @dataclass class MockConfigA2: + """Mock config.""" + field_a2: int class MockLauncherA2(interface.LauncherProtocol[MockConfigA2]): + """Mock launcher.""" + CONFIG_MODEL = MockConfigA2 @dataclass class MockConfigB1: + """Mock config.""" + field_b1: int class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): + """Mock launcher for testing CLI configuration for product B.""" + CONFIG_MODEL = MockConfigB1 @@ -74,10 +87,12 @@ class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): @pytest.fixture(autouse=True) def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + """Mock entrypoints for the plugins.""" monkeypatch_entrypoints_from_plugins(PLUGINS) def test_cli_structure(): + """Test CLI structure.""" command = _cli.build_cli(_plugins.get_all_plugins()) assert "configure" in command.commands configure_group = command.commands["configure"] @@ -93,6 +108,7 @@ def test_cli_structure(): def test_configure_single_product_launcher(temp_config_file): + """Test configuring a single product with a single launcher.""" cli_command = _cli.build_cli(_plugins.get_all_plugins()) runner = CliRunner() result = runner.invoke( @@ -113,6 +129,7 @@ def test_configure_single_product_launcher(temp_config_file): def test_configure_two_product_launchers(temp_config_file): + """Test configuring two different products with different launch modes.""" cli_command = _cli.build_cli(_plugins.get_all_plugins()) runner = CliRunner() result = runner.invoke( @@ -141,6 +158,7 @@ def test_configure_two_product_launchers(temp_config_file): def test_configure_two_product_launchers_overwrite(temp_config_file): + """Test configuring two products with overwriting configurations.""" cli_command = _cli.build_cli(_plugins.get_all_plugins()) runner = CliRunner() result = runner.invoke( @@ -169,6 +187,7 @@ def test_configure_two_product_launchers_overwrite(temp_config_file): def test_configure_two_products(temp_config_file): + """Test configuring two products.""" cli_command = _cli.build_cli(_plugins.get_all_plugins()) runner = CliRunner() result = runner.invoke( diff --git a/tests/launcher/test_cli/test_single_plugin.py b/tests/launcher/test_cli/test_single_plugin.py index 26728eb0..7d733150 100644 --- a/tests/launcher/test_cli/test_single_plugin.py +++ b/tests/launcher/test_cli/test_single_plugin.py @@ -19,6 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for testing plugins.""" from dataclasses import dataclass, field @@ -32,6 +33,8 @@ @dataclass class MockConfig: + """Mock configuration for testing CLI.""" + int_field: int str_field: str json_field: dict[str, str] @@ -40,6 +43,8 @@ class MockConfig: class MockLauncher(interface.LauncherProtocol[MockConfig]): + """Mock launcher for testing CLI configuration.""" + CONFIG_MODEL = MockConfig @@ -64,10 +69,12 @@ class MockLauncher(interface.LauncherProtocol[MockConfig]): @pytest.fixture def mock_plugins(): + """Mock plugin for testing CLI.""" return {TEST_PRODUCT: {TEST_LAUNCH_MODE: MockLauncher}} def test_cli_no_plugins(): + """Test that the CLI is built correctly with no plugins.""" command = _cli.build_cli(dict()) runner = CliRunner() result = runner.invoke(command, ["configure"]) @@ -76,6 +83,7 @@ def test_cli_no_plugins(): def test_cli_mock_plugin(mock_plugins): + """Test that the CLI is built correctly with a mock plugin.""" command = _cli.build_cli(mock_plugins) assert "configure" in command.commands configure_group = command.commands["configure"] @@ -135,6 +143,7 @@ def test_cli_mock_plugin(mock_plugins): ], ) def test_run_cli(temp_config_file, mock_plugins, commands, prompts): + """Test running the CLI.""" cli_command = _cli.build_cli(mock_plugins) runner = CliRunner() result = runner.invoke( @@ -148,6 +157,7 @@ def test_run_cli(temp_config_file, mock_plugins, commands, prompts): def test_run_cli_throws_on_incorrect_type(temp_config_file, mock_plugins): + """Test that the CLI throws an error when a field is given an incorrect type.""" cli_command = _cli.build_cli(mock_plugins) runner = CliRunner() result = runner.invoke( diff --git a/tests/launcher/test_entry_point.py b/tests/launcher/test_entry_point.py index f2132b5d..ce830033 100644 --- a/tests/launcher/test_entry_point.py +++ b/tests/launcher/test_entry_point.py @@ -19,6 +19,8 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for entry point testing.""" + import pkg_with_entrypoint from ansys.tools.common.launcher._plugins import ( @@ -30,17 +32,20 @@ def test_plugin_found(): + """Test that the plugin is found in the plugin dictionary.""" plugin_dict = get_all_plugins() assert "pkg_with_entrypoint" in plugin_dict assert "test_entry_point" in plugin_dict["pkg_with_entrypoint"] def test_get_launcher(): + """Test that the launcher can be retrieved for a specific product and launch mode.""" launcher = get_launcher(product_name="pkg_with_entrypoint", launch_mode="test_entry_point") assert launcher.__name__ == "Launcher" def test_fallback(): + """Test fallback.""" assert get_launch_mode_for(product_name="pkg_with_entrypoint") == "__fallback__" assert ( get_config_for(product_name="pkg_with_entrypoint", launch_mode="__fallback__") @@ -50,6 +55,7 @@ def test_fallback(): def test_get_config_model(): + """Test that get_config_model returns the correct configuration model.""" config_model = get_config_model(product_name="pkg_with_entrypoint", launch_mode="test_entry_point") assert config_model.__name__ == "LauncherConfig" diff --git a/tests/launcher/test_integration/__init__.py b/tests/launcher/test_integration/__init__.py index dfb9d4b8..afeb2251 100644 --- a/tests/launcher/test_integration/__init__.py +++ b/tests/launcher/test_integration/__init__.py @@ -19,3 +19,4 @@ # 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. +"""Test module.""" diff --git a/tests/launcher/test_integration/simple_test_launcher.py b/tests/launcher/test_integration/simple_test_launcher.py index 22ea4010..450221f5 100644 --- a/tests/launcher/test_integration/simple_test_launcher.py +++ b/tests/launcher/test_integration/simple_test_launcher.py @@ -19,6 +19,7 @@ # 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. +"""Simple launcher for testing module.""" import dataclasses import pathlib @@ -41,6 +42,8 @@ @dataclasses.dataclass class SimpleLauncherConfig: + """Configuration for the SimpleLauncher.""" + script_path: str = dataclasses.field( default=str(SCRIPT_PATH), metadata={METADATA_KEY_DOC: "Location of the server Python script."}, @@ -48,15 +51,19 @@ class SimpleLauncherConfig: class SimpleLauncher(LauncherProtocol[SimpleLauncherConfig]): + """Simple launcher for testing.""" + CONFIG_MODEL = SimpleLauncherConfig SERVER_SPEC = {SERVER_KEY: ServerType.GRPC} def __init__(self, *, config: SimpleLauncherConfig): + """Initialize the SimpleLauncher with the given configuration.""" self._script_path = config.script_path self._process: subprocess.Popen[str] self._url: str def start(self): + """Start the service.""" port = find_free_ports()[0] self._url = f"localhost:{port}" self._process = subprocess.Popen( @@ -71,6 +78,7 @@ def start(self): ) def stop(self, *, timeout=None): + """Stop the service.""" self._process.terminate() try: self._process.wait(timeout=timeout) @@ -79,9 +87,11 @@ def stop(self, *, timeout=None): self._process.wait() def check(self, *, timeout: float | None = None) -> bool: + """Check if the server is responding to requests.""" channel = grpc.insecure_channel(self.urls[SERVER_KEY]) return check_grpc_health(channel, timeout=timeout) @property def urls(self): + """Return the URLs of the server.""" return {SERVER_KEY: self._url} diff --git a/tests/launcher/test_integration/simple_test_server.py b/tests/launcher/test_integration/simple_test_server.py index 2e97e9da..dcd510f4 100644 --- a/tests/launcher/test_integration/simple_test_server.py +++ b/tests/launcher/test_integration/simple_test_server.py @@ -19,6 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +"""Module for test server.""" from concurrent import futures import sys diff --git a/tests/launcher/test_plugins/conftest.py b/tests/launcher/test_plugins/conftest.py index dfb9d4b8..21b6a373 100644 --- a/tests/launcher/test_plugins/conftest.py +++ b/tests/launcher/test_plugins/conftest.py @@ -19,3 +19,4 @@ # 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. +"""Conftest module.""" diff --git a/tests/launcher/test_plugins/test_plugins.py b/tests/launcher/test_plugins/test_plugins.py index 03cb28ed..5d81ae0b 100644 --- a/tests/launcher/test_plugins/test_plugins.py +++ b/tests/launcher/test_plugins/test_plugins.py @@ -37,28 +37,40 @@ @dataclass class MockConfigA1: + """Config mock.""" + pass class MockLauncherA1(interface.LauncherProtocol[MockConfigA1]): + """Launcher mock.""" + CONFIG_MODEL = MockConfigA1 @dataclass class MockConfigA2: + """Config mock.""" + pass class MockLauncherA2(interface.LauncherProtocol[MockConfigA2]): + """Launcher mock.""" + CONFIG_MODEL = MockConfigA2 @dataclass class MockConfigB1: + """Config mock.""" + pass class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): + """Launcher mock.""" + CONFIG_MODEL = MockConfigB1 @@ -73,10 +85,12 @@ class MockLauncherB1(interface.LauncherProtocol[MockConfigB1]): @pytest.fixture def monkeypatch_entrypoints(monkeypatch_entrypoints_from_plugins): + """Mock the entry points for the launcher plugins.""" monkeypatch_entrypoints_from_plugins(PLUGINS) def test_get_all_plugins(monkeypatch_entrypoints): + """Test getting all plugins.""" assert _plugins.get_all_plugins() == PLUGINS @@ -89,6 +103,7 @@ def test_get_all_plugins(monkeypatch_entrypoints): ], ) def test_get_config_model(monkeypatch_entrypoints, product_name, launch_mode, expected_config_model): + """Test getting a config model returns expected class for the given product and launch mode.""" assert _plugins.get_config_model(product_name=product_name, launch_mode=launch_mode) == expected_config_model @@ -101,10 +116,12 @@ def test_get_config_model(monkeypatch_entrypoints, product_name, launch_mode, ex ], ) def test_get_launcher(monkeypatch_entrypoints, product_name, launch_mode, expected_launcher): + """Test that getting a launcher returns the expected class for the given product and launch mode.""" assert _plugins.get_launcher(product_name=product_name, launch_mode=launch_mode) == expected_launcher def test_get_launcher_inexistent(): + """Test that getting a launcher for a non-existent product or launch mode raises error.""" with pytest.raises(KeyError) as exc: _plugins.get_launcher(product_name="does_not_exist", launch_mode="does_not_exist") assert "No plugin found" in str(exc.value) From c94c92d8120413ed66ab004d6f34871b2a082333 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 12:21:19 +0200 Subject: [PATCH 04/17] fix: Doc fixes --- doc/source/conf.py | 1 + doc/source/user_guide/index.rst | 3 ++- doc/source/user_guide/launcher/cli.rst | 2 +- doc/source/user_guide/launcher/index.rst | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index c2d52e0c..dedea902 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -113,3 +113,4 @@ typehints_defaults = "comma" # additional logos for the latex coverpage latex_additional_files = [watermark, ansys_logo_white, ansys_logo_white_cropped] +suppress_warnings = ["autoapi.python_import_resolution"] diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 29219c23..d45daf67 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -60,4 +60,5 @@ your own code. ansys_downloader ansys_exceptions versioning - report \ No newline at end of file + report + launcher/index \ No newline at end of file diff --git a/doc/source/user_guide/launcher/cli.rst b/doc/source/user_guide/launcher/cli.rst index ce436cca..9b84d707 100644 --- a/doc/source/user_guide/launcher/cli.rst +++ b/doc/source/user_guide/launcher/cli.rst @@ -1,4 +1,4 @@ -.. _cli: +.. _ref_cli: Command-line interface ====================== diff --git a/doc/source/user_guide/launcher/index.rst b/doc/source/user_guide/launcher/index.rst index 21c19322..abddb5aa 100644 --- a/doc/source/user_guide/launcher/index.rst +++ b/doc/source/user_guide/launcher/index.rst @@ -5,7 +5,7 @@ This section provides an overview of the Local Product Launcher and how to use i - The :ref:`rationale` page provides a high-level overview of the problem that the Local Product Launcher solves. -- The :ref:`cli` page describes the command-line interface. +- The :ref:`_ref_cli` page describes the command-line interface. - The :ref:`plugin_creation` page describes how to create a launcher plugin to extend the Local Product Launcher for use with another Ansys product. From 4dd8d28e8a6e30c610c7ebb027c3cef3c7c585b3 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:14:49 +0200 Subject: [PATCH 05/17] fix: Doc issues --- doc/source/conf.py | 1 + doc/source/user_guide/launcher/cli.rst | 2 +- doc/source/user_guide/launcher/index.rst | 2 +- pyproject.toml | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index dedea902..40433197 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -76,6 +76,7 @@ "sphinx_copybutton", "sphinx_design", "ansys_sphinx_theme.extension.autoapi", + "sphinx_click", # Required by local-product-launcher ] # numpydoc configuration diff --git a/doc/source/user_guide/launcher/cli.rst b/doc/source/user_guide/launcher/cli.rst index 9b84d707..543e9a92 100644 --- a/doc/source/user_guide/launcher/cli.rst +++ b/doc/source/user_guide/launcher/cli.rst @@ -8,6 +8,6 @@ launch configuration. Configuration options for products are defined by each product plugin. -.. click:: ansys.tools.local_product_launcher._cli:cli +.. click:: ansys.tools.common.launcher._cli:cli :prog: ansys-launcher :nested: full diff --git a/doc/source/user_guide/launcher/index.rst b/doc/source/user_guide/launcher/index.rst index abddb5aa..2e93afa2 100644 --- a/doc/source/user_guide/launcher/index.rst +++ b/doc/source/user_guide/launcher/index.rst @@ -5,7 +5,7 @@ This section provides an overview of the Local Product Launcher and how to use i - The :ref:`rationale` page provides a high-level overview of the problem that the Local Product Launcher solves. -- The :ref:`_ref_cli` page describes the command-line interface. +- The :ref:`ref_cli` page describes the command-line interface. - The :ref:`plugin_creation` page describes how to create a launcher plugin to extend the Local Product Launcher for use with another Ansys product. diff --git a/pyproject.toml b/pyproject.toml index dd97f3ed..0ddf5725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ doc = [ "ansys-sphinx-theme==1.5.2", "sphinx==8.2.3", "sphinx-autoapi==3.6.0", + "sphinx-click==4.4.0", "sphinx-copybutton==0.5.2", "sphinx_design==0.6.1", "sphinx-gallery==0.19.0", From ab0e19714f822d81175c434208754545dac508cd Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:24:44 +0200 Subject: [PATCH 06/17] fix: Doc dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0ddf5725..f245e838 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ tests = [ doc = [ "ansys-sphinx-theme==1.5.2", + "grpcio==1.73.0", "sphinx==8.2.3", "sphinx-autoapi==3.6.0", "sphinx-click==4.4.0", From 073c23e52436c43aef8b73e02ec198c6b031b72e Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:28:55 +0200 Subject: [PATCH 07/17] fix: Dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f245e838..c7032856 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,11 +49,13 @@ tests = [ "pyfakefs==5.8.0", "hypothesis==6.135.10", "grpcio==1.73.0", + "grpcio-health-checking==1.73.1", ] doc = [ "ansys-sphinx-theme==1.5.2", "grpcio==1.73.0", + "grpcio-health-checking==1.73.1", "sphinx==8.2.3", "sphinx-autoapi==3.6.0", "sphinx-click==4.4.0", From 9d195944e97745077ce7950a946b6bfc39b512d0 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:31:59 +0200 Subject: [PATCH 08/17] fix: Deps --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c7032856..7d2a1895 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ tests = [ doc = [ "ansys-sphinx-theme==1.5.2", "grpcio==1.73.0", - "grpcio-health-checking==1.73.1", + "grpcio-health-checking==1.73.0", "sphinx==8.2.3", "sphinx-autoapi==3.6.0", "sphinx-click==4.4.0", From 6b192270b9c6b37c127689a18ee01e574419d802 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:36:13 +0200 Subject: [PATCH 09/17] fix: supress warnings --- doc/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 40433197..789b23d5 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -114,4 +114,4 @@ typehints_defaults = "comma" # additional logos for the latex coverpage latex_additional_files = [watermark, ansys_logo_white, ansys_logo_white_cropped] -suppress_warnings = ["autoapi.python_import_resolution"] +suppress_warnings = ["autoapi.python_import_resolution", "ref.python"] From 3e75afaa0ee22c07c241ab781313fa580c206a6e Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:42:07 +0200 Subject: [PATCH 10/17] fix: Deps --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d2a1895..d5eca130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ tests = [ "pyfakefs==5.8.0", "hypothesis==6.135.10", "grpcio==1.73.0", - "grpcio-health-checking==1.73.1", + "grpcio-health-checking==1.73.0", ] doc = [ From c85f67bfe9a22f4760b59ddadc414bae1cfd97d5 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:45:53 +0200 Subject: [PATCH 11/17] fix: Dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d5eca130..7c81a443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "click>=8.1.3", # for CLI interface "scooby>=0.5.12", "appdirs>=1.4.4", + "typing-extensions>=4.5.0", ] [project.optional-dependencies] From 49a6e5b34e4fdb482b9b4de7d6533f380d01de56 Mon Sep 17 00:00:00 2001 From: afernand Date: Mon, 7 Jul 2025 13:50:13 +0200 Subject: [PATCH 12/17] fix: Modules --- tests/launcher/{test_cli => cli}/__init__.py | 0 tests/launcher/{test_cli => cli}/common.py | 0 tests/launcher/{test_cli => cli}/conftest.py | 0 tests/launcher/{test_cli => cli}/test_multiple_plugins.py | 0 tests/launcher/{test_cli => cli}/test_single_plugin.py | 0 tests/launcher/{test_integration => integration}/__init__.py | 0 .../{test_integration => integration}/simple_test_launcher.py | 0 .../{test_integration => integration}/simple_test_server.py | 0 .../{test_integration => integration}/test_simple_launcher.py | 0 tests/launcher/{test_plugins => plugins}/conftest.py | 0 tests/launcher/{test_plugins => plugins}/test_plugins.py | 0 .../integration/{test_integration.py => test_path_integration.py} | 0 12 files changed, 0 insertions(+), 0 deletions(-) rename tests/launcher/{test_cli => cli}/__init__.py (100%) rename tests/launcher/{test_cli => cli}/common.py (100%) rename tests/launcher/{test_cli => cli}/conftest.py (100%) rename tests/launcher/{test_cli => cli}/test_multiple_plugins.py (100%) rename tests/launcher/{test_cli => cli}/test_single_plugin.py (100%) rename tests/launcher/{test_integration => integration}/__init__.py (100%) rename tests/launcher/{test_integration => integration}/simple_test_launcher.py (100%) rename tests/launcher/{test_integration => integration}/simple_test_server.py (100%) rename tests/launcher/{test_integration => integration}/test_simple_launcher.py (100%) rename tests/launcher/{test_plugins => plugins}/conftest.py (100%) rename tests/launcher/{test_plugins => plugins}/test_plugins.py (100%) rename tests/path/integration/{test_integration.py => test_path_integration.py} (100%) diff --git a/tests/launcher/test_cli/__init__.py b/tests/launcher/cli/__init__.py similarity index 100% rename from tests/launcher/test_cli/__init__.py rename to tests/launcher/cli/__init__.py diff --git a/tests/launcher/test_cli/common.py b/tests/launcher/cli/common.py similarity index 100% rename from tests/launcher/test_cli/common.py rename to tests/launcher/cli/common.py diff --git a/tests/launcher/test_cli/conftest.py b/tests/launcher/cli/conftest.py similarity index 100% rename from tests/launcher/test_cli/conftest.py rename to tests/launcher/cli/conftest.py diff --git a/tests/launcher/test_cli/test_multiple_plugins.py b/tests/launcher/cli/test_multiple_plugins.py similarity index 100% rename from tests/launcher/test_cli/test_multiple_plugins.py rename to tests/launcher/cli/test_multiple_plugins.py diff --git a/tests/launcher/test_cli/test_single_plugin.py b/tests/launcher/cli/test_single_plugin.py similarity index 100% rename from tests/launcher/test_cli/test_single_plugin.py rename to tests/launcher/cli/test_single_plugin.py diff --git a/tests/launcher/test_integration/__init__.py b/tests/launcher/integration/__init__.py similarity index 100% rename from tests/launcher/test_integration/__init__.py rename to tests/launcher/integration/__init__.py diff --git a/tests/launcher/test_integration/simple_test_launcher.py b/tests/launcher/integration/simple_test_launcher.py similarity index 100% rename from tests/launcher/test_integration/simple_test_launcher.py rename to tests/launcher/integration/simple_test_launcher.py diff --git a/tests/launcher/test_integration/simple_test_server.py b/tests/launcher/integration/simple_test_server.py similarity index 100% rename from tests/launcher/test_integration/simple_test_server.py rename to tests/launcher/integration/simple_test_server.py diff --git a/tests/launcher/test_integration/test_simple_launcher.py b/tests/launcher/integration/test_simple_launcher.py similarity index 100% rename from tests/launcher/test_integration/test_simple_launcher.py rename to tests/launcher/integration/test_simple_launcher.py diff --git a/tests/launcher/test_plugins/conftest.py b/tests/launcher/plugins/conftest.py similarity index 100% rename from tests/launcher/test_plugins/conftest.py rename to tests/launcher/plugins/conftest.py diff --git a/tests/launcher/test_plugins/test_plugins.py b/tests/launcher/plugins/test_plugins.py similarity index 100% rename from tests/launcher/test_plugins/test_plugins.py rename to tests/launcher/plugins/test_plugins.py diff --git a/tests/path/integration/test_integration.py b/tests/path/integration/test_path_integration.py similarity index 100% rename from tests/path/integration/test_integration.py rename to tests/path/integration/test_path_integration.py From 62a79c239231297803b62795fd0d6b0f4018f63e Mon Sep 17 00:00:00 2001 From: afernand Date: Tue, 8 Jul 2025 12:49:44 +0200 Subject: [PATCH 13/17] fix: Test approach --- tests/launcher/conftest.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/launcher/conftest.py b/tests/launcher/conftest.py index fbfe57af..c9cf4333 100644 --- a/tests/launcher/conftest.py +++ b/tests/launcher/conftest.py @@ -23,7 +23,11 @@ from functools import partial import importlib.metadata +import pathlib +import subprocess +import sys from unittest.mock import Mock +import warnings import pytest @@ -31,6 +35,24 @@ from ansys.tools.common.launcher.interface import LAUNCHER_CONFIG_T, LauncherProtocol +def pytest_configure(config): + """Prepare the environment for testing plugins.""" + try: + # Check if the package is installed (based on its name in pyproject.toml) + importlib.metadata.version("pkg_with_entrypoint") + except importlib.metadata.PackageNotFoundError: + warnings.warn( + "WARNING: 'pkg_with_entrypoint' is not installed in the environment.\n" + "Please run: pip install -e tests/launcher/pkg_with_entrypoint", + stacklevel=1, + ) + pkg_path = pathlib.Path(__file__).parent / "launcher" / "pkg_with_entrypoint" + subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", str(pkg_path.resolve())], + check=True, + ) + + @pytest.fixture(autouse=True) def reset_config(): """Reset the configuration at the start of each test.""" From 05c7ab8bfe661073711648286d7dc8cace5c152e Mon Sep 17 00:00:00 2001 From: afernand Date: Tue, 8 Jul 2025 12:55:52 +0200 Subject: [PATCH 14/17] fix: rework CI --- .github/workflows/cicd.yml | 17 +++++++++++++++-- tests/launcher/conftest.py | 8 -------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a9e8ef4b..de95a03e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,10 +49,23 @@ jobs: ON_UBUNTU: true steps: - - name: Run tests - uses: ansys/actions/tests-pytest@v10 + - name: Checkout repository + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[tests] + pip install tests/launcher/pkg_with_entrypoint + + - name: Run tests + run: | + pytest doc-build: name: Build documentation diff --git a/tests/launcher/conftest.py b/tests/launcher/conftest.py index c9cf4333..c07aee6e 100644 --- a/tests/launcher/conftest.py +++ b/tests/launcher/conftest.py @@ -23,9 +23,6 @@ from functools import partial import importlib.metadata -import pathlib -import subprocess -import sys from unittest.mock import Mock import warnings @@ -46,11 +43,6 @@ def pytest_configure(config): "Please run: pip install -e tests/launcher/pkg_with_entrypoint", stacklevel=1, ) - pkg_path = pathlib.Path(__file__).parent / "launcher" / "pkg_with_entrypoint" - subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(pkg_path.resolve())], - check=True, - ) @pytest.fixture(autouse=True) From a380ee34488ad89494f251ab5cfec646f9592234 Mon Sep 17 00:00:00 2001 From: afernand Date: Tue, 8 Jul 2025 12:57:12 +0200 Subject: [PATCH 15/17] fix: Rework CI --- .github/workflows/run_mapdl_tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_mapdl_tests.yml b/.github/workflows/run_mapdl_tests.yml index 349bce4d..86055f53 100644 --- a/.github/workflows/run_mapdl_tests.yml +++ b/.github/workflows/run_mapdl_tests.yml @@ -34,7 +34,10 @@ jobs: with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Install library, with test extra - run: python -m pip install .[tests] + run: | + python -m pip install .[tests] + python -m pip install tests/launcher/pkg_with_entrypoint + - name: Unit testing run: | From 5bfc6b9e5245b6da2564d6285c93859a22f9114f Mon Sep 17 00:00:00 2001 From: afernand Date: Tue, 8 Jul 2025 13:44:03 +0200 Subject: [PATCH 16/17] fix: Click version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7c81a443..cc72bdef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "platformdirs>=3.6.0", - "click>=8.1.3", # for CLI interface + "click>=8.1.3,<8.2.1", # for CLI interface "scooby>=0.5.12", "appdirs>=1.4.4", "typing-extensions>=4.5.0", From ba8f54fe814d06ac596a84dcecfc3b561093da4f Mon Sep 17 00:00:00 2001 From: afernand Date: Tue, 8 Jul 2025 14:04:57 +0200 Subject: [PATCH 17/17] fix: Test --- pyproject.toml | 2 +- tests/launcher/cli/test_single_plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc72bdef..7c81a443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "platformdirs>=3.6.0", - "click>=8.1.3,<8.2.1", # for CLI interface + "click>=8.1.3", # for CLI interface "scooby>=0.5.12", "appdirs>=1.4.4", "typing-extensions>=4.5.0", diff --git a/tests/launcher/cli/test_single_plugin.py b/tests/launcher/cli/test_single_plugin.py index 7d733150..b99694eb 100644 --- a/tests/launcher/cli/test_single_plugin.py +++ b/tests/launcher/cli/test_single_plugin.py @@ -118,7 +118,7 @@ def test_cli_mock_plugin(mock_plugins): "--str_field=value", '--json_field={"a": "b"}', ], - [], + [""], ), (["configure", "my_product", "my_launcher", "--str_field=value"], ["1", '{"a": "b"}', ""]), (["configure", "my_product", "my_launcher", "--int_field=1"], ["value", '{"a": "b"}', ""]),