From d14f1c94a7e9818c39bb95b17096480f763f2614 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 21 Oct 2026 15:42:28 +1030 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=8E=89=20feat(paraview):=20integrate?= =?UTF-8?q?=20local=20dataset=20support=20and=20launch=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/source/concepts/serve.md | 4 + docs/source/concepts/viewer.md | 19 ++++ src/plaid/cli/paraview_plugin/__init__.py | 84 ++++++++++++++-- src/plaid/cli/serve.py | 18 ++-- src/plaid/viewer/paraview_session.py | 110 +++++++++++++++++++++ src/plaid/viewer/trame_app/server.py | 47 +++++++++ tests/cli/test_paraview_plugin.py | 64 +++++++++++-- tests/cli/test_serve.py | 41 +++++++- tests/viewer/test_paraview_session.py | 111 ++++++++++++++++++++++ tests/viewer/test_trame_server.py | 60 ++++++++++++ 10 files changed, 529 insertions(+), 29 deletions(-) create mode 100644 src/plaid/viewer/paraview_session.py create mode 100644 tests/viewer/test_paraview_session.py 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 65f841b7..788ecdc7 100644 --- a/docs/source/concepts/viewer.md +++ b/docs/source/concepts/viewer.md @@ -63,6 +63,25 @@ independently when the viewer is configured to pin either choice. 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 a free API port automatically and keeps the server running until that +ParaView instance exits (or the viewer shuts down). 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/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 439d6c5f..ef154d31 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -1,11 +1,14 @@ """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" +paraview_install_dir = "" def get_ParaView_plugin_path(): @@ -70,18 +73,83 @@ 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["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}) + +# 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, 'UnstructuredGridRepresentation') + +# 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..32bfac35 --- /dev/null +++ b/src/plaid/viewer/paraview_session.py @@ -0,0 +1,110 @@ +"""Manage a local PLAID data API for a ParaView launched from the viewer.""" + +from __future__ import annotations + +import atexit +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__) +_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 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 actual OS-assigned API port. + + 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 = _ServeHTTPServer( + ("127.0.0.1", 0), 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) + 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 5a695c78..482d81ab 100644 --- a/src/plaid/viewer/trame_app/server.py +++ b/src/plaid/viewer/trame_app/server.py @@ -1170,6 +1170,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 @@ -2104,6 +2105,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 = "" @@ -3020,6 +3022,36 @@ 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 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: @@ -3332,6 +3364,21 @@ def _reset_camera() -> None: hide_details=True, ) + v3.VBtn( + "Open in ParaView", + click=ctrl.open_in_paraview, + disabled=("!dataset_id || source_tab !== 'local'",), + color="primary", + variant="tonal", + block=True, + classes="mb-1", + ) + 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..38c9c5bf 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,15 @@ 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 +125,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(): 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..3f0a1d1d --- /dev/null +++ b/tests/viewer/test_paraview_session.py @@ -0,0 +1,111 @@ +"""Tests for the viewer-owned local ParaView data API.""" + +from __future__ import annotations + +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[int] = [] + 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) -> Process: + """Capture the actual data API port passed to ParaView.""" + launched.append(port) + 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 launched == [session.port] + 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) -> None: + """Raise when ParaView cannot be started.""" + 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: Process(), # noqa: ARG005 - named launcher keyword + ) + 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() diff --git a/tests/viewer/test_trame_server.py b/tests/viewer/test_trame_server.py index 551f0aea..ee46b492 100644 --- a/tests/viewer/test_trame_server.py +++ b/tests/viewer/test_trame_server.py @@ -299,6 +299,66 @@ 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"] + + 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 + + @pytest.mark.usefixtures("headless_trame_view") def test_local_sample_picker_has_number_input_before_slider( tmp_path: Path, monkeypatch: pytest.MonkeyPatch From b6ce6387d318eed9346432d32ff4ecce18cfc49e Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 21 Oct 2026 16:00:46 +1030 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=8E=89=20feat(paraview):=20refactor?= =?UTF-8?q?=20host=20and=20port=20attributes=20for=20clarity=20and=20updat?= =?UTF-8?q?e=20related=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../paraview_plugin/PlaidParaViewPlugin.py | 36 +++++++++--------- src/plaid/cli/paraview_plugin/__init__.py | 7 ++-- src/plaid/viewer/paraview_session.py | 4 +- src/plaid/viewer/trame_app/server.py | 37 ++++++++++++++----- tests/viewer/test_paraview_session.py | 30 +++++++++++---- tests/viewer/test_trame_server.py | 37 +++++++++++++++++++ 6 files changed, 111 insertions(+), 40 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 9bc270e3..7514da88 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" @@ -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 ef154d31..96396158 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -88,7 +88,7 @@ def run_paraview_with_plugin_client_server( 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" my_env["PLAID_PORT"] = str(port) current_pv_path = os.environ.get("PARAVIEW_EXEC", paraview_exec) @@ -110,7 +110,7 @@ def run_paraview_with_plugin_client_server( # create a new 'Plaid Explorer' plaidExplorer1 = PlaidExplorer(registrationName={tree_name!r}) - +plaidExplorer1.UpdatePipeline() # get animation scene animationScene1 = GetAnimationScene() @@ -119,9 +119,8 @@ def run_paraview_with_plugin_client_server( # get active view renderView1 = GetActiveViewOrCreate('RenderView') - # show data in view -plaidExplorer1Display = Show(plaidExplorer1, renderView1, 'UnstructuredGridRepresentation') +plaidExplorer1Display = Show(plaidExplorer1, renderView1) # trace defaults for the display properties. plaidExplorer1Display.Representation = 'Surface' diff --git a/src/plaid/viewer/paraview_session.py b/src/plaid/viewer/paraview_session.py index 32bfac35..dd41f494 100644 --- a/src/plaid/viewer/paraview_session.py +++ b/src/plaid/viewer/paraview_session.py @@ -98,7 +98,9 @@ def open_dataset_in_paraview(dataset_path: Path) -> ParaViewSession: 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) + 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: diff --git a/src/plaid/viewer/trame_app/server.py b/src/plaid/viewer/trame_app/server.py index 482d81ab..9455df84 100644 --- a/src/plaid/viewer/trame_app/server.py +++ b/src/plaid/viewer/trame_app/server.py @@ -3044,6 +3044,16 @@ def _open_in_paraview() -> None: 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}" @@ -3364,15 +3374,24 @@ def _reset_camera() -> None: hide_details=True, ) - v3.VBtn( - "Open in ParaView", - click=ctrl.open_in_paraview, - disabled=("!dataset_id || source_tab !== 'local'",), - color="primary", - variant="tonal", - block=True, - classes="mb-1", - ) + 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",), diff --git a/tests/viewer/test_paraview_session.py b/tests/viewer/test_paraview_session.py index 3f0a1d1d..49214d21 100644 --- a/tests/viewer/test_paraview_session.py +++ b/tests/viewer/test_paraview_session.py @@ -16,7 +16,7 @@ def test_open_dataset_serves_selected_path_on_actual_port( ) -> None: dataset = tmp_path / "selected" dataset.mkdir() - launched: list[int] = [] + launched: list[tuple[int, dict[str, str]]] = [] exited = threading.Event() class Process: @@ -26,9 +26,17 @@ def wait(self) -> None: """Block until ParaView exits.""" exited.wait(timeout=5) - def launch(port: int) -> Process: - """Capture the actual data API port passed to ParaView.""" - launched.append(port) + 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() @@ -38,7 +46,7 @@ def launch(port: int) -> Process: ) session = paraview_session.open_dataset_in_paraview(dataset) try: - assert launched == [session.port] + 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( @@ -57,8 +65,14 @@ def test_launch_failure_releases_port( dataset = tmp_path / "selected" dataset.mkdir() - def fail(port: int) -> None: - """Raise when ParaView cannot be started.""" + 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( @@ -97,7 +111,7 @@ def wait(self) -> None: monkeypatch.setattr( paraview_session, "run_paraview_with_plugin_client_server", - lambda port: Process(), # noqa: ARG005 - named launcher keyword + lambda port, options: Process(), # noqa: ARG005 - launcher keywords ) session = paraview_session.open_dataset_in_paraview(dataset) try: diff --git a/tests/viewer/test_trame_server.py b/tests/viewer/test_trame_server.py index ee46b492..2f172a54 100644 --- a/tests/viewer/test_trame_server.py +++ b/tests/viewer/test_trame_server.py @@ -335,6 +335,9 @@ def launch(path: Path) -> types.SimpleNamespace: 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() @@ -358,6 +361,40 @@ def launch(path: Path) -> types.SimpleNamespace: 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( From 3ffa9566eaa167c532a11cda90ae71e880d20d01 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 22 Oct 2026 17:08:29 +1030 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=8E=89=20feat(paraview):=20enhance=20?= =?UTF-8?q?local=20dataset=20server=20to=20select=20available=20ports=20an?= =?UTF-8?q?d=20handle=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/source/concepts/viewer.md | 7 +++- src/plaid/viewer/paraview_session.py | 32 ++++++++++++-- tests/viewer/test_paraview_session.py | 60 +++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/docs/source/concepts/viewer.md b/docs/source/concepts/viewer.md index 788ecdc7..0c13d70f 100644 --- a/docs/source/concepts/viewer.md +++ b/docs/source/concepts/viewer.md @@ -65,8 +65,11 @@ 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 a free API port automatically and keeps the server running until that -ParaView instance exits (or the viewer shuts down). ParaView must be installed +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: diff --git a/src/plaid/viewer/paraview_session.py b/src/plaid/viewer/paraview_session.py index dd41f494..ce45a89c 100644 --- a/src/plaid/viewer/paraview_session.py +++ b/src/plaid/viewer/paraview_session.py @@ -3,6 +3,7 @@ from __future__ import annotations import atexit +import errno import logging import threading from pathlib import Path @@ -13,6 +14,7 @@ from plaid.cli.serve import ServeContext, _ServeHTTPServer logger = logging.getLogger(__name__) +_PARAVIEW_PORTS = range(8081, 8091) _sessions: set[ParaViewSession] = set() _sessions_lock = threading.Lock() @@ -75,6 +77,30 @@ def _close_sessions() -> None: 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. @@ -82,7 +108,7 @@ def open_dataset_in_paraview(dataset_path: Path) -> ParaViewSession: dataset_path: Directory of the selected local PLAID dataset. Returns: - The session, with the actual OS-assigned API port. + The session, with the selected API port between 8081 and 8090. Raises: FileNotFoundError: If the dataset directory no longer exists. @@ -91,9 +117,7 @@ def open_dataset_in_paraview(dataset_path: Path) -> ParaViewSession: path = dataset_path.resolve(strict=True) if not path.is_dir(): raise FileNotFoundError(f"Dataset is not a directory: {path}") - server = _ServeHTTPServer( - ("127.0.0.1", 0), ServeContext(default_dataset_uri=str(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. diff --git a/tests/viewer/test_paraview_session.py b/tests/viewer/test_paraview_session.py index 49214d21..c33aadf1 100644 --- a/tests/viewer/test_paraview_session.py +++ b/tests/viewer/test_paraview_session.py @@ -2,6 +2,8 @@ from __future__ import annotations +import errno +import socket import threading from pathlib import Path from urllib import request @@ -46,6 +48,7 @@ def launch(port: int, options: dict[str, str]) -> Process: ) 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()) @@ -123,3 +126,60 @@ def wait(self) -> None: 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 From 6bb3f5e9028c49434c2cf1f47194fc4a7559e9f2 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 22 Oct 2026 17:46:04 +1030 Subject: [PATCH 4/5] format and test fixup --- src/plaid/cli/paraview_plugin/__init__.py | 3 +-- tests/cli/test_paraview_plugin.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 96396158..6aed46df 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -8,7 +8,6 @@ from typing import Optional paraview_exec = "paraview" -paraview_install_dir = "" def get_ParaView_plugin_path(): @@ -88,7 +87,7 @@ def run_paraview_with_plugin_client_server( 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) diff --git a/tests/cli/test_paraview_plugin.py b/tests/cli/test_paraview_plugin.py index 38c9c5bf..c86f9b18 100644 --- a/tests/cli/test_paraview_plugin.py +++ b/tests/cli/test_paraview_plugin.py @@ -105,7 +105,6 @@ def fake_popen(args, env, stdin): 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" @@ -251,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.""" From 54977278362c4126b1a687fa42670f8419a0ffbb Mon Sep 17 00:00:00 2001 From: Fabien Casenave Date: Thu, 22 Oct 2026 10:39:59 +0200 Subject: [PATCH 5/5] update changelog --- CHANGELOG.md | 3 +++ src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 565357d2..beeae7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,15 @@ 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`). ### Changed - (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/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 7514da88..d9abcf59 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -428,7 +428,7 @@ def SetHost(self, value): return super().SetHost(value) @smproperty.intvector( - name="PortNb", default_values=str(plaid_explorer_server_default_port) + name="PortNB", default_values=str(plaid_explorer_server_default_port) ) def SetPortNB(self, value): """Set the server port to connect to for fetching dataset information and samples.""" @@ -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