diff --git a/pathview/__init__.py b/pathview/__init__.py index a9a0313..f9cd1b4 100644 --- a/pathview/__init__.py +++ b/pathview/__init__.py @@ -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 diff --git a/pathview/app.py b/pathview/app.py index 13615fc..63bf489 100644 --- a/pathview/app.py +++ b/pathview/app.py @@ -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) @@ -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", "")}, @@ -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", "")}, @@ -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())) @@ -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: diff --git a/pathview/converter.py b/pathview/converter.py index b5d7719..1dd613c 100644 --- a/pathview/converter.py +++ b/pathview/converter.py @@ -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) @@ -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() @@ -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 @@ -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 @@ -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"]) @@ -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 @@ -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"]) diff --git a/pathview/worker.py b/pathview/worker.py index 05b762b..56d1be9 100644 --- a/pathview/worker.py +++ b/pathview/worker.py @@ -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() @@ -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) @@ -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}) @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d5afa21..7a0b146 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/lib/pyodide/bridge.ts b/src/lib/pyodide/bridge.ts index 1c9a9c5..d4fe978 100644 --- a/src/lib/pyodide/bridge.ts +++ b/src/lib/pyodide/bridge.ts @@ -396,6 +396,7 @@ export async function runStreamingSimulation( throw error; } finally { streamingActive = false; + consoleStore.flush(); } } @@ -492,6 +493,7 @@ if 'sim' not in dir() or sim is None: throw error; } finally { streamingActive = false; + consoleStore.flush(); } }