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 69a6ce79a60..552b264b0dd 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..53407cccbcc --- /dev/null +++ b/src/ansys/fluent/core/rest/client.py @@ -0,0 +1,763 @@ +# 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 +import warnings + +logger = logging.getLogger(__name__) + +# HTTP status codes eligible for automatic retry. +_RETRYABLE_STATUS_CODES = frozenset({502, 503, 504}) + +# HTTP methods safe to retry automatically (idempotent). +_RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + + +class FluentRestError(RuntimeError): + """HTTP error returned by the Fluent REST server.""" + + def __init__(self, status: int, message: str) -> None: + self.status = status + super().__init__(f"HTTP {status}: {message}") + + +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._validate_base_url(base_url, auth_token, ssl_context) + if timeout <= 0: + raise ValueError("timeout must be > 0") + if max_retries < 0: + raise ValueError("max_retries must be >= 0") + if retry_delay < 0: + raise ValueError("retry_delay must be >= 0") + 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 + + @property + def _is_secure(self) -> bool: + """Return True if the connection is HTTPS, False otherwise.""" + return self._base_url.startswith("https://") + + # ------------------------------------------------------------------ + # Validation (SRP: input validation is a single, isolated concern) + # ------------------------------------------------------------------ + + @staticmethod + def _validate_base_url( + base_url: str, + auth_token: str | None, + ssl_context: ssl.SSLContext | None, + ) -> None: + """Validate *base_url* and warn on insecure auth transport. + + Raises + ------ + ValueError + If *base_url* has an unsupported scheme or no host. + """ + parsed = urllib.parse.urlparse(base_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError("scheme must be http or https") + if not parsed.netloc: + raise ValueError("base_url must include host") + if auth_token and parsed.scheme == "http" and ssl_context is None: + warnings.warn( + "auth_token is being sent over plain HTTP. " + "Use https:// to protect credentials in transit.", + stacklevel=2, + ) + + # ------------------------------------------------------------------ + # HTTP transport internals + # ------------------------------------------------------------------ + + @staticmethod + def _encode_path(path: str) -> str: + """Percent-encode each segment of a slash-delimited path.""" + return "/".join(urllib.parse.quote(seg, safe="") for seg in path.split("/")) + + def _url(self, endpoint: str) -> str: + """Build a full URL from *base_url* + *endpoint*.""" + return f"{self._base_url}/{endpoint}" + + def _build_auth_header(self) -> str | None: + """Return the ``Authorization`` header value, or ``None``.""" + if not self._auth_token: + return None + return f"Bearer {hashlib.sha256(self._auth_token.encode()).hexdigest()}" + + def _build_request( + self, + method: str, + url: str, + body: Any = None, + ) -> urllib.request.Request: + """Assemble an :class:`urllib.request.Request`. + + Serialises *body* to JSON if provided and attaches auth headers. + """ + data: bytes | None = None + headers: dict[str, str] = {} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + auth = self._build_auth_header() + if auth: + headers["Authorization"] = auth + return urllib.request.Request( + url, data=data, headers=headers, method=method.upper() + ) + + @staticmethod + def _parse_error_detail(exc: urllib.error.HTTPError) -> str: + """Return a readable error message from an HTTP error response.""" + try: + raw = exc.read().decode("utf-8", errors="replace") + # Server returns plain text, not JSON + if raw.strip(): + return raw.strip() + return exc.reason + except Exception: + return exc.reason + + 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 resp: # nosec B310 + raw = resp.read() + if not raw.strip(): + return None + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + return {} + + 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") + url = self._url(endpoint) + req = self._build_request(method, url, body) + + retries = self._max_retries if method.upper() in _RETRYABLE_METHODS else 0 + for attempt in range(retries + 1): + try: + return self._send_once(req) + except urllib.error.HTTPError as exc: + detail = self._parse_error_detail(exc) + if exc.code in _RETRYABLE_STATUS_CODES and attempt < retries: + time.sleep(self._retry_delay * (2**attempt)) + continue + raise FluentRestError(exc.code, detail) from exc + except urllib.error.URLError as exc: + if attempt < retries: + time.sleep(self._retry_delay * (2**attempt)) + continue + raise FluentRestError(0, str(exc.reason)) from exc + except OSError as exc: + # Catches RemoteDisconnected, ConnectionResetError, + # ConnectionAbortedError — all signs the server died. + raise FluentRestError(0, str(exc)) from exc + + # ------------------------------------------------------------------ + # 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 set_var(self, path: str, value: Any) -> None: + """Set the value at *path* (PUT {path}).""" + self._request("PUT", f"{self._api_base}/{self._encode_path(path)}", body=value) + + def get_attrs(self, path: str, attrs: list[str], recursive: bool = False) -> Any: + """Return selected attributes for *path* (GET with ``attrs=...``). + + Raises + ------ + FluentRestError + If the request fails. + """ + params = {"attrs": ",".join(attrs)} + if recursive: + params["recursive"] = "true" + query = urllib.parse.urlencode(params) + return self._request( + "GET", f"{self._api_base}/{self._encode_path(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. + """ + try: + result = self._request("GET", f"{self._api_base}/{self._encode_path(path)}") + except FluentRestError as exc: + if exc.status == 404: + return [] + raise + if isinstance(result, list): + return result + if isinstance(result, dict): + # Real Fluent returns named objects as dict with names as keys: + # {"hot-inlet": {...}, "cold-inlet": {...}} + return list(result.keys()) + return [] + + 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", f"{self._api_base}/{self._encode_path(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._api_base}/{self._encode_path(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._api_base}/{self._encode_path(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 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. + """ + try: + result = self._request("GET", f"{self._api_base}/{self._encode_path(path)}") + except FluentRestError as exc: + if exc.status == 404: + return 0 + raise + if isinstance(result, list): + return len(result) + if isinstance(result, dict): + # Explicit size field from list-objects + if "size" in result: + return result["size"] + # Named-object containers: count the keys (object names) + return len(result) + return 0 + + def resize_list_object(self, path: str, size: int) -> None: + """Resize the list-object at *path* to *size* elements (POST {path}).""" + self._request( + "POST", + f"{self._api_base}/{self._encode_path(path)}", + body={"new-size": size}, + ) + + def _execute(self, path: str, name: str, **kwds) -> Any: + """POST a command/query endpoint and return the raw response payload.""" + encoded_name = urllib.parse.quote(name, safe="") + return self._request( + "POST", + f"{self._api_base}/{self._encode_path(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 = urllib.parse.quote(command, safe="") + endpoint = f"{self._api_base}/{self._encode_path(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 + try: + self._request("POST", "api/app/exit") + except FluentRestError as exc: + if exc.status in (403, 409): + logger.warning("Exit blocked (HTTP %d): %s", exc.status, exc) + raise + # Connection lost or other error → server already down + except OSError: + # Server died mid-response + pass + self._is_closed = True + logger.info("Fluent server terminated.") + + def __enter__(self) -> "FluentRestClient": + """Enter the context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Exit the context manager — calls :meth:`exit`.""" + self.exit() + + + +""" +# The Single Level of Abstraction Principle + +## The Central Problem: Three Versions of Every Piece of Code + +When you read a function, you are reading only one of three things that exist simultaneously: + +1. **What was written** — the literal code on the screen. +2. **What was intended** — the idea in the author's mind when they wrote it. +3. **What is correct** — what the code should actually do to be right. + +This is the central difficulty of programming. Only version 1 is ever visible to a reader — whether they are reviewing, understanding, or changing the code. Versions 2 and 3 are invisible. They live in the author's head, in a requirements document somewhere, or nowhere at all. + +The **Single Level of Abstraction Principle (SLAP)** is a design rule that directly addresses this gap. It states that all the code inside a function should operate at the same conceptual level. No line should reach down into low-level mechanics while neighbouring lines speak in high-level business terms. When a function respects this principle, it becomes possible — often for the first time — for a reader to see not just what was written, but what was intended. And once intent is visible, correctness can be judged. + +What follows is a walkthrough of real production code that illustrates what happens when the principle is violated, what it costs, and what it looks like to fix it. + +--- + +## The Code + +Here is a pair of methods from PyFluent. Read them carefully before continuing. + +```python +def exit(self) -> None: + ""Gracefully shut down the Fluent session. + + Raises + ------ + FluentServerShutdown + If the session has already been closed. + "" + if self._is_closed: + raise FluentServerShutdown("Session is already closed.") + try: + self._execute("/", "exit") + except Exception: + pass # nosec B110 - server drops the connection on exit — expected + self._is_closed = True + logger.info("Fluent server exited.") + +def _execute(self, path: str, name: str, **kwds) -> Any: + ""Post a command or query and return the ``"reply"`` payload. + + Retries automatically when the server returns + ``400 Fluent not running`` — the solver may still be initialising + after the web server port opened. Gives up after *_SOLVER_READY_TIMEOUT* + seconds and re-raises the original error. + "" + _SOLVER_READY_TIMEOUT = 120 # seconds + _SOLVER_RETRY_DELAY = 5 # seconds between retries + start = time.monotonic() + while True: + try: + encoded_name = urllib.parse.quote(name, safe="") + result = self._request( + "POST", + f"{self._api_base}/{self._encode_path(path)}/{encoded_name}", + body=kwds, + ) + return result.get("reply") if isinstance(result, dict) else result + except FluentRestError as exc: + elapsed = time.monotonic() - start + if ( + exc.status == 400 + and "Fluent not running" in str(exc) + and elapsed < _SOLVER_READY_TIMEOUT + ): + logger.debug( + "Solver not ready yet (400 Fluent not running) — " + "retrying in %ds (elapsed=%.0fs / %ds)...", + _SOLVER_RETRY_DELAY, + elapsed, + _SOLVER_READY_TIMEOUT, + ) + time.sleep(_SOLVER_RETRY_DELAY) + continue + raise + +@staticmethod +def _encode_path(path: str) -> str: + ""Percent-encode each segment of a slash-delimited path. + + Fluent object names may contain URL-sensitive characters such as + spaces, ``#``, ``?``, or ``%``. Each segment is individually + quoted so the resulting URL is always valid. + "" + return "/".join(urllib.parse.quote(seg, safe="") for seg in path.split("/")) +``` + +There is a lot happening here. We will move through it gradually. + +--- + +## A Contrast Hidden in Plain Sight + +Look at the inside of `_execute`. It builds a URL to post to. In doing so it encodes both a `path` and a `name`. Here is how each is handled: + +```python +encoded_name = urllib.parse.quote(name, safe="") +result = self._request( + "POST", + f"{self._api_base}/{self._encode_path(path)}/{encoded_name}", + ... +) +``` + +The `path` is encoded by calling `self._encode_path(path)`. The `name` is encoded inline using `urllib.parse.quote(name, safe="")`. + +These two lines do conceptually identical things — they encode a URL component so that special characters are safe to transmit. But they do it at two completely different levels of abstraction. `_encode_path` is named after what it means. `urllib.parse.quote` is named after what it does mechanically. + +When you read `self._encode_path(path)`, you read intent. The name tells you the purpose of the call; you do not need to understand its implementation to understand the code around it. You can immediately ask: does encoding the path make sense here? Is it the right thing to do? You are reading version 2 alongside version 1, and that makes version 3 — correctness — something you can evaluate. + +When you read `urllib.parse.quote(name, safe="")`, you have no such luxury. The name of the function tells you about its implementation: it percent-encodes a string. It tells you nothing about why it is being called here. To understand its purpose, you must read the lines around it, understand what `name` represents in this context, figure out that it is a URL component that needs to be safe-transmitted, and only then can you reconstruct the intent. The reader is forced to induce the intention from the surrounding code rather than read it directly. This is how code becomes hard to read: not because any individual line is complex, but because the reader carries the burden of perpetually reconstructing intent that was never written down. + +The fix is straightforward: extract a method named after the intention. + +```python +encoded_name = self._encode_name(name) +``` + +This single rename closes the gap. Now both the path and the name are encoded through methods that name the operation at the same conceptual level as everything around them. + +The deeper lesson here is that `_encode_path` already existed, which means the author already knew how to write at the right level. The inconsistency is a sign that SLAP violations tend to be accidental and local rather than a matter of principle. They slip in line by line, and each one individually seems harmless. The damage accumulates. + +--- + +## When a Three-Part Condition Tells You Three Different Things + +Now look at the retry logic inside `_execute`: + +```python +if ( + exc.status == 400 + and "Fluent not running" in str(exc) + and elapsed < _SOLVER_READY_TIMEOUT +): +``` + +This condition has three clauses. They are not three equal parts of one idea. The first two are about the exception — what kind of error occurred. The third is about time — whether we are still within the window where retrying is worth attempting. These are two entirely separate concerns packed into one expression with `and`. + +When a reader encounters this, they must do two things at once: understand the semantics of the error, and understand the continuation policy. They also have to notice — without any guidance from the code — that the first two clauses are related to each other and the third is separate. This mental overhead is exactly what SLAP violations cost: not confusion about any one line, but the constant overhead of performing the author's reasoning work on their behalf. + +The remedy is to make the structure visible by naming the concerns separately: + +```python +if ( + _error_allows_retry(exc) + and _within_retry_deadline(elapsed) +): +``` + +Now the structure is explicit. There are two concerns. One is about the error; one is about time. They can be understood and evaluated independently. + +We can raise the level one step further: + +```python +if _should_retry(exc, elapsed): +``` + +This reads like a policy decision, which is exactly what it is. The implementation of that decision lives elsewhere, in functions that can be read, understood, and tested in isolation: + +```python +def _should_retry(exc, elapsed): + return _error_allows_retry(exc) and _within_retry_deadline(elapsed) + +def _error_allows_retry(exc): + return _error_means_bad_request(exc) and _error_means_fluent_not_running(exc) + +def _within_retry_deadline(elapsed): + return elapsed < _SOLVER_READY_TIMEOUT + +def _error_means_bad_request(exc): + return exc.status == 400 + +def _error_means_fluent_not_running(exc): + return "Fluent not running" in str(exc) +``` + +Notice what happened to `_error_allows_retry`. It retries if the error is a bad request (`400`) **and** if Fluent is not running. Read that aloud: *we retry if the request was bad and Fluent is not running*. Does that make sense? The combination is at least worth questioning — and it can now be questioned, because the logic is readable for the first time. This is a significant outcome: by raising the conceptual level, we have made a potentially dubious business rule visible, where before it was buried inside an inscrutable compound condition. Raising the abstraction level does not just improve readability; it enables code review. + +A word on naming. An intermediate version of the bad-request check might be called `_error_is_400`. This name fails to raise the conceptual level because it simply restates the implementation in words. Renaming it `_error_means_bad_request` is what closes the gap. The name now carries the semantic intent — a 400 status code means the server considers the request malformed — rather than just mirroring the numeric literal. Naming is not a cosmetic activity. It is the primary mechanism by which intent is made visible. + +--- + +## Comments: A Symptom, Not a Cure + +There is a comment on the `except` block in the original `exit` method: + +```python +except Exception: + pass # nosec B110 - server drops the connection on exit — expected +``` + +This comment is doing important work. It is explaining why a broad, silent exception catch is acceptable. Without it, any reader would rightfully be alarmed: swallowing all exceptions is one of the most dangerous patterns in Python. The comment is necessary precisely because the code does not say what it means. + +This is a common pattern. Code written at a low conceptual level is routinely accompanied by explanatory comments, because the code itself cannot bear the weight of communicating its purpose. The comments fill the gap. This feels like a solution, but it is not. It adds clutter — now there is more text to read, more to maintain. Comments drift. Code changes and comments go unupdated. There is no mechanism that keeps a comment synchronised with the code it describes the way a function name is inseparable from its implementation. A comment is a promise the codebase cannot enforce. + +The real solution is to write code that does not need the comment. We will see exactly how to do that for this case shortly. + +--- + +## Testability as a Natural Consequence + +The five functions produced by the retry-condition refactor — `_should_retry`, `_error_allows_retry`, `_within_retry_deadline`, `_error_means_bad_request`, `_error_means_fluent_not_running` — share a useful property: each one can be tested in complete isolation. + +Before the refactor, the logic `exc.status == 400 and "Fluent not running" in str(exc)` could only be tested by constructing an entire scenario involving `_execute`, a mock HTTP layer, and a manufactured exception. The logic was tangled up with the retry loop, the timeout logic, and the sleep. Writing a test for it was expensive enough that it probably would not be written at all. + +After the refactor, testing whether a given exception allows a retry is a two-line test that constructs a mock exception and calls `_error_allows_retry`. The act of naming the logic and extracting it into a function makes it independently reachable by tests. + +This is not a coincidence. Kent Beck's rules of simple design place these properties in order of priority: + +1. All the tests pass. +2. There is no duplication. +3. The code expresses the intent of the programmer. +4. Classes and methods are minimised. + +These rules are not independent of each other. Code that expresses intent at a consistent conceptual level tends to be naturally modular and naturally testable. SLAP is one of the primary mechanisms by which rule 3 is achieved. When rule 3 is satisfied, rule 1 becomes much easier to satisfy too. + +--- + +## How Abstraction Failures Propagate: The `exit` Method + +The problems in `_execute` are not contained within `_execute`. They propagate upward into `exit`, which calls it. This is how abstraction failures compound. + +Look at `exit` again: + +```python +def exit(self) -> None: + if self._is_closed: + raise FluentServerShutdown("Session is already closed.") + try: + self._execute("/", "exit") + except Exception: + pass # nosec B110 - server drops the connection on exit — expected + self._is_closed = True + logger.info("Fluent server exited.") +``` + +`_execute` is a general-purpose dispatcher. Its name says: I execute things. It accepts arbitrary string paths and names. It is a low-level tool designed to forward any request to the server. Using it inside `exit` — a method with a very specific, high-level purpose — imports all of `_execute`'s low-level character into `exit`. The call site `self._execute("/", "exit")` does not say *send an exit request to the server*. It says *post something to the path `/` with the name `"exit"`*. The reader must do the translation. + +The exception handling is worse. `except Exception` catches everything. It was placed here because, as the comment explains, the Fluent server drops the connection when it shuts down, and that drop manifests as an exception. But because the exception type is unspecified, this handler would also silently swallow network errors, programming errors, and any other unexpected failure. And here is the real danger: `self._is_closed = True` is set unconditionally after that broad catch. If `_execute` raises for any reason other than a connection drop, the code will still mark the session as closed. A subsequent caller attempting `exit` will receive `FluentServerShutdown("Session is already closed.")` even though the server never actually shut down. The server becomes unkillable. + +This is a direct consequence of operating at the wrong abstraction level. The intent — handle the expected connection drop, propagate everything else — cannot be implemented correctly when the exception type carries no semantic information. `Exception` is too broad to be the basis of a strategic decision. + +--- + +## Raising the Level of `exit`: Step by Step + +The first step is to introduce a method that expresses the specific intent: sending an exit request. + +```python +def exit(self) -> None: + if self._is_closed: + raise ServerAlreadyShutDown() + try: + self._sendExitRequest() + except ServerDroppedConnectionOnExit: + pass # nosec B110 - server drops the connection on exit — expected + finally: + self._is_closed = True + logger.info("Fluent server exited.") +``` + +Several things have changed. `_execute("/", "exit")` has become `self._sendExitRequest()`. The name now says what the call is for. `FluentServerShutdown("Session is already closed.")` has become `ServerAlreadyShutDown()`. The name now says what the condition means, and the string message is redundant — the type is the documentation. Most importantly, `except Exception` has become `except ServerDroppedConnectionOnExit`. The handler now catches only the specific, named condition it was designed for. Any other exception will propagate, which is correct behaviour. `finally` ensures `_is_closed` is set regardless of outcome, which prevents the unkillable-server bug. + +But the comment is still there. That `# nosec B110` annotation is still telling us something we should not need to be told: that a connection drop is expected. This is a detail of the exit transaction — it belongs inside `_sendExitRequest`, not leaked into the method that calls it. The fix is to encapsulate it: + +```python +def exit(self) -> None: + if self._is_closed: + raise ServerAlreadyShutDown() + self._sendExitRequest() + self._is_closed = True + logger.info("Fluent server exited.") +``` + +`_sendExitRequest` absorbs the knowledge that a connection drop is expected and handles it internally. `exit` no longer needs to know about it. The comment is gone — not because we deleted it, but because the information it contained has been encoded into the structure of the code itself. This is the ideal outcome: intent expressed through names and structure rather than through annotations layered on top of unclear code. + +Read this final version of `exit` from top to bottom: + +1. If the session is already closed, raise an error that says so. +2. Send the exit request. +3. Mark the session as closed. +4. Log that the server exited. + +Every line is at the same conceptual level: the level of *what is happening in a shutdown sequence*. There is no URL encoding, no HTTP status codes, no connection-drop mechanics. Those things exist, but they live at the level where they belong. Here, the reader can see version 1 and version 2 simultaneously — what was written and what was intended — and can therefore form an opinion about version 3. + +--- + +## Summary + +The Single Level of Abstraction Principle is not a rule about code organisation for its own sake. It is a rule about communication. Code is read far more often than it is written. Every time a developer reads a function, they are trying to reconstruct what the author intended, and every low-level detail that intrudes on a high-level narrative forces them to do that reconstruction work themselves. + +The concrete costs are: + +- **Readability**: a reader must induce intent rather than read it, which is slower and error-prone. +- **Reviewability**: logic buried inside compound expressions or broad exception handlers cannot be evaluated for correctness until it is first decoded. +- **Maintainability**: code changed without full understanding of intent produces bugs. Comments that attempted to compensate for the lack of clarity go stale. +- **Testability**: logic entangled with surrounding mechanics cannot be tested in isolation. +- **Safety**: low-level constructs like `except Exception` carry insufficient information to make correct high-level decisions, which leads directly to bugs. + +The remedies are equally concrete: extract methods named after what they mean rather than what they do, name exceptions after the conditions they represent, and ensure that every line in a function speaks the same language as the lines around it. + +When code is written at a consistent level of abstraction, intent becomes legible. And when intent is legible, correctness can be debated — which is, ultimately, the only way to know whether what was written is what should have been written. + +""" \ No newline at end of file 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..86b6c0bd7d1 --- /dev/null +++ b/src/ansys/fluent/core/rest/rest_launcher.py @@ -0,0 +1,303 @@ +# 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_server( + port: int, + timeout: int = 120, + ssl_context: ssl.SSLContext | None = None, +) -> None: + """Block until the Fluent web server is fully ready. + + Phase 1: TCP connect (port open). Phase 2: HTTP probe (solver ready). + Raises :class:`TimeoutError` if not ready within *timeout* seconds. + """ + scheme = "https" if ssl_context else "http" + deadline = time.monotonic() + timeout + + # ── Phase 1: wait for TCP port to open ────────────────────────────── + 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) + break + except OSError: + time.sleep(2) + else: + raise TimeoutError(f"Port {port} not open within {timeout}s.") + + # ── Phase 2: wait for solver to be ready (no 400) ─────────────────── + logger.info("[wait] Phase 2 — waiting for solver to be ready on port %d...", port) + probe_url = f"{scheme}://{_LOCALHOST}:{port}/api/connection/run_mode" + 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 on port %d.", port) + return + except urllib.error.HTTPError as exc: + if exc.code == 400: + # Web server is up but solver has not initialised yet + logger.debug("[wait] Solver not ready yet (HTTP 400) — retrying...") + time.sleep(3) + elif exc.code == 401: + # Auth required — server and solver are fully up + logger.info("[wait] Solver ready (HTTP 401 on probe) — proceeding.") + return + else: + logger.debug("[wait] Unexpected HTTP %d — retrying...", exc.code) + time.sleep(3) + except urllib.error.URLError: + # Connection refused / DNS failure — server not yet listening + time.sleep(3) + except OSError: + # Low-level socket error (e.g. connection reset) + time.sleep(3) + + raise TimeoutError(f"Solver on port {port} not ready within {timeout}s.") + + +def _get_fluent_exe( + product_version: str | None = None, + fluent_path: str | None = None, +) -> str: + """Resolve the Fluent executable path via :func:`get_fluent_exe_path`.""" + return str( + get_fluent_exe_path( + product_version=product_version, + fluent_path=fluent_path, + ) + ) + + +# --------------------------------------------------------------------------- +# 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 = _find_cert_dir(cert_dir) + ssl_ctx = None + if resolved_cert_dir: + ssl_ctx = _build_ssl_context(resolved_cert_dir) + logger.info("HTTPS enabled — certificates from %s", resolved_cert_dir) + else: + logger.warning( + "No TLS certificates found. Launching Fluent in HTTP mode. " + "For HTTPS, provide webserver.crt, webserver.key, and dh.pem " + ) + + # 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 = _get_fluent_exe( + product_version=product_version, + fluent_path=fluent_path, + ) + + # 5 — build the launch command and spawn Fluent + 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 resolved_cert_dir: + env["FLUENT_WEBSERVER_CERTIFICATE_ROOT"] = resolved_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}).") + + # 6 — wait for the web server and construct the session + try: + _wait_for_server(port, timeout=start_timeout, ssl_context=ssl_ctx) + + scheme = "https" if ssl_ctx else "http" + base_url = f"{scheme}://{_LOCALHOST}:{port}" + session = FluentRestClient( + base_url, + auth_token=auth_token, + component=component, + timeout=timeout, + max_retries=max_retries, + retry_delay=retry_delay, + ssl_context=ssl_ctx, + ) + except Exception: + logger.exception( + "Failed after launching Fluent (pid=%d) — terminating.", + process.pid, + ) + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + + return session 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 9c5157377ff..f54d394b3ad 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 @@ -521,3 +524,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