Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pathview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
from importlib.metadata import version
__version__ = version("pathview")
except Exception:
__version__ = "0.5.0" # fallback for editable installs / dev
__version__ = "0.8.0" # fallback for editable installs / dev

from pathview.converter import convert
20 changes: 15 additions & 5 deletions pathview/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ def api_init():
session_id = _get_session_id()
if not session_id:
return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400
data = request.get_json(force=True)
data = request.get_json()
if data is None:
return jsonify({"type": "error", "error": "Invalid or missing JSON body"}), 400
packages = data.get("packages", [])

session = get_or_create_session(session_id)
Expand Down Expand Up @@ -401,7 +403,9 @@ def _handle_worker_request(msg: dict, success_type: str) -> tuple:
@app.route("/api/exec", methods=["POST"])
def api_exec():
"""Execute Python code in the session's worker."""
data = request.get_json(force=True)
data = request.get_json()
if data is None:
return jsonify({"type": "error", "error": "Invalid or missing JSON body"}), 400
msg_id = data.get("id", str(uuid.uuid4()))
return _handle_worker_request(
{"type": "exec", "id": msg_id, "code": data.get("code", "")},
Expand All @@ -411,7 +415,9 @@ def api_exec():
@app.route("/api/eval", methods=["POST"])
def api_eval():
"""Evaluate a Python expression in the session's worker."""
data = request.get_json(force=True)
data = request.get_json()
if data is None:
return jsonify({"type": "error", "error": "Invalid or missing JSON body"}), 400
msg_id = data.get("id", str(uuid.uuid4()))
return _handle_worker_request(
{"type": "eval", "id": msg_id, "expr": data.get("expr", "")},
Expand All @@ -428,7 +434,9 @@ def api_stream_start():
session_id = _get_session_id()
if not session_id:
return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400
data = request.get_json(force=True)
data = request.get_json()
if data is None:
return jsonify({"type": "error", "error": "Invalid or missing JSON body"}), 400
expr = data.get("expr", "")
msg_id = data.get("id", str(uuid.uuid4()))

Expand Down Expand Up @@ -471,7 +479,9 @@ def api_stream_exec():
session_id = _get_session_id()
if not session_id:
return jsonify({"error": "Missing X-Session-ID header"}), 400
data = request.get_json(force=True)
data = request.get_json()
if data is None:
return jsonify({"error": "Invalid or missing JSON body"}), 400
code = data.get("code", "")

with _sessions_lock:
Expand Down
40 changes: 26 additions & 14 deletions pathview/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
def load_registry(registry_path: Path) -> dict:
"""Load the JSON registry generated by extract.py."""
if not registry_path.exists():
print(f"Error: Registry not found at {registry_path}", file=sys.stderr)
print("Run 'npm run extract' or 'python scripts/extract.py' first.", file=sys.stderr)
sys.exit(1)
raise FileNotFoundError(
f"Registry not found at {registry_path}. "
"Run 'npm run extract' or 'python scripts/extract.py' first."
)
with open(registry_path, encoding="utf-8") as f:
return json.load(f)

Expand All @@ -42,12 +43,8 @@ def sanitize_name(name: str) -> str:
"""Sanitize a name for use as a Python variable."""
if not name:
return ""
sanitized = ""
for char in name:
if re.match(r"[a-zA-Z0-9_]", char):
sanitized += char
elif char == " ":
sanitized += "_"
sanitized = name.replace(" ", "_")
sanitized = re.sub(r"[^a-zA-Z0-9_]", "", sanitized)
if sanitized and sanitized[0].isdigit():
sanitized = "n_" + sanitized
return sanitized.lower()
Expand Down Expand Up @@ -213,7 +210,10 @@ def generate_subsystem_code(
# Generate subsystem variable name
var_name = sanitize_name(node["name"])
if not var_name or var_name in var_names:
var_name = f"subsystem_{len(var_names)}"
counter = len(var_names)
while f"subsystem_{counter}" in var_names:
counter += 1
var_name = f"subsystem_{counter}"
var_names.append(var_name)
node_vars[node["id"]] = var_name

Expand Down Expand Up @@ -249,7 +249,10 @@ def generate_subsystem_code(
continue
child_var = sub_prefix + sanitize_name(child["name"])
if not child_var or child_var in internal_var_names:
child_var = f"{sub_prefix}block_{len(internal_var_names)}"
counter = len(internal_var_names)
while f"{sub_prefix}block_{counter}" in internal_var_names:
counter += 1
child_var = f"{sub_prefix}block_{counter}"
internal_var_names.append(child_var)
internal_node_vars[child["id"]] = child_var

Expand All @@ -276,7 +279,10 @@ def generate_subsystem_code(
continue
evt_var = sub_prefix + sanitize_name(event["name"])
if not evt_var or evt_var in var_names or evt_var in event_var_names:
evt_var = f"{sub_prefix}event_{len(event_var_names)}"
counter = len(event_var_names)
while f"{sub_prefix}event_{counter}" in var_names or f"{sub_prefix}event_{counter}" in event_var_names:
counter += 1
evt_var = f"{sub_prefix}event_{counter}"
event_var_names.append(evt_var)

valid_params = set(event_info["params"])
Expand Down Expand Up @@ -412,7 +418,10 @@ def generate_python(pvm: dict, registry: dict, source_name: str = "") -> str:

var_name = sanitize_name(node["name"])
if not var_name or var_name in var_names:
var_name = f"block_{i}"
counter = i
while f"block_{counter}" in var_names:
counter += 1
var_name = f"block_{counter}"
var_names.append(var_name)
node_vars[node["id"]] = var_name

Expand Down Expand Up @@ -462,7 +471,10 @@ def generate_python(pvm: dict, registry: dict, source_name: str = "") -> str:

evt_var = sanitize_name(event["name"])
if not evt_var or evt_var in var_names or evt_var in event_var_names:
evt_var = f"event_{len(event_var_names)}"
counter = len(event_var_names)
while f"event_{counter}" in var_names or f"event_{counter}" in event_var_names:
counter += 1
evt_var = f"event_{counter}"
event_var_names.append(evt_var)

valid_params = set(event_info["params"])
Expand Down
13 changes: 13 additions & 0 deletions pathview/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@
import traceback
import queue
import ctypes
import re

from pathview.config import EXEC_TIMEOUT

# Only allow valid Python dotted identifiers as import names
_VALID_IMPORT_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$")

# Lock for thread-safe stdout writing (protocol messages only)
_stdout_lock = threading.Lock()

Expand Down Expand Up @@ -117,6 +121,9 @@ def _ensure_package(pkg: dict) -> None:

send({"type": "progress", "value": f"Installing {import_name}..."})

if not _VALID_IMPORT_NAME.match(import_name):
raise ValueError(f"Invalid import name: {import_name!r}")

try:
# Try importing first — skip pip if already installed
exec(f"import {import_name}", _namespace)
Expand Down Expand Up @@ -249,6 +256,9 @@ def do_eval():
exec(exec_code_str, _namespace)

_run_with_timeout(do_eval)
if "_eval_result" not in _namespace:
send({"type": "error", "id": msg_id, "error": "Expression produced no result"})
return
to_json = _namespace.get("_to_json", str)
result = json.dumps(_namespace["_eval_result"], default=to_json)
send({"type": "value", "id": msg_id, "value": result})
Expand Down Expand Up @@ -345,6 +355,9 @@ def run_streaming_loop(msg_id: str, expr: str) -> None:
send({"type": "error", "id": msg_id,
"error": f"Simulation step timed out after {EXEC_TIMEOUT}s"})
break
if "_eval_result" not in _namespace:
send({"type": "error", "id": msg_id, "error": "Stream expression produced no result"})
break
raw_result = _namespace["_eval_result"]
done = raw_result.get("done", False) if isinstance(raw_result, dict) else False

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pathview"
version = "0.5.0"
version = "0.8.0"
description = "Visual node editor for building and simulating dynamic systems with PathSim"
readme = "README.md"
license = {text = "MIT"}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/pyodide/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export async function runStreamingSimulation(
throw error;
} finally {
streamingActive = false;
consoleStore.flush();
}
}

Expand Down Expand Up @@ -492,6 +493,7 @@ if 'sim' not in dir() or sim is None:
throw error;
} finally {
streamingActive = false;
consoleStore.flush();
}
}

Expand Down