nt2gui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nt2gui/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """nt2gui: a browser UI for Entity (nt2py) simulation output."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("nt2gui")
7
+ except PackageNotFoundError: # running from a source checkout
8
+ __version__ = "0.0.0+unknown"
nt2gui/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
nt2gui/api/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """The JSON API, served under `/api`: one blueprint per area."""
2
+
3
+ from flask import Blueprint
4
+
5
+ from . import fields, particles, simulations, state
6
+
7
+
8
+ def create_api() -> Blueprint:
9
+ api = Blueprint("api", __name__)
10
+ for area in (state, simulations, fields, particles):
11
+ api.register_blueprint(area.bp)
12
+ return api
nt2gui/api/common.py ADDED
@@ -0,0 +1,69 @@
1
+ """Request helpers shared by the API blueprints.
2
+
3
+ Helpers raise `UserError` (400) / `NotFound` (404); the app's error handlers
4
+ turn those, and pydantic `ValidationError`s, into JSON error responses.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, TypeVar
9
+
10
+ import xarray as xr
11
+ from flask import current_app, request
12
+ from pydantic import BaseModel, ValidationError
13
+
14
+ from ..data.particles import particle_dataset
15
+ from ..errors import UserError
16
+ from ..sources import SimulationSource
17
+ from ..workspace import Workspace
18
+
19
+ EXTENSION_KEY = "nt2gui.workspace"
20
+ Model = TypeVar("Model", bound=BaseModel)
21
+
22
+
23
+ def workspace() -> Workspace:
24
+ return current_app.extensions[EXTENSION_KEY]
25
+
26
+
27
+ def simulation(simulation_id: str) -> SimulationSource:
28
+ return workspace().simulation(simulation_id)
29
+
30
+
31
+ def json_object() -> dict[str, Any]:
32
+ """The request body, which must be a JSON object (missing counts as {})."""
33
+ payload = request.get_json(silent=True)
34
+ if payload is None:
35
+ return {}
36
+ if not isinstance(payload, dict):
37
+ raise UserError("Expected a JSON object")
38
+ return payload
39
+
40
+
41
+ def require_time(payload: dict[str, Any]) -> float:
42
+ time = payload.get("time")
43
+ # bool is an int subclass, but never a valid time.
44
+ if isinstance(time, bool) or not isinstance(time, (int, float)):
45
+ raise UserError("Time value is required")
46
+ return float(time)
47
+
48
+
49
+ def require_text(payload: dict[str, Any], key: str, message: str) -> str:
50
+ value = payload.get(key)
51
+ if not isinstance(value, str) or not value.strip():
52
+ raise UserError(message)
53
+ return value.strip()
54
+
55
+
56
+ def parse(model: type[Model], value: Any, what: str) -> Model:
57
+ try:
58
+ return model.model_validate(value)
59
+ except ValidationError as error:
60
+ raise UserError(
61
+ f"Invalid {what}", details=json.loads(error.json(include_url=False))
62
+ ) from error
63
+
64
+
65
+ def particles_at(source: SimulationSource, time: float) -> tuple[xr.Dataset, float]:
66
+ """Particles of the output nearest to `time`, and that output's time."""
67
+ if not source.species:
68
+ raise UserError("This simulation has no particle output")
69
+ return particle_dataset(source.particles(time)), source.particle_time(time)
nt2gui/api/fields.py ADDED
@@ -0,0 +1,59 @@
1
+ """Field metadata, custom-quantity checks, and field plots."""
2
+
3
+ from flask import Blueprint, jsonify
4
+
5
+ from ..data.metadata import field_metadata
6
+ from ..data.quantities import quantity_dims
7
+ from ..plots.line import render_line
8
+ from ..plots.pseudocolor import render_pseudocolor
9
+ from ..session.schema import LineConfig, PseudocolorConfig, Range, ZoomWindow
10
+ from .common import json_object, parse, require_text, require_time, simulation
11
+
12
+ bp = Blueprint("fields", __name__, url_prefix="/simulations/<simulation_id>")
13
+
14
+
15
+ @bp.get("/fields")
16
+ def get_fields(simulation_id: str):
17
+ return jsonify(field_metadata(simulation(simulation_id).fields))
18
+
19
+
20
+ @bp.post("/quantities/check")
21
+ def check_quantity(simulation_id: str):
22
+ """Validate a field name or expression and report its spatial dims."""
23
+ source = simulation(simulation_id)
24
+ expression = require_text(json_object(), "expression", "An expression is required")
25
+ dims = quantity_dims(expression, source.fields)
26
+ return jsonify({"expression": expression, "dims": dims})
27
+
28
+
29
+ @bp.post("/render/line")
30
+ def line_plot(simulation_id: str):
31
+ source = simulation(simulation_id)
32
+ payload = json_object()
33
+ time = require_time(payload)
34
+ config = parse(LineConfig, payload.get("config"), "line plot request")
35
+ value_range = (
36
+ parse(Range, payload["range"], "zoom range").to_wire()
37
+ if payload.get("range") is not None
38
+ else None
39
+ )
40
+ fields = source.fields
41
+ return jsonify(
42
+ render_line(
43
+ fields, config.quantities, config.axis, config.slices, time, value_range
44
+ )
45
+ )
46
+
47
+
48
+ @bp.post("/render/pseudocolor")
49
+ def pseudocolor_plot(simulation_id: str):
50
+ source = simulation(simulation_id)
51
+ payload = json_object()
52
+ time = require_time(payload)
53
+ config = parse(PseudocolorConfig, payload.get("config"), "pseudocolor request")
54
+ viewport = (
55
+ parse(ZoomWindow, payload["viewport"], "zoom window")
56
+ if payload.get("viewport") is not None
57
+ else None
58
+ )
59
+ return jsonify(render_pseudocolor(source.fields, config, time, viewport))
@@ -0,0 +1,62 @@
1
+ """Particle metadata, custom-quantity checks, and particle plots."""
2
+
3
+ from flask import Blueprint, jsonify
4
+
5
+ from ..data.metadata import particle_metadata
6
+ from ..data.particles import particle_dataset, quantity_values
7
+ from ..errors import UserError
8
+ from ..plots.particle_histogram import render_particle_histogram
9
+ from ..plots.particle_phase import render_particle_phase
10
+ from ..session.schema import ParticleHistogramConfig, ParticlePhaseConfig
11
+ from ..sources import first_particles
12
+ from .common import (
13
+ json_object,
14
+ parse,
15
+ particles_at,
16
+ require_text,
17
+ require_time,
18
+ simulation,
19
+ )
20
+
21
+ bp = Blueprint("particles", __name__, url_prefix="/simulations/<simulation_id>")
22
+
23
+ # Expressions are checked on a few particles; validity doesn't depend on values.
24
+ CHECK_SAMPLE_SIZE = 16
25
+
26
+
27
+ @bp.get("/particles")
28
+ def get_particles(simulation_id: str):
29
+ return jsonify(particle_metadata(simulation(simulation_id)))
30
+
31
+
32
+ @bp.post("/particles/check")
33
+ def check_particle_quantity(simulation_id: str):
34
+ source = simulation(simulation_id)
35
+ expression = require_text(json_object(), "expression", "An expression is required")
36
+ if not source.species:
37
+ raise UserError("This simulation has no particle output")
38
+ sample = particle_dataset(first_particles(source).head(CHECK_SAMPLE_SIZE))
39
+ quantity_values(expression, sample)
40
+ return jsonify({"expression": expression})
41
+
42
+
43
+ @bp.post("/render/particles/histogram")
44
+ def histogram_plot(simulation_id: str):
45
+ source = simulation(simulation_id)
46
+ payload = json_object()
47
+ time = require_time(payload)
48
+ config = parse(ParticleHistogramConfig, payload.get("config"), "histogram request")
49
+ particles, timestamp = particles_at(source, time)
50
+ return jsonify(
51
+ {**render_particle_histogram(particles, config), "timestamp": timestamp}
52
+ )
53
+
54
+
55
+ @bp.post("/render/particles/phase")
56
+ def phase_plot(simulation_id: str):
57
+ source = simulation(simulation_id)
58
+ payload = json_object()
59
+ time = require_time(payload)
60
+ config = parse(ParticlePhaseConfig, payload.get("config"), "phase-space request")
61
+ particles, timestamp = particles_at(source, time)
62
+ return jsonify({**render_particle_phase(particles, config), "timestamp": timestamp})
@@ -0,0 +1,24 @@
1
+ """Opening and closing simulations, and what each contains."""
2
+
3
+ from flask import Blueprint, jsonify
4
+
5
+ from ..data.metadata import summarize
6
+ from .common import json_object, require_text, simulation, workspace
7
+
8
+ bp = Blueprint("simulations", __name__)
9
+
10
+
11
+ @bp.post("/simulations")
12
+ def add_simulation():
13
+ path = require_text(json_object(), "path", "Simulation path is required")
14
+ return jsonify(workspace().add(path))
15
+
16
+
17
+ @bp.delete("/simulations/<simulation_id>")
18
+ def remove_simulation(simulation_id: str):
19
+ return jsonify(workspace().remove(simulation_id))
20
+
21
+
22
+ @bp.get("/simulations/<simulation_id>/summary")
23
+ def get_summary(simulation_id: str):
24
+ return jsonify(summarize(simulation(simulation_id)))
nt2gui/api/state.py ADDED
@@ -0,0 +1,79 @@
1
+ """The synced UI state, and saving/loading whole sessions."""
2
+
3
+ from flask import Blueprint, current_app, jsonify
4
+
5
+ from ..errors import Conflict, NotFound, UserError
6
+ from ..session.files import (
7
+ parse_document,
8
+ read_document,
9
+ resolve_session_path,
10
+ write_document,
11
+ )
12
+ from ..session.schema import UiState
13
+ from .common import json_object, parse, require_text, workspace
14
+
15
+ bp = Blueprint("state", __name__)
16
+
17
+
18
+ @bp.get("/state")
19
+ def get_state():
20
+ return jsonify(workspace().store.snapshot())
21
+
22
+
23
+ @bp.put("/state")
24
+ def put_state():
25
+ payload = json_object()
26
+ revision = payload.get("revision")
27
+ if isinstance(revision, bool) or not isinstance(revision, int):
28
+ raise UserError("Expected {revision: int, state: object}")
29
+ state = parse(UiState, payload.get("state"), "state")
30
+ return jsonify(workspace().store.replace_state(state, revision))
31
+
32
+
33
+ @bp.get("/session/export")
34
+ def export_session():
35
+ return jsonify(workspace().store.document().to_wire())
36
+
37
+
38
+ @bp.post("/session/import")
39
+ def import_session():
40
+ try:
41
+ document = parse_document(json_object().get("session"))
42
+ except ValueError as error:
43
+ raise UserError(f"Invalid session file: {error}") from error
44
+ return jsonify(workspace().apply_document(document))
45
+
46
+
47
+ @bp.post("/session/save")
48
+ def save_session():
49
+ payload = json_object()
50
+ path = resolve_session_path(
51
+ require_text(payload, "path", "A session file path is required")
52
+ )
53
+ try:
54
+ write_document(
55
+ path, workspace().store.document(), bool(payload.get("overwrite"))
56
+ )
57
+ except FileExistsError as error:
58
+ raise Conflict("File already exists", path=str(path)) from error
59
+ except OSError as error:
60
+ raise UserError(f"Could not write {path}: {error.strerror or error}") from error
61
+ current_app.logger.info("Session saved to %s", path)
62
+ return jsonify({**workspace().store.snapshot(), "path": str(path)})
63
+
64
+
65
+ @bp.post("/session/load")
66
+ def load_session():
67
+ path = resolve_session_path(
68
+ require_text(json_object(), "path", "A session file path is required")
69
+ )
70
+ try:
71
+ document = read_document(path)
72
+ except FileNotFoundError as error:
73
+ raise NotFound("File not found", path=str(path)) from error
74
+ except ValueError as error:
75
+ raise UserError(f"Invalid session file: {error}") from error
76
+ except OSError as error:
77
+ raise UserError(f"Could not read file: {error.strerror or error}") from error
78
+ current_app.logger.info("Session loaded from %s", path)
79
+ return jsonify({**workspace().apply_document(document), "path": str(path)})
nt2gui/app.py ADDED
@@ -0,0 +1,51 @@
1
+ """The Flask app: the JSON API under `/api` and the built client at `/`."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from flask import Flask, jsonify, send_from_directory
7
+
8
+ from . import config
9
+ from .api import create_api
10
+ from .api.common import EXTENSION_KEY
11
+ from .errors import UserError
12
+ from .session.store import SessionStore
13
+ from .workspace import Workspace
14
+
15
+
16
+ def create_app(
17
+ state_file: Path | str | None = None, static_dir: Path | str | None = None
18
+ ) -> Flask:
19
+ """`state_file` defaults to $NT2GUI_STATE_FILE, then `.nt2gui/session.json`
20
+ in the working directory; `static_dir` defaults to the bundled client."""
21
+ app = Flask(__name__, static_folder=None)
22
+ app.logger.setLevel(logging.INFO)
23
+
24
+ path = config.state_file(state_file)
25
+ app.logger.info("Session file: %s", path.resolve())
26
+ app.extensions[EXTENSION_KEY] = Workspace(SessionStore(path))
27
+
28
+ @app.errorhandler(UserError)
29
+ def user_error(error: UserError):
30
+ return jsonify({"error": error.message, **error.extra}), error.status
31
+
32
+ app.register_blueprint(create_api(), url_prefix="/api")
33
+ _serve_client(app, Path(static_dir) if static_dir else config.STATIC_DIR)
34
+ return app
35
+
36
+
37
+ def _serve_client(app: Flask, static_dir: Path) -> None:
38
+ """Serve the single-page client. Asset URLs are relative (vite `base: "./"`)
39
+ so the app also works behind a path-prefixing proxy."""
40
+
41
+ @app.get("/")
42
+ @app.get("/<path:filename>")
43
+ def client(filename: str = "index.html"):
44
+ if not (static_dir / "index.html").is_file():
45
+ return (
46
+ "The nt2gui client is not built. Run `npm run build`, or use "
47
+ "`npm run dev` for development.",
48
+ 404,
49
+ {"Content-Type": "text/plain; charset=utf-8"},
50
+ )
51
+ return send_from_directory(static_dir, filename)
nt2gui/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """`nt2gui serve`: run the server (API and bundled client) on this machine.
2
+
3
+ The server is meant to run next to the data (e.g. on a cluster node) while the
4
+ browser runs on the user's laptop, connected through an SSH tunnel.
5
+ """
6
+
7
+ import argparse
8
+ import getpass
9
+ import logging
10
+ import socket
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__, config
15
+
16
+ DEFAULT_HOST = "127.0.0.1"
17
+ DEFAULT_PORT = 8765
18
+
19
+
20
+ def tunnel_hint(port: int, host: str, hostname: str, user: str) -> str:
21
+ """How to reach a server on `hostname:port` from the user's machine."""
22
+ if host in ("127.0.0.1", "localhost", "::1"):
23
+ # Loopback is reachable only from `hostname` itself, so tunnel to it.
24
+ command = f"ssh -N -L {port}:localhost:{port} {user}@{hostname}"
25
+ note = f"(add `-J {user}@<login-node>` if {hostname} is behind a login node)"
26
+ else:
27
+ command = f"ssh -N -L {port}:{hostname}:{port} {user}@<login-node>"
28
+ note = ""
29
+ lines = [
30
+ f"nt2gui {__version__} serving on {hostname}:{port}",
31
+ "From your machine, open a tunnel:",
32
+ f" {command}",
33
+ *([f" {note}"] if note else []),
34
+ f"then browse to http://localhost:{port}/",
35
+ ]
36
+ return "\n".join(lines)
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(prog="nt2gui", description=__doc__)
41
+ parser.add_argument("--version", action="version", version=__version__)
42
+ commands = parser.add_subparsers(dest="command", required=True)
43
+
44
+ serve = commands.add_parser("serve", help="Run the nt2gui server.")
45
+ serve.add_argument(
46
+ "--host",
47
+ default=DEFAULT_HOST,
48
+ help=f"Interface to bind (default: {DEFAULT_HOST}, reachable only "
49
+ "from this machine; use an SSH tunnel).",
50
+ )
51
+ serve.add_argument(
52
+ "--port",
53
+ type=int,
54
+ default=DEFAULT_PORT,
55
+ help=f"Port to listen on (default: {DEFAULT_PORT}; 0 picks a free one).",
56
+ )
57
+ serve.add_argument(
58
+ "--state-file",
59
+ type=Path,
60
+ default=None,
61
+ help=f"Where the UI session is persisted (default: ${config.STATE_FILE_ENV} "
62
+ f"or {config.DEFAULT_STATE_FILE}).",
63
+ )
64
+ serve.add_argument(
65
+ "--threads",
66
+ type=int,
67
+ default=8,
68
+ help="Worker threads for concurrent requests (default: 8).",
69
+ )
70
+ return parser
71
+
72
+
73
+ def serve(host: str, port: int, state_file: Path | None, threads: int) -> None:
74
+ # Imported here so `nt2gui --help` stays fast.
75
+ from waitress import create_server
76
+
77
+ from .app import create_app
78
+
79
+ logging.basicConfig(
80
+ level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"
81
+ )
82
+ # One process on purpose: open simulations, caches and the session live in
83
+ # memory and must be shared by all requests.
84
+ server = create_server(
85
+ create_app(state_file), host=host, port=port, threads=threads
86
+ )
87
+ effective_port = getattr(server, "effective_port", port)
88
+ print(
89
+ tunnel_hint(effective_port, host, socket.gethostname(), getpass.getuser()),
90
+ flush=True,
91
+ )
92
+ try:
93
+ server.run()
94
+ except KeyboardInterrupt:
95
+ pass
96
+ finally:
97
+ server.close()
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ args = build_parser().parse_args(argv)
102
+ if args.command == "serve":
103
+ serve(args.host, args.port, args.state_file, args.threads)
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
nt2gui/config.py ADDED
@@ -0,0 +1,14 @@
1
+ """Defaults and environment settings shared by the app and the CLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ STATE_FILE_ENV = "NT2GUI_STATE_FILE"
7
+ DEFAULT_STATE_FILE = Path(".nt2gui") / "session.json"
8
+ # The built client (`npm run build`), bundled into the wheel as package data.
9
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
10
+
11
+
12
+ def state_file(explicit: Path | str | None = None) -> Path:
13
+ """The session file: `explicit`, else $NT2GUI_STATE_FILE, else the default."""
14
+ return Path(explicit or os.environ.get(STATE_FILE_ENV, DEFAULT_STATE_FILE))
File without changes
nt2gui/data/arrays.py ADDED
@@ -0,0 +1,9 @@
1
+ """Array helpers for JSON responses."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def finite_list(values: object) -> list[float | None]:
7
+ """Floats for JSON, with NaN/inf (not valid JSON) as null."""
8
+ array = np.asarray(values, dtype=float).ravel()
9
+ return [float(v) if np.isfinite(v) else None for v in array]