diff --git a/CHANGELOG.md b/CHANGELOG.md index 84424603..22fea818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - (ParaView plugin) support direct CGNS-tree/VTK conversion, including VTK-to-CGNS export, CGNS hierarchy and tag preservation, and bulk connectivity conversion for uniform fixed-size unstructured grids. The self-contained plugin bundle can be written to a caller-provided directory. - (storage/writer) add optional `sample_callback` to `save_to_disk`, invoked once per sample right after it is written to disk as `sample_callback(split_name, index, sample_path)`. This lets callers process samples one by one once written instead of waiting for the whole dataset. Currently supported for the `cgns` backend, including parallel writing (`num_proc > 1`), where the callback runs inside the worker processes and must be picklable and process-safe. - (python env) add python 3.14 support +- (dataset-viewer) add an **Open in ParaView** button for local datasets: it serves the selected dataset through a loopback-only `plaid-serve` data API on the first available port in 8081–8090 and launches ParaView with the PLAID plugin, keeping the server alive until ParaView exits. The ParaView executable is read from `PARAVIEW_EXEC` (default `paraview`). - (docs) generate the viewer screenshots from a synthetic dataset (`docs/source/images/generate_viewer_images.py`) and embed an interactive skrub report, built with the documentation, in the viewer page. ### Changed @@ -22,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - (plaidcheck/problem_definition) support multiple subsplits per problem-definition split. - (dataset-viewer) keep the camera viewpoint when changing samples; it is only reset on the first load or by **Reset camera**. - (dataset-viewer) keep the current physical time when changing samples, using the closest available time step of the new sample. +- (plaid-serve) `--ParaViewRun` now starts the data API before launching ParaView, and passes a generated script that creates and displays a **Plaid Explorer** source automatically. +- (ParaView plugin) rename the server port property from `Port` to `PortNB` (`SetPort` to `SetPortNB`); update ParaView state files and scripts that set it. ### Fixed diff --git a/docs/source/concepts/serve.md b/docs/source/concepts/serve.md index 951f5d2b..1f0981da 100644 --- a/docs/source/concepts/serve.md +++ b/docs/source/concepts/serve.md @@ -36,6 +36,10 @@ location in the JSON body using either `dataset` or `uri`. | `--dataset PATH` | Default local PLAID dataset directory used by dataset endpoints. | | `--ParaViewRun` | Launch ParaView with the PLAID plugin, set `PLAID_PORT` to the selected port, and stop the server when ParaView exits. The ParaView executable is read from `PARAVIEW_EXEC`. | +When `--ParaViewRun` is used, the launcher passes a generated script to +ParaView to create and display a **Plaid Explorer** source. The script is kept +available until the launcher exits. + ## Request conventions Dataset endpoints use `POST` with a JSON object body. The server resolves the diff --git a/docs/source/concepts/viewer.md b/docs/source/concepts/viewer.md index cbe975f4..c6ec94c0 100644 --- a/docs/source/concepts/viewer.md +++ b/docs/source/concepts/viewer.md @@ -68,6 +68,28 @@ already be filled in. The root controls and dataset selector are hidden by Pick the **Dataset** you want to inspect. +For a **local** dataset, click **Open in ParaView** to launch ParaView with the +PLAID plugin connected to a loopback-only `plaid-serve` data API. The viewer +chooses the first available API port from **8081–8090**, keeping it close to the +default viewer port (8080), and keeps the server running until that ParaView +instance exits (or the viewer shuts down). If all ports in this range are in use +or denied, the launch reports an error instead of using a high-numbered port. +Your firewall must allow the selected API port. ParaView must be installed +and available as `paraview` on your `PATH`, or you must set `PARAVIEW_EXEC` to +the ParaView executable **before starting `plaid-viewer`**. For example, when +running from WSL with ParaView installed on Windows: + +```bash +export PARAVIEW_EXEC=/mnt/c/Users/User/Apps/ParaView-6.2.0-Windows-Python3.12-msvc2017-AMD64/bin/paraview.exe +uv run plaid-viewer +``` + +Replace the example path with the location of your ParaView installation. +The launch script adds and displays a **Plaid Explorer** source in ParaView +automatically so you can inspect and compare samples. +Hub-streamed datasets are not supported by `plaid-serve` and cannot be opened +with this button. + The list depends on the selected source tab: local datasets are shown on the **Local** tab, while streamed Hub datasets are shown on the **Hub** tab. diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 9bc270e3..d9abcf59 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -315,8 +315,8 @@ def __init__( inputType=inputType, outputType=outputType, ) - self.host: str = plaid_explorer_server_default_host - self.port: int = plaid_explorer_server_default_port + self.host_name: str = plaid_explorer_server_default_host + self.port_nb: int = plaid_explorer_server_default_port self.server_ok = False def _CleanCache(self): @@ -329,18 +329,18 @@ def _CleanCache(self): def SetHost(self, value): """Set the server host address to connect to for fetching dataset information and samples.""" value = str(value) - if self.host != value: - self.host = value + if self.host_name != value: + self.host_name = value self._CleanCache() @smproperty.intvector( - name="Port", default_values=str(plaid_explorer_server_default_port) + name="PortNB", default_values=str(plaid_explorer_server_default_port) ) - def SetPort(self, value): + def SetPortNB(self, value): """Set the server port to connect to for fetching dataset information and samples.""" value = int(value) - if self.port != value: - self.port = value + if self.port_nb != value: + self.port_nb = value self._CleanCache() def isServerOK(self): @@ -349,7 +349,7 @@ def isServerOK(self): try: with request.urlopen( request.Request( - url=f"http://{self.host}:{self.port}/health", + url=f"http://{self.host_name}:{self.port_nb}/health", headers={"Content-Type": "application/json"}, method="POST", ), @@ -384,7 +384,7 @@ def _request_json( data = json.dumps(payload).encode("utf-8") if payload is not None else None req = request.Request( - url=f"http://{self.host}:{self.port}{endpoint}", + url=f"http://{self.host_name}:{self.port_nb}{endpoint}", data=data, headers={"Content-Type": "application/json"}, method="POST", @@ -428,11 +428,11 @@ def SetHost(self, value): return super().SetHost(value) @smproperty.intvector( - name="Port", default_values=str(plaid_explorer_server_default_port) + name="PortNB", default_values=str(plaid_explorer_server_default_port) ) - def SetPort(self, value): + def SetPortNB(self, value): """Set the server port to connect to for fetching dataset information and samples.""" - return super().SetPort(value) + return super().SetPortNB(value) @smproperty.stringvector( name="SelectSplit", default_values="", immediate_update="1" @@ -553,7 +553,7 @@ def __init__(self): self._timestep_values_cache: list[float] | None = None self._sample_cache = None self.host: str = plaid_process_server_default_host - self.port: int = plaid_process_server_default_port + self.port_nb: int = plaid_process_server_default_port self.process_op: str = "" self.ensure_3D_points = False @@ -617,13 +617,13 @@ def SetHost(self, value): self._PropertyChanged() @smproperty.intvector( - name="Port", default_values=int(plaid_process_server_default_port) + name="PortNB", default_values=int(plaid_process_server_default_port) ) - def SetPort(self, value): + def SetPortNB(self, value): """Set the server port to connect to for processing data.""" value = int(value) - if self.port != value: - self.port = value + if self.port_nb != value: + self.port_nb = value self._PropertyChanged() def _request_json( @@ -646,7 +646,7 @@ def _request_json( data = json.dumps(payload).encode("utf-8") if payload is not None else None req = request.Request( - url=f"http://{self.host}:{self.port}{endpoint}", + url=f"http://{self.host}:{self.port_nb}{endpoint}", data=data, headers={"Content-Type": "application/json"}, method="POST", diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 439d6c5f..6aed46df 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -1,9 +1,11 @@ """Utilities to launch ParaView with the PLAID plugin configured.""" +import atexit import os import subprocess import tempfile from pathlib import Path +from typing import Optional paraview_exec = "paraview" @@ -70,18 +72,82 @@ def convert_wsl_to_win(wsl_path: str) -> str: return result.stdout.strip() -def run_paraview_with_plugin(): - """Launches ParaView with environment variables set to load the plugin.""" +def run_paraview_with_plugin_client_server( + port: int = 8000, options: Optional[dict[str, str]] = None +) -> subprocess.Popen: + """Launch ParaView with the plugin and a generated explorer script. + + Args: + port: Port of the PLAID data API. + options: Optional source settings, including ``tree_name``. + + Returns: + The ParaView process. + """ my_env = os.environ.copy() my_env["PV_PLUGIN_PATH"] = str(get_ParaView_plugin_path_one_file()) - my_env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] = "ON" + # my_env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] = "ON" # uncomment this for debug + my_env["PLAID_PORT"] = str(port) current_pv_path = os.environ.get("PARAVIEW_EXEC", paraview_exec) - if current_pv_path.startswith("/mnt/") and os: - # we are in a wsl and the paraview exec is windows, we need to convert the wsl path - # my_env["PV_PLUGIN_PATH"] = convert_wsl_to_win(str(get_ParaView_plugin_path())) - my_env["WSLENV"] = "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" - - process = subprocess.Popen([current_pv_path], env=my_env) + if current_pv_path.startswith("/mnt/"): + my_env["WSLENV"] = ( + my_env.get("WSLENV", "") + ":" if my_env.get("WSLENV") else "" + ) + "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY:PLAID_PORT:SCRIPT_PATH" + + if options is None: + options = {} + tree_name = options.get("tree_name", "PlaidExplorer1") + + script = f""" + +#### import the simple module from the paraview +from paraview.simple import * +#### disable automatic camera reset on 'Show' +paraview.simple._DisableFirstRenderCameraReset() + +# create a new 'Plaid Explorer' +plaidExplorer1 = PlaidExplorer(registrationName={tree_name!r}) +plaidExplorer1.UpdatePipeline() +# get animation scene +animationScene1 = GetAnimationScene() + +# update animation scene based on data timesteps +animationScene1.UpdateAnimationUsingDataTimeSteps() + +# get active view +renderView1 = GetActiveViewOrCreate('RenderView') +# show data in view +plaidExplorer1Display = Show(plaidExplorer1, renderView1) + +# trace defaults for the display properties. +plaidExplorer1Display.Representation = 'Surface' + +# reset view to fit data +renderView1.ResetCamera(False, 0.9) + +# update the view to ensure updated data information +renderView1.Update() +Show(plaidExplorer1) +Render() +Show(plaidExplorer1) +Render() +""" + script_dir = tempfile.TemporaryDirectory() + file_path = os.path.join(script_dir.name, "script.py") + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(script) + my_env["SCRIPT_PATH"] = file_path + process = subprocess.Popen( + [current_pv_path, "--script", file_path], + env=my_env, + stdin=subprocess.DEVNULL, + ) + except BaseException: + script_dir.cleanup() + raise + # ParaView may not read the script immediately after Popen returns. + atexit.register(script_dir.cleanup) return process diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py index a5a95c98..068b44a2 100644 --- a/src/plaid/cli/serve.py +++ b/src/plaid/cli/serve.py @@ -11,7 +11,6 @@ import argparse import json import logging -import os import threading from dataclasses import dataclass, field from http import HTTPStatus @@ -500,22 +499,21 @@ def main(argv: list[str] | None = None) -> int: level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) - if args.ParaViewRun: - os.environ["PLAID_PORT"] = str(args.port) - from .paraview_plugin import run_paraview_with_plugin - - process = run_paraview_with_plugin() - else: - process = None - context = ServeContext(default_dataset_uri=args.dataset) server = _ServeHTTPServer((str(args.host), int(args.port)), context) log.info("PLAID data API listening on http://%s:%s", args.host, args.port) - if process is None: + if not args.ParaViewRun: log.info("Running PLAID data API until interrupted") server.serve_forever() else: + from .paraview_plugin import run_paraview_with_plugin_client_server + + try: + process = run_paraview_with_plugin_client_server(port=server.server_port) + except BaseException: + server.server_close() + raise log.info("Running PLAID data API for ParaView") _run_server_until_process_exits(server, process) diff --git a/src/plaid/viewer/paraview_session.py b/src/plaid/viewer/paraview_session.py new file mode 100644 index 00000000..ce45a89c --- /dev/null +++ b/src/plaid/viewer/paraview_session.py @@ -0,0 +1,136 @@ +"""Manage a local PLAID data API for a ParaView launched from the viewer.""" + +from __future__ import annotations + +import atexit +import errno +import logging +import threading +from pathlib import Path +from subprocess import Popen +from typing import Any + +from plaid.cli.paraview_plugin import run_paraview_with_plugin_client_server +from plaid.cli.serve import ServeContext, _ServeHTTPServer + +logger = logging.getLogger(__name__) +_PARAVIEW_PORTS = range(8081, 8091) +_sessions: set[ParaViewSession] = set() +_sessions_lock = threading.Lock() + + +class ParaViewSession: + """Own the local HTTP server while its ParaView process is running.""" + + def __init__( + self, + server: _ServeHTTPServer, + process: Popen[Any], + server_thread: threading.Thread, + ) -> None: + """Store the running HTTP server and ParaView process. + + Args: + server: Local PLAID data API server. + process: ParaView process using the API. + server_thread: Thread already serving HTTP requests. + """ + self.server = server + self.process = process + self.port = server.server_port + self._server_thread = server_thread + + def start(self) -> None: + """Serve requests and stop serving once ParaView exits.""" + with _sessions_lock: + _sessions.add(self) + threading.Thread(target=self._wait_for_paraview, daemon=True).start() + + def _wait_for_paraview(self) -> None: + """Release the HTTP server when the associated ParaView exits.""" + try: + self.process.wait() + except Exception: # noqa: BLE001 + logger.exception("Failed to wait for ParaView") + finally: + self.close() + + def close(self) -> None: + """Stop the HTTP server, including on viewer shutdown.""" + with _sessions_lock: + if self not in _sessions: + return + _sessions.remove(self) + self.server.shutdown() + self.server.server_close() + self._server_thread.join(timeout=5) + + +def _close_sessions() -> None: + """Close all data API servers when the viewer exits.""" + with _sessions_lock: + sessions = list(_sessions) + for session in sessions: + session.close() + + +atexit.register(_close_sessions) + + +def _create_server(context: ServeContext) -> _ServeHTTPServer: + """Bind the data API to the first available port near the viewer port. + + Args: + context: Dataset context to expose through the data API. + + Returns: + A loopback-only server bound to a port between 8081 and 8090. + + Raises: + OSError: If no permitted port is available or server creation fails. + """ + for port in _PARAVIEW_PORTS: + try: + return _ServeHTTPServer(("127.0.0.1", port), context) + except OSError as error: + if error.errno not in (errno.EADDRINUSE, errno.EACCES): + raise + raise OSError( + "Cannot start the ParaView data API: no available port in 8081-8090. " + "Free or allow a port in this range and try again." + ) + + +def open_dataset_in_paraview(dataset_path: Path) -> ParaViewSession: + """Start a loopback-only data API and launch ParaView for a local dataset. + + Args: + dataset_path: Directory of the selected local PLAID dataset. + + Returns: + The session, with the selected API port between 8081 and 8090. + + Raises: + FileNotFoundError: If the dataset directory no longer exists. + OSError: If the server or ParaView cannot start. + """ + path = dataset_path.resolve(strict=True) + if not path.is_dir(): + raise FileNotFoundError(f"Dataset is not a directory: {path}") + server = _create_server(ServeContext(default_dataset_uri=str(path))) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + try: + # Bind and serve before launching: the plugin can connect immediately. + server_thread.start() + process = run_paraview_with_plugin_client_server( + port=server.server_port, options={"tree_name": path.name} + ) + session = ParaViewSession(server, process, server_thread) + session.start() + except BaseException: + if server_thread.is_alive(): + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + raise + return session diff --git a/src/plaid/viewer/trame_app/server.py b/src/plaid/viewer/trame_app/server.py index 5dae1450..cb215b88 100644 --- a/src/plaid/viewer/trame_app/server.py +++ b/src/plaid/viewer/trame_app/server.py @@ -1183,6 +1183,7 @@ async def _csv(request: Any) -> Any: "allow_dataset_change", dataset_service._config.allow_dataset_change ) state.setdefault("dataset_id", initial_dataset_id) + state.setdefault("paraview_status", "") # Separate lists per source so the dropdown only shows datasets that # match the active tab. ``dataset_ids`` is kept for backwards # compatibility (e.g. tests that inspect the full list) but the UI @@ -2119,6 +2120,7 @@ def _discard_globals() -> None: @state.change("dataset_id") def _on_dataset(**_: object) -> None: + state.paraview_status = "" _discard_globals() state.explore_global_filter = "" state.explore_global_filter_error = "" @@ -3035,6 +3037,46 @@ def _on_color_range(**_: object) -> None: # --- Datasets root management ---------------------------------------- + @ctrl.set("open_in_paraview") + def _open_in_paraview() -> None: + """Serve the selected local dataset and open the ParaView plugin.""" + dataset_id = state.dataset_id + if not dataset_id or dataset_id not in (state.local_dataset_ids or []): + state.paraview_status = "Select a local dataset first." + return + try: + # Re-discover rather than trusting a browser-supplied dataset id + # as a filesystem path (or the editable datasets-root text field). + dataset = next( + entry + for entry in dataset_service.list_datasets() + if entry.dataset_id == dataset_id and not entry.is_streaming + ) + from plaid.viewer.paraview_session import ( # noqa: PLC0415 + open_dataset_in_paraview, + ) + + session = open_dataset_in_paraview(Path(dataset.path)) + except StopIteration: + state.paraview_status = "Selected local dataset is no longer available." + except FileNotFoundError as exc: + logger.exception("Failed to open dataset in ParaView") + if exc.filename == os.environ.get("PARAVIEW_EXEC", "paraview"): + state.paraview_status = ( + "Cannot launch ParaView. Add paraview to your PATH, or set " + "PARAVIEW_EXEC to its executable path before starting " + "plaid-viewer." + ) + else: + state.paraview_status = f"Cannot open ParaView: {exc}" + except Exception as exc: # noqa: BLE001 + logger.exception("Failed to open dataset in ParaView") + state.paraview_status = f"Cannot open ParaView: {exc}" + else: + state.paraview_status = ( + f"Opened {dataset_id} in ParaView (data API port {session.port})." + ) + def _reload_dataset_list() -> None: """Re-discover datasets under the (possibly new) datasets root.""" try: @@ -3347,6 +3389,30 @@ def _reset_camera() -> None: hide_details=True, ) + with html.Div(classes="mb-1"): + v3.VBtn( + "Open in ParaView", + click=ctrl.open_in_paraview, + disabled=("!dataset_id || source_tab !== 'local'",), + color="primary", + variant="tonal", + block=True, + ) + v3.VTooltip( + text=( + "Open a local dataset in ParaView. Add paraview " + "to your PATH, or set PARAVIEW_EXEC to the full " + "executable path before starting plaid-viewer. " + "Hub datasets are not supported." + ), + activator="parent", + ) + html.Div( + "{{ paraview_status }}", + v_if=("paraview_status",), + classes="text-caption mb-3", + ) + with html.Div(classes="rounded-lg border pa-2 mb-3"): with html.Div( classes="d-flex justify-center", diff --git a/tests/cli/test_paraview_plugin.py b/tests/cli/test_paraview_plugin.py index 53fa8ab2..c86f9b18 100644 --- a/tests/cli/test_paraview_plugin.py +++ b/tests/cli/test_paraview_plugin.py @@ -84,8 +84,10 @@ def test_run_paraview_with_plugin_sets_environment(monkeypatch, tmp_path): """Launching ParaView should pass plugin-related environment variables.""" calls = [] - def fake_popen(args, env): - calls.append({"args": args, "env": env}) + def fake_popen(args, env, stdin): + calls.append({"args": args, "env": env, "stdin": stdin}) + assert Path(args[2]).is_file() + assert "PlaidExplorer1" in Path(args[2]).read_text() return SimpleNamespace(pid=1234) monkeypatch.setattr( @@ -96,12 +98,14 @@ def fake_popen(args, env): monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) monkeypatch.setenv("PARAVIEW_EXEC", "custom-paraview") - process = paraview_plugin.run_paraview_with_plugin() + process = paraview_plugin.run_paraview_with_plugin_client_server(port=8123) assert process.pid == 1234 - assert calls[0]["args"] == ["custom-paraview"] + assert calls[0]["args"][:2] == ["custom-paraview", "--script"] + assert calls[0]["env"]["SCRIPT_PATH"] == calls[0]["args"][2] + assert calls[0]["stdin"] == paraview_plugin.subprocess.DEVNULL assert calls[0]["env"]["PV_PLUGIN_PATH"] == str(tmp_path) - assert calls[0]["env"]["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" + assert calls[0]["env"]["PLAID_PORT"] == "8123" assert os.environ["PARAVIEW_EXEC"] == "custom-paraview" @@ -120,15 +124,60 @@ def test_run_paraview_with_plugin_sets_wslenv_for_windows_paraview( monkeypatch.setattr( paraview_plugin.subprocess, "Popen", - lambda args, env: calls.append({"args": args, "env": env}) or object(), + lambda args, env, stdin: ( + calls.append({"args": args, "env": env, "stdin": stdin}) or object() + ), ) monkeypatch.setenv("PARAVIEW_EXEC", "/mnt/c/ParaView/bin/paraview.exe") - paraview_plugin.run_paraview_with_plugin() + monkeypatch.setenv("WSLENV", "EXISTING/u") + paraview_plugin.run_paraview_with_plugin_client_server(port=8123) assert calls[0]["env"]["WSLENV"] == ( - "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" + "EXISTING/u:PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY:PLAID_PORT:SCRIPT_PATH" ) + assert calls[0]["env"]["PLAID_PORT"] == "8123" + + +def test_paraview_script_uses_custom_tree_name(monkeypatch, tmp_path): + """The options are reflected in the script passed to ParaView.""" + monkeypatch.setattr( + paraview_plugin, "get_ParaView_plugin_path_one_file", lambda: tmp_path + ) + scripts = [] + + def fake_popen(args, env, stdin): + assert env["SCRIPT_PATH"] == args[2] + assert stdin == paraview_plugin.subprocess.DEVNULL + scripts.append(Path(args[2]).read_text()) + return SimpleNamespace(pid=1234) + + monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) + paraview_plugin.run_paraview_with_plugin_client_server( + options={"tree_name": "A 'special' sample"} + ) + assert "registrationName=\"A 'special' sample\"" in scripts[0] + + +def test_paraview_script_directory_is_cleaned_up_on_launch_failure( + monkeypatch, tmp_path +): + """A failed executable launch does not leave the generated script behind.""" + monkeypatch.setattr( + paraview_plugin, "get_ParaView_plugin_path_one_file", lambda: tmp_path + ) + script_paths = [] + + def fail(args, env, stdin): + assert env["SCRIPT_PATH"] == args[2] + assert stdin == paraview_plugin.subprocess.DEVNULL + script_paths.append(Path(args[2])) + raise OSError("ParaView unavailable") + + monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fail) + with pytest.raises(OSError, match="ParaView unavailable"): + paraview_plugin.run_paraview_with_plugin_client_server() + assert not script_paths[0].exists() def test_process_operation_is_text_input_and_invalidates_cache_on_change(): @@ -201,7 +250,7 @@ def test_process_request_json_returns_response_and_propagates_errors( compile(ast.Module(body=[method], type_ignores=[]), str(plugin_path), "exec"), namespace, ) - client = SimpleNamespace(host="localhost", port=8001) + client = SimpleNamespace(host="localhost", port_nb=8001) class Response: """Provide a JSON HTTP response context manager.""" diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py index 6cd73917..6874e173 100644 --- a/tests/cli/test_serve.py +++ b/tests/cli/test_serve.py @@ -565,6 +565,7 @@ def test_main_runs_server_forever(monkeypatch): class FakeServer: def __init__(self, address, context): + self.server_port = address[1] calls.append(("init", address, context.default_dataset_uri)) def serve_forever(self): @@ -583,6 +584,7 @@ def test_main_runs_until_paraview_process_exits(monkeypatch): class FakeServer: def __init__(self, address, context): + self.server_port = address[1] calls.append(("init", address, context.default_dataset_uri)) monkeypatch.setattr(serve, "_ServeHTTPServer", FakeServer) @@ -592,11 +594,42 @@ def __init__(self, address, context): lambda server, pv_process: calls.append(("run_until_exit", server, pv_process)), ) monkeypatch.setattr( - "plaid.cli.paraview_plugin.run_paraview_with_plugin", - lambda: process, + "plaid.cli.paraview_plugin.run_paraview_with_plugin_client_server", + lambda port: calls.append(("launch", port)) or process, ) assert serve.main(["--port", "8123", "--ParaViewRun"]) == 0 assert calls[0] == ("init", ("0.0.0.0", 8123), None) - assert calls[1][0] == "run_until_exit" - assert calls[1][2] is process + assert calls[1] == ("launch", 8123) + assert calls[2][0] == "run_until_exit" + assert calls[2][2] is process + + +def test_main_closes_server_when_paraview_cannot_launch(monkeypatch): + """A failed ParaView launch must release the bound data API port.""" + calls = [] + + class FakeServer: + """Record server cleanup after a launch failure.""" + + def __init__(self, address, context): + self.server_port = address[1] + calls.append(("bind", context.default_dataset_uri)) + + def server_close(self): + """Record socket closure.""" + calls.append("close") + + def fail(port): + """Simulate ParaView not being installed.""" + calls.append(("launch", port)) + raise OSError("ParaView not found") + + monkeypatch.setattr(serve, "_ServeHTTPServer", FakeServer) + monkeypatch.setattr( + "plaid.cli.paraview_plugin.run_paraview_with_plugin_client_server", fail + ) + + with pytest.raises(OSError, match="ParaView not found"): + serve.main(["--port", "8123", "--dataset", "/tmp/data", "--ParaViewRun"]) + assert calls == [("bind", "/tmp/data"), ("launch", 8123), "close"] diff --git a/tests/viewer/test_paraview_session.py b/tests/viewer/test_paraview_session.py new file mode 100644 index 00000000..c33aadf1 --- /dev/null +++ b/tests/viewer/test_paraview_session.py @@ -0,0 +1,185 @@ +"""Tests for the viewer-owned local ParaView data API.""" + +from __future__ import annotations + +import errno +import socket +import threading +from pathlib import Path +from urllib import request + +import pytest + +from plaid.viewer import paraview_session + + +def test_open_dataset_serves_selected_path_on_actual_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + dataset = tmp_path / "selected" + dataset.mkdir() + launched: list[tuple[int, dict[str, str]]] = [] + exited = threading.Event() + + class Process: + """Model a running ParaView until the test releases it.""" + + def wait(self) -> None: + """Block until ParaView exits.""" + exited.wait(timeout=5) + + def launch(port: int, options: dict[str, str]) -> Process: + """Capture the API port and source options passed to ParaView. + + Args: + port: Port of the viewer-owned data API. + options: ParaView source settings, including the tree name. + + Returns: + The simulated running ParaView process. + """ + launched.append((port, options)) + with request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) as response: + assert response.read() == b'{"status": "ok"}' + return Process() + + monkeypatch.setattr( + paraview_session, "run_paraview_with_plugin_client_server", launch + ) + session = paraview_session.open_dataset_in_paraview(dataset) + try: + assert 8081 <= session.port <= 8090 + assert launched == [(session.port, {"tree_name": "selected"})] + assert session.server.server_address[0] == "127.0.0.1" + assert session.server.context.default_dataset_uri == str(dataset.resolve()) + with request.urlopen( + f"http://127.0.0.1:{session.port}/health", timeout=2 + ) as response: + assert response.read() == b'{"status": "ok"}' + finally: + exited.set() + session.close() + session.close() + + +def test_launch_failure_releases_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + dataset = tmp_path / "selected" + dataset.mkdir() + + def fail(port: int, options: dict[str, str]) -> None: + """Raise when ParaView cannot be started. + + Args: + port: Port of the viewer-owned data API. + options: ParaView source settings. + """ + assert options == {"tree_name": "selected"} + raise OSError(f"Cannot launch on {port}") + + monkeypatch.setattr( + paraview_session, "run_paraview_with_plugin_client_server", fail + ) + with pytest.raises(OSError, match="Cannot launch on"): + paraview_session.open_dataset_in_paraview(dataset) + + +def test_missing_dataset_is_rejected(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + paraview_session.open_dataset_in_paraview(tmp_path / "missing") + + file = tmp_path / "file" + file.touch() + with pytest.raises(FileNotFoundError, match="not a directory"): + paraview_session.open_dataset_in_paraview(file) + + +def test_session_closes_after_paraview_exits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The process watcher releases the API even when waiting fails.""" + dataset = tmp_path / "selected" + dataset.mkdir() + finished = threading.Event() + + class Process: + """Model a ParaView wait failure after its process exits.""" + + def wait(self) -> None: + """Signal the watcher, then raise a process error.""" + finished.wait(timeout=5) + raise OSError("wait failed") + + monkeypatch.setattr( + paraview_session, + "run_paraview_with_plugin_client_server", + lambda port, options: Process(), # noqa: ARG005 - launcher keywords + ) + session = paraview_session.open_dataset_in_paraview(dataset) + try: + assert session in paraview_session._sessions + finished.set() + # Both the watcher and shutdown cleanup should be safe to call. + paraview_session._close_sessions() + assert session not in paraview_session._sessions + finally: + finished.set() + session.close() + + +def test_server_skips_occupied_port(monkeypatch: pytest.MonkeyPatch) -> None: + """Use the next candidate when a real socket occupies the first port.""" + with socket.socket() as occupied, socket.socket() as available: + occupied.bind(("127.0.0.1", 0)) + occupied.listen() + available.bind(("127.0.0.1", 0)) + occupied_port = occupied.getsockname()[1] + available_port = available.getsockname()[1] + available.close() + monkeypatch.setattr( + paraview_session, "_PARAVIEW_PORTS", (occupied_port, available_port) + ) + server = paraview_session._create_server(paraview_session.ServeContext()) + try: + assert server.server_port == available_port + finally: + server.server_close() + + +def test_server_reports_exhausted_ports(monkeypatch: pytest.MonkeyPatch) -> None: + """Report exhaustion rather than fall back to an OS-assigned port.""" + with socket.socket() as occupied: + occupied.bind(("127.0.0.1", 0)) + occupied.listen() + monkeypatch.setattr( + paraview_session, "_PARAVIEW_PORTS", (occupied.getsockname()[1],) + ) + with pytest.raises(OSError, match="no available port in 8081-8090"): + paraview_session._create_server(paraview_session.ServeContext()) + + +@pytest.mark.parametrize("error_number", [errno.EACCES, errno.EINVAL]) +def test_server_handles_bind_errors( + monkeypatch: pytest.MonkeyPatch, error_number: int +) -> None: + """Skip denied ports, but propagate unexpected server errors.""" + attempted: list[int] = [] + + def fail(address: tuple[str, int], context: object) -> None: + """Record the bind attempt and simulate an operating system error. + + Args: + address: Loopback address and candidate API port. + context: Dataset serving context. + """ + assert isinstance(context, paraview_session.ServeContext) + attempted.append(address[1]) + raise OSError(error_number, "bind failed") + + monkeypatch.setattr(paraview_session, "_ServeHTTPServer", fail) + message = "no available port" if error_number == errno.EACCES else "bind failed" + with pytest.raises(OSError, match=message): + paraview_session._create_server(paraview_session.ServeContext()) + expected = list(range(8081, 8091)) if error_number == errno.EACCES else [8081] + assert attempted == expected diff --git a/tests/viewer/test_trame_server.py b/tests/viewer/test_trame_server.py index f74e4971..4939c886 100644 --- a/tests/viewer/test_trame_server.py +++ b/tests/viewer/test_trame_server.py @@ -299,6 +299,103 @@ def handle_endtag(self, tag: str) -> None: assert "allow_root_change" not in parser.selector_conditions +@pytest.mark.usefixtures("headless_trame_view") +def test_open_in_paraview_uses_current_local_selection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Launch only the selected local dataset, not another root or Hub repo.""" + from trame import app # noqa: PLC0415 + + from plaid.viewer import paraview_session # noqa: PLC0415 + from plaid.viewer.config import ViewerConfig # noqa: PLC0415 + from plaid.viewer.services import ( # noqa: PLC0415 + ParaviewArtifactService, + PlaidDatasetService, + ) + from plaid.viewer.trame_app.server import build_server # noqa: PLC0415 + + for name in ("first", "second"): + (tmp_path / name / "data").mkdir(parents=True) + get_server = app.get_server + monkeypatch.setattr( + app, + "get_server", + lambda **kwargs: get_server(name="open-paraview-test", **kwargs), + ) + launched: list[Path] = [] + + def launch(path: Path) -> types.SimpleNamespace: + """Record the selected path without starting a desktop application.""" + launched.append(path) + return types.SimpleNamespace(port=8123) + + monkeypatch.setattr(paraview_session, "open_dataset_in_paraview", launch) + service = PlaidDatasetService(ViewerConfig(datasets_root=tmp_path)) + service.add_hub_dataset("org/repo") + server = build_server(service, ParaviewArtifactService(service, tmp_path)) + server.state.ready() + assert "Open in ParaView" in server.state["trame__template_main"] + assert "Add paraview to your PATH" in server.state["trame__template_main"] + assert "PARAVIEW_EXEC" in server.state["trame__template_main"] + assert "Hub datasets are not supported" in server.state["trame__template_main"] + + server.state.dataset_id = "second" + server.state.flush() + server.controller.open_in_paraview() + assert launched == [tmp_path / "second"] + assert "8123" in server.state.paraview_status + + server.state.dataset_id = "org/repo" + server.state.flush() + server.controller.open_in_paraview() + assert launched == [tmp_path / "second"] + assert "Select a local dataset" in server.state.paraview_status + + server.state.dataset_id = "first" + server.state.flush() + monkeypatch.setattr( + paraview_session, + "open_dataset_in_paraview", + lambda _path: (_ for _ in ()).throw(OSError("not installed")), + ) + server.controller.open_in_paraview() + assert "not installed" in server.state.paraview_status + + def missing_executable(_path: Path) -> None: + """Model the launcher failing to find the ParaView executable.""" + raise FileNotFoundError(2, "No such file or directory", "paraview") + + monkeypatch.setattr( + paraview_session, "open_dataset_in_paraview", missing_executable + ) + server.controller.open_in_paraview() + assert "paraview to your PATH" in server.state.paraview_status + assert "PARAVIEW_EXEC" in server.state.paraview_status + + monkeypatch.setenv("PARAVIEW_EXEC", "/opt/paraview/bin/paraview") + + def missing_custom_executable(_path: Path) -> None: + """Model a missing custom ParaView executable.""" + raise FileNotFoundError( + 2, "No such file or directory", "/opt/paraview/bin/paraview" + ) + + monkeypatch.setattr( + paraview_session, "open_dataset_in_paraview", missing_custom_executable + ) + server.controller.open_in_paraview() + assert "PARAVIEW_EXEC" in server.state.paraview_status + + monkeypatch.setattr( + paraview_session, + "open_dataset_in_paraview", + lambda _path: (_ for _ in ()).throw(FileNotFoundError("dataset missing")), + ) + server.controller.open_in_paraview() + assert "dataset missing" in server.state.paraview_status + assert "Add paraview" not in server.state.paraview_status + + @pytest.mark.usefixtures("headless_trame_view") def test_local_sample_picker_has_number_input_before_slider( tmp_path: Path, monkeypatch: pytest.MonkeyPatch