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 +8 -0
- nt2gui/__main__.py +5 -0
- nt2gui/api/__init__.py +12 -0
- nt2gui/api/common.py +69 -0
- nt2gui/api/fields.py +59 -0
- nt2gui/api/particles.py +62 -0
- nt2gui/api/simulations.py +24 -0
- nt2gui/api/state.py +79 -0
- nt2gui/app.py +51 -0
- nt2gui/cli.py +108 -0
- nt2gui/config.py +14 -0
- nt2gui/data/__init__.py +0 -0
- nt2gui/data/arrays.py +9 -0
- nt2gui/data/expressions.py +217 -0
- nt2gui/data/metadata.py +110 -0
- nt2gui/data/particles.py +212 -0
- nt2gui/data/quantities.py +149 -0
- nt2gui/errors.py +27 -0
- nt2gui/plots/__init__.py +5 -0
- nt2gui/plots/line.py +74 -0
- nt2gui/plots/particle_histogram.py +17 -0
- nt2gui/plots/particle_phase.py +37 -0
- nt2gui/plots/pseudocolor.py +76 -0
- nt2gui/render.py +103 -0
- nt2gui/session/__init__.py +0 -0
- nt2gui/session/files.py +106 -0
- nt2gui/session/schema.py +240 -0
- nt2gui/session/store.py +112 -0
- nt2gui/sources/__init__.py +92 -0
- nt2gui/sources/cache.py +27 -0
- nt2gui/sources/demo.py +144 -0
- nt2gui/sources/nt2.py +158 -0
- nt2gui/static/assets/index-CMkhsKnH.js +21 -0
- nt2gui/static/assets/index-CqdFF03j.css +1 -0
- nt2gui/static/index.html +13 -0
- nt2gui/workspace.py +138 -0
- nt2gui-0.1.0.dist-info/METADATA +56 -0
- nt2gui-0.1.0.dist-info/RECORD +40 -0
- nt2gui-0.1.0.dist-info/WHEEL +4 -0
- nt2gui-0.1.0.dist-info/entry_points.txt +2 -0
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
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))
|
nt2gui/api/particles.py
ADDED
|
@@ -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))
|
nt2gui/data/__init__.py
ADDED
|
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]
|