diff --git a/doc/changelog.d/5015.added.md b/doc/changelog.d/5015.added.md new file mode 100644 index 00000000000..18c3f91acf9 --- /dev/null +++ b/doc/changelog.d/5015.added.md @@ -0,0 +1 @@ +Connection over rest diff --git a/pyproject.toml b/pyproject.toml index e16f379d5d6..d6009a5163b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,7 +119,8 @@ markers = [ "settings_only: Read and modify the case settings only, without loading the mesh, initializing, or solving the case", "nightly: Tests that run under nightly CI", "fluent_version(version): Tests that runs with specified Fluent version", - "standalone: Tests that cannot be run within container" + "standalone: Tests that cannot be run within container", + "real_server: Tests that require a live Fluent / SimBA server" ] [tool.black] diff --git a/src/ansys/fluent/core/rest/__init__.py b/src/ansys/fluent/core/rest/__init__.py new file mode 100644 index 00000000000..5a2db2d8b6a --- /dev/null +++ b/src/ansys/fluent/core/rest/__init__.py @@ -0,0 +1,55 @@ +# Copyright (C) 2021 - 2026 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. + +"""REST-based PyFluent settings client and session. + +Standalone HTTP transport layer for PyFluent, connecting to Fluent's +embedded web server via REST. Pure HTTP/JSON — no gRPC, no protobuf, +no code-generated modules, no local settings tree. + +* :class:`~ansys.fluent.core.rest.client.FluentRestClient` – pure-Python + HTTP client using stdlib ``urllib`` only. Each method makes one HTTP + call and returns the server's JSON directly. + +* :func:`~ansys.fluent.core.rest.rest_launcher.launch_webserver` – **primary + entry point**. Spawns a local Fluent process with ``-ws -ws-port={port}``, + generates and configures the web server authentication token internally + for the subprocess, and returns a connected + :class:`~ansys.fluent.core.rest.client.FluentRestClient`. + +Example:: + + from ansys.fluent.core.rest import launch_webserver + + client = launch_webserver() + print(client.get_var("setup/models/energy/enabled")) + client.set_var("setup/models/energy/enabled", False) +""" + +from ansys.fluent.core.rest.client import FluentRestClient +from ansys.fluent.core.rest.rest_launcher import ( + launch_webserver, +) + +__all__ = [ + "FluentRestClient", + "launch_webserver", +] diff --git a/src/ansys/fluent/core/rest/client.py b/src/ansys/fluent/core/rest/client.py new file mode 100644 index 00000000000..74893432dc3 --- /dev/null +++ b/src/ansys/fluent/core/rest/client.py @@ -0,0 +1,442 @@ +# Copyright (C) 2021 - 2026 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. + +"""REST client for Fluent DataModel settings endpoints. + +This client talks to ``/api/{component}/...`` and sends +``Authorization: Bearer `` when a token is configured. +Most HTTP failures are raised as :class:`FluentRestError`. +""" + +import hashlib +import json +import logging +import ssl +import time +from typing import Any +import urllib.error +import urllib.parse +import urllib.request + +logger = logging.getLogger(__name__) + +_RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + +_RETRYABLE_STATUS_CODES = frozenset({502, 503, 504}) + + +class FluentRestError(RuntimeError): + """HTTP error raised when a Fluent REST request fails. + + This class is the **single place** that understands how to interpret + transport-level failures. It knows which HTTP status codes come from + the server vs. which originate from a broken connection, and it knows + which failures are transient enough to be worth retrying. + + Attributes + ---------- + status : int + HTTP status code. ``0`` means the request never reached the + server (connection refused, reset, DNS failure, etc.). + retryable : bool + ``True`` when the failure is transient — a 502/503/504 gateway + error or a connection-level ``OSError`` — and re-issuing the + same request has a reasonable chance of succeeding. + """ + + def __init__(self, status: int, message: str, *, retryable: bool = False) -> None: + self.status = status + self.retryable = retryable + super().__init__(f"HTTP {status}: {message}") + + @classmethod + def from_transport(cls, exc: OSError) -> "FluentRestError": + """Construct from a stdlib transport exception. + + ``urllib`` raises ``HTTPError`` (a subclass of ``OSError``) when + the server replies with an error status, and plain ``OSError`` + when the connection itself fails. This factory inspects the + exception once and produces a fully-populated domain error. + """ + if isinstance(exc, urllib.error.HTTPError): + return cls( + exc.code, + cls._read_server_message(exc), + retryable=exc.code in _RETRYABLE_STATUS_CODES, + ) + return cls(0, cls._read_connection_message(exc), retryable=True) + + @staticmethod + def _read_server_message(exc: urllib.error.HTTPError) -> str: + """Extract the plain-text body the server sent with the error.""" + raw = exc.read().decode("utf-8", errors="replace") + return raw.strip() or exc.reason + + @staticmethod + def _read_connection_message(exc: OSError) -> str: + """Produce a human-readable message from a connection failure.""" + return str(getattr(exc, "reason", exc)) + + +class FluentRestClient: + """HTTP client for the Fluent DataModel REST API. + + Parameters + ---------- + base_url : str + Root URL of the Fluent REST server, e.g. ``"http://127.0.0.1:"``. + A trailing slash is stripped automatically. + auth_token : str, optional + Raw bearer token (the password set when Fluent was started). Before + each request the token is SHA-256 hashed and sent as + ``Authorization: Bearer ``. + component : str, optional + DataModel component name. Defaults to ``"fluent_1"`` (solver). + Use ``"fluent_meshing_1"`` for a meshing session. + timeout : float, optional + Socket timeout in seconds for every request. Defaults to ``30.0``. + max_retries : int, optional + Maximum number of automatic retries on transient connection errors + (``URLError``) or HTTP 502/503/504 responses. Defaults to ``0`` + (no retries — fail immediately). + retry_delay : float, optional + Base delay in seconds between retries. Uses exponential back-off: + ``retry_delay * 2 ** attempt``. Defaults to ``1.0``. + ssl_context : ssl.SSLContext, optional + Custom SSL context for HTTPS connections. Defaults to ``None``. + """ + + def __init__( + self, + base_url: str, + *, + auth_token: str | None = None, + component: str = "fluent_1", + timeout: float = 30.0, + max_retries: int = 0, + retry_delay: float = 1.0, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._auth_token = auth_token + self._component = component + self._timeout = timeout + self._max_retries = max_retries + self._retry_delay = retry_delay + self._ssl_context = ssl_context + self._api_base = f"api/{component}" + self._is_closed = False + self._headers = self._make_auth_headers(auth_token) + + @staticmethod + def _make_auth_headers(auth_token: str | None) -> dict[str, str]: + """Return auth headers for *auth_token*, or empty dict if no token.""" + if not auth_token: + return {} + token_hash = hashlib.sha256(auth_token.encode()).hexdigest() + return {"Authorization": f"Bearer {token_hash}"} + + # ------------------------------------------------------------------ + # URL helpers + # ------------------------------------------------------------------ + + @staticmethod + def _encode_name(name: str) -> str: + """Percent-encode a single URL segment (Object name, command name).""" + return urllib.parse.quote(name, safe="") + + @staticmethod + def _encode_path(path: str) -> str: + """Percent-encode each segment of a slash-delimited path.""" + return "/".join(FluentRestClient._encode_name(seg) for seg in path.split("/")) + + def _settings_endpoint(self, path: str) -> str: + """Return the full API endpoint URL for a settings path.""" + return f"{self._api_base}/{self._encode_path(path)}" + + # ------------------------------------------------------------------ + # HTTP transport internals + # ------------------------------------------------------------------ + + def _build_request( + self, + method: str, + endpoint: str, + body: Any = None, + ) -> urllib.request.Request: + """Assemble an :class:`urllib.request.Request`. + + Serialises *body* to JSON if provided and attaches auth headers. + """ + url = f"{self._base_url}/{endpoint}" + data: bytes | None = None + headers: dict[str, str] = dict(self._headers) + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + return urllib.request.Request( + url, data=data, headers=headers, method=method.upper() + ) + + def _send_once(self, req: urllib.request.Request) -> Any: + """Execute one HTTP request and decode JSON response content. + + Returns ``None`` for empty response bodies and ``{}`` for non-JSON + non-empty bodies. + """ + with urllib.request.urlopen( + req, timeout=self._timeout, context=self._ssl_context + ) as req: # nosec B310 + raw = req.read() + if not raw.strip(): + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return {} + + def _send(self, req: urllib.request.Request) -> Any: + """Send one request, translating transport errors to FluentRestError.""" + try: + return self._send_once(req) + except OSError as exc: + raise FluentRestError.from_transport(exc) from exc + + # def _back_off(self, attempt: int) -> None: + # """Sleep for an exponentially increasing amount of time.""" + # time.sleep(self._retry_delay * (2**attempt)) + + # def _send_with_retry(self, req: urllib.request.Request, retries: int) -> Any: + # """Send a request with retry logic for retryable errors.""" + # for attempt in range(retries): + # try: + # return self._send(req) + # except FluentRestError as exc: + # if not exc.retryable and exc.status in _RETRYABLE_STATUS_CODES: + # exc.retryable = True + # self._back_off(attempt) + # return self._send(req) + + def _request( + self, + method: str, + endpoint: str, + *, + body: Any = None, + ) -> Any: + """Send an HTTP request with retry for idempotent methods only.""" + if self._is_closed: + raise FluentRestError(0, "Session is closed") + req = self._build_request(method, endpoint, body) + retries = self._max_retries if method.upper() in _RETRYABLE_METHODS else 0 + return self._send_with_retry(req, retries) + + # ------------------------------------------------------------------ + # Settings API — read / write + # ------------------------------------------------------------------ + + def get_static_info(self) -> dict[str, Any]: + """Return the full settings schema (GET static-info).""" + return self._request("GET", f"{self._api_base}/static-info") + + def get_var(self, path: str) -> Any: + """Return the value at *path* (POST ``get_var``).""" + return self._request( + "POST", f"{self._api_base}/get_var", body={"path": path.lstrip("/")} + ) + + def get_attrs(self, path: str, attrs: list[str], recursive: bool = False) -> Any: + """Return selected attributes for *path* (GET with ``attrs=...``).""" + params = {"attrs": ",".join(attrs)} + if recursive: + params["recursive"] = "true" + query = urllib.parse.urlencode(params) + return self._request("GET", f"{self._settings_endpoint(path)}?{query}") + + def get_object_names(self, path: str) -> list[str]: + """Return child object names at *path* (GET {path}); return ``[]`` on 404. + + Raises + ------ + FluentRestError + If the request fails with a non-404 HTTP error. + """ + result = self._request("GET", self._settings_endpoint(path)) + return self._names_from(result) + + def get_list_size(self, path: str) -> int: + """Return element count at *path* (GET {path}); return 0 on 404. + + Raises + ------ + FluentRestError + If the request fails with a non-404 HTTP error. + """ + result = self._request("GET", self._settings_endpoint(path)) + return self._size_from(result) + + def set_var(self, path: str, value: Any) -> None: + """Write *value* at *path* (PUT ``{path}``).""" + self._request("PUT", self._settings_endpoint(path), body=value) + + def resize_list_object(self, path: str, size: int) -> None: + """Resize the list-object at *path* to *size* elements (POST ``{path}``).""" + self._request("POST", self._settings_endpoint(path), body={"new-size": size}) + + # ------------------------------------------------------------------ + # Settings API — named objects CRUD + # ------------------------------------------------------------------ + + def create(self, path: str, name: str = "", properties: dict | None = None) -> Any: + """Create a child object at *path* (POST {path}). + + Raises + ------ + FluentRestError + If the request fails. + """ + body = dict(properties) if properties else {} + if name: + body["name"] = name + return self._request("POST", self._settings_endpoint(path), body=body) + + def delete(self, path: str, name: str, *, ignore_not_found: bool = False) -> None: + """Delete named object *name* at *path* (DELETE {path}/{name}). + + Raises + ------ + FluentRestError + If deletion fails, except when ``ignore_not_found=True`` and the + server returns HTTP 404. + """ + encoded_name = urllib.parse.quote(name, safe="") + try: + self._request("DELETE", f"{self._settings_endpoint(path)}/{encoded_name}") + except FluentRestError as exc: + if ignore_not_found and exc.status == 404: + return + raise + + def rename(self, path: str, new: str, old: str) -> None: + """Rename *old* to *new* at *path* (PUT {path}/{old}).""" + encoded_old = urllib.parse.quote(old, safe="") + self._request( + "PUT", + f"{self._settings_endpoint(path)}/{encoded_old}", + body={"name": new}, + ) + + def delete_child_objects( + self, + path: str, + obj_type: str, + child_names: list[str], + ) -> None: + """Delete specific named children of *obj_type* under *path*.""" + for name in child_names: + self.delete(f"{path}/{obj_type}", name) + + def delete_all_child_objects(self, path: str, obj_type: str) -> None: + """Delete all named children of *obj_type* under *path*.""" + names = self.get_object_names(f"{path}/{obj_type}") + self.delete_child_objects(path, obj_type, names) + + def _execute(self, path: str, name: str, **kwds) -> Any: + """POST a command/query endpoint and return the raw response payload.""" + encoded_name = self._encode_name(name) + return self._request( + "POST", + f"{self._settings_endpoint(path)}/{encoded_name}", + body=kwds, + ) + + def execute_cmd(self, path: str, command: str, force: bool = True, **kwds) -> Any: + """Execute *command* at *path*; appends ``force=true`` when requested.""" + encoded = self._encode_name(command) + endpoint = f"{self._settings_endpoint(path)}/{encoded}" + if force: + endpoint += "?force=true" + return self._request("POST", endpoint, body=kwds) + + def execute_query(self, path: str, query: str, **kwds) -> Any: + """Execute *query* at *path* (POST {path}/{query}).""" + return self._execute(path, query, **kwds) + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def exit(self) -> None: + """Request shutdown via ``POST /api/app/exit`` and mark session closed. + + HTTP 403/409 are raised to the caller. Other failures are treated as + shutdown-in-progress and suppressed. + + Raises + ------ + FluentRestError + If shutdown is blocked by the server (HTTP 403 or 409). + """ + if self._is_closed: + return + else: + self._request("POST", "api/app/exit") + self._is_closed = True + logger.info("Fluent server terminated.") + + def __enter__(self) -> "FluentRestClient": + """Enter the context manager.""" + return self + + def __exit__( + self, + ) -> None: + """Exit the context manager — calls :meth:`exit`.""" + self.exit() + + # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + + @staticmethod + def _names_from(result: Any) -> list[str]: + """Normalise a child-listing response to a plain list of names. + + The server returns either a JSON array ``["a", "b"]`` or a dict + keyed by object name ``{"a": {...}, "b": {...}}``. + """ + if isinstance(result, list): + return result + if isinstance(result, dict): + return list(result.keys()) + return [] + + @staticmethod + def _size_from(result: Any) -> int: + """Extract an element count from a list-object response. + + A list-object reports its length directly; a named-object container + may include an explicit ``size`` field or just its key count. + """ + if isinstance(result, list): + return len(result) + if isinstance(result, dict): + return result.get("size", len(result)) + return 0 diff --git a/src/ansys/fluent/core/rest/rest_launcher.py b/src/ansys/fluent/core/rest/rest_launcher.py new file mode 100644 index 00000000000..bfb650d7ef1 --- /dev/null +++ b/src/ansys/fluent/core/rest/rest_launcher.py @@ -0,0 +1,331 @@ +# Copyright (C) 2021 - 2026 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. + +"""Launch, connect, and session management for the Fluent REST transport. + +This module provides a **standalone, low-level** REST transport layer. +It does **not** build a settings tree (no ``session.settings``), expose +convenience helpers like ``read_case()``, or depend on ``flobject``. +All interaction is via explicit path-based calls (``get_var``, ``set_var``, +``execute_command``, etc.). + +Transport security +~~~~~~~~~~~~~~~~~~ +``launch_webserver()`` uses **HTTPS** when user-provided TLS certificates +are found (via the ``cert_dir`` parameter, the +``FLUENT_WEBSERVER_CERTIFICATE_ROOT`` environment variable, or the default +Fluent install path). Falls back to plain HTTP if no certificates are +available. + +Public API +---------- +* :func:`launch_webserver` — spawn Fluent with ``-ws``, returning a connected + :class:`~ansys.fluent.core.rest.client.FluentRestClient`. + +Examples +-------- +Launch a local Fluent web server and connect with a REST client:: + + from ansys.fluent.core.rest import launch_webserver + client = launch_webserver() + client.get_var("setup/models/energy/enabled") +""" + +from __future__ import annotations + +import logging +import os +import secrets +import socket +import ssl +import subprocess +import time +import urllib.error +import urllib.request + +from ansys.fluent.core.launcher.process_launch_string import get_fluent_exe_path +from ansys.fluent.core.rest.client import FluentRestClient +from ansys.fluent.core.rest.tls import _build_ssl_context, _find_cert_dir + +__all__ = ["launch_webserver"] + +logger = logging.getLogger(__name__) + +_LOCALHOST = "127.0.0.1" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _get_free_port() -> int: + """Return an available local TCP port.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((_LOCALHOST, 0)) + return sock.getsockname()[1] + except OSError as exc: + raise RuntimeError(f"No free TCP port: {exc}") from exc + + +def _generate_auth_token(nbytes: int = 32) -> str: + """Generate a cryptographically secure URL-safe auth token. + + Returns + ------- + str + A URL-safe base-64 token (43 chars for the default 32 bytes). + """ + token = secrets.token_urlsafe(nbytes) + logger.debug("Generated per-launch auth token.") + return token + + +def _wait_for_port(port: int, deadline: float) -> None: + """Block until *port* accepts TCP connections (Phase 1).""" + logger.info("[wait] Phase 1 — waiting for TCP port %d to open...", port) + while time.monotonic() < deadline: + try: + with socket.create_connection((_LOCALHOST, port), timeout=2.0): + logger.info("[wait] Port %d is open.", port) + return + except OSError: + time.sleep(2) + raise TimeoutError(f"Port {port} not open in time.") + + +def _wait_for_solver_ready( + probe_url: str, + ssl_context: ssl.SSLContext | None, + deadline: float, +) -> None: + """Block until the solver answers the readiness probe (Phase 2).""" + logger.info("[wait] Phase 2 — waiting for solver to be ready...") + while time.monotonic() < deadline: + try: + req = urllib.request.Request(probe_url, method="GET") + with urllib.request.urlopen( + req, timeout=3, context=ssl_context + ): # nosec B310 + logger.info("[wait] Solver is ready.") + return + except urllib.error.HTTPError as exc: + if exc.code == 401: + # Auth required — server and solver are fully up. + logger.info("[wait] Solver ready (HTTP 401 on probe) — proceeding.") + return + # 400 = solver still initialising; anything else = transient. + logger.debug( + "[wait] Solver not ready yet (HTTP %d) — retrying...", exc.code + ) + time.sleep(3) + except (urllib.error.URLError, OSError): + # Connection refused / reset / DNS failure — not listening yet. + time.sleep(3) + raise TimeoutError("Solver not ready in time.") + + +def _wait_for_server( + port: int, + timeout: int = 120, + ssl_context: ssl.SSLContext | None = None, +) -> None: + """Block until the Fluent web server is ready (port open, then solver up).""" + deadline = time.monotonic() + timeout + scheme = "https" if ssl_context else "http" + probe_url = f"{scheme}://{_LOCALHOST}:{port}/api/connection/run_mode" + + _wait_for_port(port, deadline) + _wait_for_solver_ready(probe_url, ssl_context, deadline) + + +def _resolve_transport_security( + cert_dir: str | None, +) -> tuple[str | None, ssl.SSLContext | None]: + """Return ``(cert_dir, ssl_context)`` for HTTPS, or ``(None, None)`` for HTTP.""" + resolved_cert_dir = _find_cert_dir(cert_dir) + if resolved_cert_dir: + ssl_ctx = _build_ssl_context(resolved_cert_dir) + logger.info("HTTPS enabled — certificates from %s", resolved_cert_dir) + return resolved_cert_dir, ssl_ctx + + logger.warning( + "No TLS certificates found. Launching Fluent in HTTP mode. " + "For HTTPS, provide webserver.crt, webserver.key, and dh.pem." + ) + return None, None + + +def _spawn_fluent( + fluent_exe: str, + dimension: str, + port: int, + auth_token: str, + cert_dir: str | None, +) -> subprocess.Popen: + """Spawn the Fluent web server process; raise if it exits immediately.""" + launch_cmd = [fluent_exe, dimension, "-ws", f"-ws-port={port}"] + logger.info("Launching Fluent: %s", launch_cmd) + + env = os.environ.copy() + env["FLUENT_WEBSERVER_TOKEN"] = auth_token + if cert_dir: + env["FLUENT_WEBSERVER_CERTIFICATE_ROOT"] = cert_dir + + process = subprocess.Popen(launch_cmd, env=env) # nosec B603 B607 + if process.poll() is not None: + raise RuntimeError(f"Fluent exited immediately (rc={process.returncode}).") + return process + + +def _connect_client( + port: int, + ssl_context: ssl.SSLContext | None, + auth_token: str, + component: str, + timeout: float, + max_retries: int, + retry_delay: float, +) -> FluentRestClient: + """Build a :class:`FluentRestClient` bound to the running server.""" + scheme = "https" if ssl_context else "http" + base_url = f"{scheme}://{_LOCALHOST}:{port}" + return FluentRestClient( + base_url, + auth_token=auth_token, + component=component, + timeout=timeout, + max_retries=max_retries, + retry_delay=retry_delay, + ssl_context=ssl_context, + ) + + +def _terminate_process(process: subprocess.Popen) -> None: + """Terminate a process, escalating to ``kill`` if it does not exit.""" + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +# --------------------------------------------------------------------------- +# Public API — launchers +# --------------------------------------------------------------------------- + + +def launch_webserver( + *, + product_version: str | None = None, + fluent_path: str | None = None, + cert_dir: str | None = None, + dimension: str = "3ddp", + start_timeout: int = 60, + component: str = "fluent_1", + timeout: float = 30.0, + max_retries: int = 0, + retry_delay: float = 1.0, +) -> FluentRestClient: + """Launch a local Fluent process with the embedded web server. + + Discovers user-provided TLS certificates and launches Fluent with + HTTPS when found, otherwise falls back to plain HTTP. + + Parameters + ---------- + product_version : str, optional + Fluent version, e.g. ``"261"``. + fluent_path : str, optional + Explicit path to the Fluent executable. + cert_dir : str, optional + Path to a directory containing ``webserver.crt``, + ``webserver.key``, and ``dh.pem``. Takes precedence over the + ``FLUENT_WEBSERVER_CERTIFICATE_ROOT`` environment variable and + the default Fluent install path. If no certificates are found + from any source, Fluent starts in HTTP mode. + dimension : str, optional + Solver dimension. Defaults to ``"3ddp"``. + start_timeout : int, optional + Max seconds to wait for the server. Defaults to ``60``. + component : str, optional + DataModel component. Defaults to ``"fluent_1"``. + timeout : float, optional + HTTP timeout in seconds. Defaults to ``30.0``. + max_retries : int, optional + Retries on transient errors. Defaults to ``0``. + retry_delay : float, optional + Base retry delay in seconds. Defaults to ``1.0``. + + Returns + ------- + FluentRestClient + + Raises + ------ + RuntimeError + If the Fluent process exits immediately after spawning. + FileNotFoundError + If the Fluent executable cannot be located. + TimeoutError + If the web server does not start within *start_timeout* seconds. + Exception + Any exception during server connection is re-raised after + terminating the spawned process. + """ + # 1 — generate a fresh per-launch auth token + auth_token = _generate_auth_token() + + # 2 — discover user-provided TLS certificates + resolved_cert_dir, ssl_ctx = _resolve_transport_security(cert_dir) + + # 3 — discover a free local TCP port (pure stdlib) + port = _get_free_port() + logger.info("Discovered free port %d for Fluent web server.", port) + + # 4 — resolve the Fluent executable + fluent_exe = str( + get_fluent_exe_path(product_version=product_version, fluent_path=fluent_path) + ) + + # 5 — build the launch command and spawn Fluent + process = _spawn_fluent(fluent_exe, dimension, port, auth_token, resolved_cert_dir) + + # 6 — wait for the web server and construct the session + try: + _wait_for_server(port, timeout=start_timeout, ssl_context=ssl_ctx) + return _connect_client( + port=port, + ssl_context=ssl_ctx, + auth_token=auth_token, + component=component, + timeout=timeout, + max_retries=max_retries, + retry_delay=retry_delay, + ) + except Exception: + logger.exception( + "Failed after launching Fluent (pid=%d) — terminating.", process.pid + ) + _terminate_process(process) + raise diff --git a/src/ansys/fluent/core/rest/tls.py b/src/ansys/fluent/core/rest/tls.py new file mode 100644 index 00000000000..bab94bda1af --- /dev/null +++ b/src/ansys/fluent/core/rest/tls.py @@ -0,0 +1,199 @@ +# Copyright (C) 2021 - 2026 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. + +"""TLS certificate discovery and verification for the Fluent REST transport. + +This module does **not** generate certificates — that is the user's +responsibility. Fluent's embedded web server expects the following files +in a certificate directory: + +* ``webserver.crt`` — the SSL certificate file +* ``webserver.key`` — the corresponding private key file +* ``dh.pem`` — the DH parameter file + +The certificate directory is resolved in the following order: + +1. An explicit ``cert_dir`` parameter passed to :func:`_find_cert_dir`. +2. The ``FLUENT_WEBSERVER_CERTIFICATE_ROOT`` environment variable. +3. The default location inside the Fluent installation: + ``/FluidsOne/web/certificate/`` + +If none of the above provides valid certificate files, the web server +starts in plain HTTP mode. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import ssl + +logger = logging.getLogger(__name__) + +# Files that Fluent's embedded web server expects. +_REQUIRED_CERT_FILES = ("webserver.crt", "webserver.key", "dh.pem") + + +def _find_cert_dir(cert_dir: str | None = None) -> str | None: + """Discover a certificate directory containing all required files. + + Resolution order: + + 1. Explicit *cert_dir* parameter (highest priority). + 2. ``FLUENT_WEBSERVER_CERTIFICATE_ROOT`` environment variable. + 3. Default Fluent install path ``/FluidsOne/web/certificate/``. + + Parameters + ---------- + cert_dir : str, optional + Explicit path to a certificate directory. When provided and + valid, it takes precedence over all other sources. + + Returns + ------- + str or None + Absolute path to the certificate directory, or ``None`` if no + valid directory was found. + """ + # 1. Explicit parameter + if cert_dir and _verify_cert_dir(cert_dir): + logger.info("Using certificates from explicit cert_dir: %s", cert_dir) + return str(Path(cert_dir).resolve()) + + if cert_dir: + logger.warning( + "Explicit cert_dir='%s' but required files missing (%s).", + cert_dir, + ", ".join(_REQUIRED_CERT_FILES), + ) + + # 2. Environment variable + env_dir = os.environ.get("FLUENT_WEBSERVER_CERTIFICATE_ROOT") + if env_dir and _verify_cert_dir(env_dir): + logger.info( + "Using certificates from FLUENT_WEBSERVER_CERTIFICATE_ROOT: %s", + env_dir, + ) + return str(Path(env_dir).resolve()) + + if env_dir: + logger.warning( + "FLUENT_WEBSERVER_CERTIFICATE_ROOT='%s' but required files " + "missing (%s).", + env_dir, + ", ".join(_REQUIRED_CERT_FILES), + ) + + # 3. Default Fluent installation path via AWP_ROOTnnn + default_dir = _get_default_cert_dir() + if default_dir and _verify_cert_dir(default_dir): + logger.info("Using certificates from default Fluent path: %s", default_dir) + return str(Path(default_dir).resolve()) + + return None + + +def _get_default_cert_dir() -> str | None: + """Return the default certificate directory from the Fluent install. + + Scans ``AWP_ROOTnnn`` environment variables (highest version first) + and returns ``/FluidsOne/web/certificate/`` if it exists. + + Returns + ------- + str or None + Path to the default certificate directory, or ``None``. + """ + awp_vars = sorted( + ( + (k, v) + for k, v in os.environ.items() + if k.startswith("AWP_ROOT") and k[8:].isdigit() + ), + key=lambda kv: int(kv[0][8:]), + reverse=True, + ) + for var_name, awp_root in awp_vars: + cert_path = Path(awp_root) / "FluidsOne" / "web" / "certificate" + if cert_path.is_dir(): + logger.debug("Found default cert dir via %s: %s", var_name, cert_path) + return str(cert_path) + return None + + +def _verify_cert_dir(cert_dir: str) -> bool: + """Return ``True`` if *cert_dir* contains all required certificate files. + + Required files: ``webserver.crt``, ``webserver.key``, ``dh.pem``. + + Parameters + ---------- + cert_dir : str + Path to the directory to check. + + Returns + ------- + bool + """ + d = Path(cert_dir) + if not d.is_dir(): + return False + missing = [f for f in _REQUIRED_CERT_FILES if not (d / f).is_file()] + if missing: + logger.debug("Cert dir '%s' missing files: %s", cert_dir, missing) + return False + return True + + +def _build_ssl_context(cert_dir: str) -> ssl.SSLContext: + """Build an SSL context from the certificates in *cert_dir*. + + Loads ``webserver.crt`` as the CA trust anchor so that the client + trusts the server's self-signed certificate. + + Parameters + ---------- + cert_dir : str + Directory containing ``webserver.crt``, ``webserver.key``, + ``dh.pem``. + + Returns + ------- + ssl.SSLContext + + Raises + ------ + FileNotFoundError + If any required file is missing from *cert_dir*. + ssl.SSLError + If the certificate files are invalid or cannot be loaded. + """ + cert_path = Path(cert_dir) + for name in _REQUIRED_CERT_FILES: + f = cert_path / name + if not f.is_file(): + raise FileNotFoundError(f"Required certificate file not found: {f}") + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(str(cert_path / "webserver.crt")) + logger.debug("SSL context built from certificates in %s", cert_dir) + return ctx diff --git a/tests/conftest.py b/tests/conftest.py index 1b14fbf8c3d..c574696f780 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,12 +22,15 @@ from contextlib import nullcontext import functools +import hashlib import inspect import operator import os from pathlib import Path import shutil +import ssl import sys +import urllib.request from packaging.specifiers import SpecifierSet from packaging.version import Version @@ -524,3 +527,98 @@ def datamodel_api_version_all(request, monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def datamodel_api_version_new(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("REMOTING_NEW_DM_API", "1") + + +# --------------------------------------------------------------------------- +# REST transport fixtures (real-server integration tests) +# --------------------------------------------------------------------------- + + +def _get_rest_env() -> dict[str, str]: + """Read REST env vars at call time (not import time). + + Returns a dict with keys: token, port_str, host, component, scheme. + """ + return { + "token": os.environ.get("FLUENT_WEBSERVER_TOKEN", ""), + "port_str": os.environ.get("FLUENT_REST_PORT", ""), + "host": os.environ.get("FLUENT_REST_HOST", "127.0.0.1"), + "component": os.environ.get("FLUENT_REST_COMPONENT", "fluent_1"), + "scheme": os.environ.get("FLUENT_REST_SCHEME", "http"), + } + + +def _rest_env_vars_present() -> bool: + """Return ``True`` when mandatory REST env vars are set.""" + env = _get_rest_env() + return bool(env["token"] and env["port_str"]) + + +def _parse_rest_port() -> int | None: + """Parse ``FLUENT_REST_PORT`` as an integer, or return ``None``.""" + port_str = os.environ.get("FLUENT_REST_PORT", "") + try: + return int(port_str) + except ValueError: + return None + + +def _rest_server_reachable() -> bool: + """Return ``True`` if the real REST server responds to a probe.""" + if not _rest_env_vars_present(): + return False + port = _parse_rest_port() + if port is None: + return False + env = _get_rest_env() + + url = f"{env['scheme']}://{env['host']}:{port}/api/connection/run_mode" + req = urllib.request.Request(url, method="GET") + req.add_header( + "Authorization", + f"Bearer {hashlib.sha256(env['token'].encode()).hexdigest()}", + ) + # Support self-signed certs for HTTPS probes + ssl_ctx = None + if env["scheme"] == "https": + ssl_ctx = ssl.create_default_context() + cert_path = os.environ.get("FLUENT_REST_CA_CERT", "") + if cert_path and os.path.isfile(cert_path): + ssl_ctx.load_verify_locations(cert_path) + else: + # Self-signed / dev certs — skip verification for probe only + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + try: + with urllib.request.urlopen(req, timeout=3, context=ssl_ctx): # nosec B310 + return True + except Exception: + return False + + +@pytest.fixture(scope="module") +def real_client(): + """Provide a :class:`FluentRestClient` connected to a live REST server. + + Auto-skips when ``FLUENT_WEBSERVER_TOKEN`` / ``FLUENT_REST_PORT`` are + unset or the server is unreachable. + """ + from ansys.fluent.core.rest.client import FluentRestClient + + env = _get_rest_env() + if not _rest_env_vars_present(): + pytest.skip( + "REST env vars not set — set FLUENT_WEBSERVER_TOKEN and " + "FLUENT_REST_PORT to run real-server tests." + ) + port = _parse_rest_port() + if port is None: + pytest.skip(f"FLUENT_REST_PORT={env['port_str']!r} is not a valid integer.") + if not _rest_server_reachable(): + pytest.skip(f"REST server at {env['host']}:{port} not reachable.") + base_url = f"{env['scheme']}://{env['host']}:{port}" + return FluentRestClient( + base_url, + auth_token=env["token"], + component=env["component"], + ) diff --git a/tests/test_rest.py b/tests/test_rest.py new file mode 100644 index 00000000000..757a8aa4df4 --- /dev/null +++ b/tests/test_rest.py @@ -0,0 +1,547 @@ +# Copyright (C) 2021 - 2026 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. + +"""Pytest tests against a live Fluent REST server. + +All tests here are marked ``real_server`` and are **skipped automatically** +when the real server is not reachable (the ``real_client`` fixture in +``conftest.py`` handles the skip logic). + +Run real-server tests:: + + pytest tests/test_rest.py -v -m real_server + +Tests are **case-agnostic** — they validate types, structure, and API +contracts dynamically. No boundary-condition names, model values, or +object counts are hardcoded. + +Path format: Real Fluent uses **kebab-case** (e.g. ``boundary-conditions``). +""" + +import io +import json +from unittest.mock import MagicMock, patch +import urllib.error + +import pytest + +from ansys.fluent.core.rest.client import FluentRestClient, FluentRestError + +pytestmark = pytest.mark.real_server + +_BASE_URL = "http://127.0.0.1:5000" + + +def _make_response(body: object, status: int = 200) -> MagicMock: + """Return a mock suitable for ``urllib.request.urlopen`` context manager.""" + raw = json.dumps(body).encode("utf-8") + resp = MagicMock() + resp.read.return_value = raw + resp.status = status + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def _make_http_error( + status: int, body: object | None = None, reason: str = "Error" +) -> urllib.error.HTTPError: + """Construct an ``HTTPError`` with a readable body.""" + data = json.dumps(body).encode("utf-8") if body else b"" + return urllib.error.HTTPError( + url=_BASE_URL, code=status, msg=reason, hdrs={}, fp=io.BytesIO(data) + ) + + +def _client(**kwargs) -> FluentRestClient: + """Convenience constructor with sensible defaults.""" + kwargs.setdefault("auth_token", "tok123") + return FluentRestClient(_BASE_URL, **kwargs) + + +# --------------------------------------------------------------------------- +# 1. get_static_info +# --------------------------------------------------------------------------- + + +class TestRealStaticInfo: + """GET /api/fluent_1/static-info""" + + def test_returns_dict(self, real_client): + """Verify that ``get_static_info()`` returns a dictionary.""" + info = real_client.get_static_info() + assert isinstance(info, dict) + + def test_root_type_is_group(self, real_client): + """Verify that the root of the settings tree is a 'group'.""" + info = real_client.get_static_info() + assert info.get("type") == "group" + + def test_has_setup_and_solution(self, real_client): + """Verify that 'setup' and 'solution' are top-level children.""" + info = real_client.get_static_info() + children = set(info.get("children", {}).keys()) + assert "setup" in children + assert "solution" in children + + def test_setup_has_models(self, real_client): + """Verify that 'setup' contains 'models'.""" + info = real_client.get_static_info() + setup_children = info["children"]["setup"].get("children", {}) + assert "models" in setup_children + + def test_setup_has_boundary_conditions(self, real_client): + """Verify that 'setup' contains 'boundary-conditions'.""" + info = real_client.get_static_info() + setup_children = info["children"]["setup"].get("children", {}) + assert "boundary-conditions" in setup_children + + +# --------------------------------------------------------------------------- +# 2. get_var — read settings +# --------------------------------------------------------------------------- + + +class TestRealGetVar: + """POST /api/{component}/get_var — body: {"path": ""}""" + + def test_energy_enabled_is_bool(self, real_client): + """Verify that reading the energy model state returns a boolean.""" + val = real_client.get_var("setup/models/energy/enabled") + assert isinstance(val, bool) + + def test_viscous_model_is_string(self, real_client): + """Verify that reading the viscous model returns a non-empty string.""" + val = real_client.get_var("setup/models/viscous/model") + assert isinstance(val, str) + assert len(val) > 0 + + def test_solver_time_is_string(self, real_client): + """Verify that reading the solver time returns a non-empty string.""" + val = real_client.get_var("setup/general/solver/time") + assert isinstance(val, str) + assert len(val) > 0 + + def test_solver_group_returns_dict(self, real_client): + """Verify that reading a settings group returns a dictionary.""" + val = real_client.get_var("setup/general/solver") + assert isinstance(val, dict) + assert "time" in val + + def test_nonexistent_path_raises_error(self, real_client): + """Verify that reading a nonexistent path raises an error.""" + with pytest.raises(FluentRestError) as exc_info: + real_client.get_var("setup/nonexistent/fake") + assert exc_info.value.status in (404, 500) + + def test_solution_run_calculation_is_dict(self, real_client): + """Verify that reading a command group returns a dictionary.""" + val = real_client.get_var("solution/run-calculation") + assert isinstance(val, dict) + + +# --------------------------------------------------------------------------- +# 3. set_var — write settings (read-modify-restore pattern) +# --------------------------------------------------------------------------- + + +class TestRealSetVar: + """PUT /api/fluent_1/{path}""" + + def test_set_and_restore_bool(self, real_client): + """Toggle energy enabled, verify change, then restore original.""" + path = "setup/models/energy/enabled" + original = real_client.get_var(path) + assert isinstance(original, bool) + + toggled = not original + real_client.set_var(path, toggled) + try: + readback = real_client.get_var(path) + assert ( + readback == toggled + ), f"set_var did not take effect: expected {toggled}, got {readback}" + finally: + # Restore + real_client.set_var(path, original) + restored = real_client.get_var(path) + assert restored == original + + def test_write_same_value_round_trips(self, real_client): + """Writing the current value back should succeed or raise a + validation error — both are acceptable.""" + path = "setup/general/solver/time" + current = real_client.get_var(path) + try: + real_client.set_var(path, current) + readback = real_client.get_var(path) + assert readback == current + except FluentRestError as exc: + assert exc.status in (500, 409) + + +# --------------------------------------------------------------------------- +# 4. get_object_names — named-object containers (dynamic) +# --------------------------------------------------------------------------- + + +class TestRealGetObjectNames: + """GET /api/fluent_1/{path} — returns dict with names as keys.""" + + def test_velocity_inlet_returns_string_list(self, real_client): + """Verify that a named-object container returns a list of strings.""" + names = real_client.get_object_names("setup/boundary-conditions/velocity-inlet") + assert isinstance(names, list) + assert len(names) > 0 + assert all(isinstance(n, str) for n in names) + + def test_pressure_outlet_returns_list(self, real_client): + """Verify that another named-object container also returns a list.""" + names = real_client.get_object_names( + "setup/boundary-conditions/pressure-outlet" + ) + assert isinstance(names, list) + assert len(names) > 0 + + def test_wall_returns_list(self, real_client): + """Verify that the 'wall' container returns a list of names when present.""" + names = real_client.get_object_names("setup/boundary-conditions/wall") + assert isinstance(names, list) + assert all(isinstance(n, str) for n in names) + + def test_unknown_path_returns_empty(self, real_client): + """Verify that a nonexistent container path returns an empty list.""" + names = real_client.get_object_names( + "setup/boundary-conditions/nonexistent-bc-type" + ) + assert names == [] + + def test_no_duplicates(self, real_client): + """Verify that object names within a container are unique.""" + names = real_client.get_object_names("setup/boundary-conditions/velocity-inlet") + assert len(names) == len(set(names)) + + +# --------------------------------------------------------------------------- +# 5. get_list_size — cross-validated against get_object_names +# --------------------------------------------------------------------------- + + +class TestRealGetListSize: + """GET /api/fluent_1/{path} — count object keys.""" + + def test_velocity_inlet_size_positive(self, real_client): + """Verify that a named-object container has a positive size.""" + size = real_client.get_list_size("setup/boundary-conditions/velocity-inlet") + assert isinstance(size, int) + assert size > 0 + + def test_size_matches_object_names(self, real_client): + """Verify that get_list_size agrees with len(get_object_names).""" + path = "setup/boundary-conditions/wall" + size = real_client.get_list_size(path) + names = real_client.get_object_names(path) + assert size == len(names) + + def test_unknown_path_returns_zero(self, real_client): + """Verify that a nonexistent path returns a size of zero.""" + size = real_client.get_list_size("setup/nonexistent/fake") + assert size == 0 + + +# --------------------------------------------------------------------------- +# 6. get_attrs — dynamic validation +# --------------------------------------------------------------------------- + + +class TestRealGetAttrs: + """GET /api/fluent_1/{path}?attrs=... — attribute retrieval.""" + + def test_allowed_values_is_nonempty_string_list(self, real_client): + """allowed-values must be a non-empty list of strings.""" + result = real_client.get_attrs("setup/models/viscous/model", ["allowed-values"]) + assert isinstance(result, dict) + attrs = result.get("attrs", {}) + allowed = attrs.get("allowed-values", []) + assert isinstance(allowed, list) + assert len(allowed) > 0 + assert all(isinstance(v, str) for v in allowed) + + def test_current_value_in_allowed_values(self, real_client): + """The current viscous model must be one of its allowed values.""" + current = real_client.get_var("setup/models/viscous/model") + result = real_client.get_attrs("setup/models/viscous/model", ["allowed-values"]) + allowed = result.get("attrs", {}).get("allowed-values", []) + assert ( + current in allowed + ), f"Current model '{current}' not in allowed values: {allowed}" + + def test_set_var_respects_allowed_values(self, real_client): + """Pick a different allowed value, set it, verify, restore.""" + path = "setup/models/viscous/model" + original = real_client.get_var(path) + result = real_client.get_attrs(path, ["allowed-values"]) + allowed = result.get("attrs", {}).get("allowed-values", []) + + alternatives = [v for v in allowed if v != original] + if not alternatives: + pytest.skip("Only one allowed viscous model — nothing to toggle") + + new_value = alternatives[0] + try: + real_client.set_var(path, new_value) + readback = real_client.get_var(path) + assert readback == new_value + except FluentRestError as exc: + if getattr(exc, "status", None) in (400, 409): + pytest.skip( + f"Solver rejected allowed value '{new_value}' for '{path}' " + f"due to runtime constraints: {exc}" + ) + pytest.fail( + f"Unexpected REST failure while setting allowed value '{new_value}' " + f"for '{path}': {exc}" + ) + finally: + try: + real_client.set_var(path, original) + except FluentRestError as exc: + pytest.fail( + f"Failed to restore '{path}' to original value " + f"'{original}': {exc}" + ) + + +# --------------------------------------------------------------------------- +# 7. execute_cmd — command execution +# --------------------------------------------------------------------------- + + +class TestRealExecuteCmd: + """POST /api/fluent_1/{path}/{cmd}""" + + def test_initialize_does_not_crash(self, real_client): + """initialize either succeeds or returns a conflict/server error.""" + try: + real_client.execute_cmd("solution/initialization", "initialize") + except FluentRestError as exc: + assert exc.status in (409, 500) + + +# --------------------------------------------------------------------------- +# 8. execute_query +# --------------------------------------------------------------------------- + + +class TestRealExecuteQuery: + """POST /api/fluent_1/{path}/{query}""" + + def test_query_endpoint_reachable(self, real_client): + """Query endpoint is reachable; may return error for unknown queries.""" + try: + reply = real_client.execute_query( + "setup/boundary-conditions/velocity-inlet", "get-zone-names" + ) + assert reply is None or isinstance(reply, (list, str)) + except FluentRestError as exc: + assert exc.status in (404, 405, 500) + + +# =================================================================== +# 9. exit / context manager +# =================================================================== + + +class TestExit: + """Verify exit() sends POST to /api/connection/exit.""" + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_sends_post_to_connection_exit(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + c.exit() + req = mock_urlopen.call_args[0][0] + assert req.get_method() == "POST" + assert "api/app/exit" in req.full_url + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_raises_on_403(self, mock_urlopen): + mock_urlopen.side_effect = _make_http_error( + 403, body={"detail": "Exit is not allowed."} + ) + c = _client() + with pytest.raises(FluentRestError, match="403"): + c.exit() + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_raises_on_409(self, mock_urlopen): + mock_urlopen.side_effect = _make_http_error( + 409, body={"show-prompt": "Save changes?"} + ) + c = _client() + with pytest.raises(FluentRestError, match="409"): + c.exit() + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_swallows_connection_error(self, mock_urlopen): + mock_urlopen.side_effect = OSError("Connection refused") + c = _client() + c.exit() # should not raise + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_swallows_other_http_errors(self, mock_urlopen): + mock_urlopen.side_effect = _make_http_error(500) + c = _client() + c.exit() # should not raise (server may be down) + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_context_manager_calls_exit(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + with c: + pass + req = mock_urlopen.call_args[0][0] + assert "api/app/exit" in req.full_url + + def test_context_manager_enter_returns_self(self): + c = _client() + assert c.__enter__() is c + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_sets_is_closed(self, mock_urlopen): + """After exit(), _is_closed must be True.""" + mock_urlopen.return_value = _make_response({"message": "Shutting down"}) + c = _client() + assert not c._is_closed + c.exit() + assert c._is_closed + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_is_idempotent(self, mock_urlopen): + """Calling exit() twice must not raise or send a second request.""" + mock_urlopen.return_value = _make_response({"message": "Shutting down"}) + c = _client() + c.exit() + c.exit() # should not raise + assert mock_urlopen.call_count == 1 # only one POST sent + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_closed_session_blocks_requests(self, mock_urlopen): + """After exit(), any API call must raise FluentRestError.""" + mock_urlopen.return_value = _make_response({"message": "Shutting down"}) + c = _client() + c.exit() + with pytest.raises(FluentRestError, match="Session is closed"): + c.get_static_info() + # urlopen should NOT be called again (only the exit call) + assert mock_urlopen.call_count == 1 + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_exit_swallows_url_error(self, mock_urlopen): + """URLError (connection refused) should be swallowed by exit().""" + mock_urlopen.side_effect = urllib.error.URLError("Connection refused") + c = _client() + c.exit() # should not raise + assert c._is_closed + + +# =================================================================== +# API endpoint wiring — create / delete / rename +# =================================================================== + + +class TestNamedObjectMutation: + """Verify create/delete/rename build the correct HTTP requests.""" + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_create_sends_post_with_name(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + c.create("setup/bc/wall", "new-wall") + req = mock_urlopen.call_args[0][0] + assert req.get_method() == "POST" + assert json.loads(req.data) == {"name": "new-wall"} + assert "setup/bc/wall" in req.full_url + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_delete_sends_delete(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + c.delete("setup/bc/wall", "wall-1") + req = mock_urlopen.call_args[0][0] + assert req.get_method() == "DELETE" + assert "setup/bc/wall/wall-1" in req.full_url + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_delete_ignore_not_found(self, mock_urlopen): + mock_urlopen.side_effect = _make_http_error(404, {"detail": "gone"}) + c = _client() + # Must not raise + c.delete("setup/bc/wall", "wall-1", ignore_not_found=True) + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_delete_raises_on_404_by_default(self, mock_urlopen): + mock_urlopen.side_effect = _make_http_error(404, {"detail": "gone"}) + c = _client() + with pytest.raises(FluentRestError) as exc_info: + c.delete("setup/bc/wall", "wall-1") + assert exc_info.value.status == 404 + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_rename_sends_put_with_new_name(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + c.rename("setup/bc/wall", "new-name", "old-name") + req = mock_urlopen.call_args[0][0] + assert req.get_method() == "PUT" + assert json.loads(req.data) == {"name": "new-name"} + assert "setup/bc/wall/old-name" in req.full_url + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_delete_child_objects_calls_delete_for_each(self, mock_urlopen): + mock_urlopen.return_value = _make_response({}) + c = _client() + c.delete_child_objects("setup/bc", "wall", ["w1", "w2"]) + assert mock_urlopen.call_count == 2 + urls = [call[0][0].full_url for call in mock_urlopen.call_args_list] + assert any("wall/w1" in u for u in urls) + assert any("wall/w2" in u for u in urls) + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_delete_all_child_objects(self, mock_urlopen): + """delete_all discovers names via GET, then deletes each.""" + # First call: GET returns object names + get_resp = _make_response({"w1": {}, "w2": {}}) + delete_resp = _make_response({}) + mock_urlopen.side_effect = [get_resp, delete_resp, delete_resp] + c = _client() + c.delete_all_child_objects("setup/bc", "wall") + # 1 GET + 2 DELETEs + assert mock_urlopen.call_count == 3 + + @patch("ansys.fluent.core.rest.client.urllib.request.urlopen") + def test_create_does_not_mutate_caller_dict(self, mock_urlopen): + """create() must not inject 'name' into the caller's properties dict.""" + mock_urlopen.return_value = _make_response({}) + c = _client() + props = {"momentum": 0.5} + c.create("setup/bc/wall", "new-wall", properties=props) + assert "name" not in props