diff --git a/pathview/data/registry.json b/pathview/data/registry.json index 11555c3d..7d4ee5fa 100644 --- a/pathview/data/registry.json +++ b/pathview/data/registry.json @@ -579,148 +579,6 @@ "blockClass": "Interface", "importPath": "pathsim.blocks", "params": [] - }, - "Process": { - "blockClass": "Process", - "importPath": "pathsim_chem", - "params": [ - "tau", - "initial_value", - "source_term" - ] - }, - "ResidenceTime": { - "blockClass": "ResidenceTime", - "importPath": "pathsim_chem", - "params": [ - "tau", - "betas", - "gammas", - "initial_value", - "source_term" - ] - }, - "Splitter": { - "blockClass": "Splitter", - "importPath": "pathsim_chem", - "params": [ - "fractions" - ] - }, - "Bubbler4": { - "blockClass": "Bubbler4", - "importPath": "pathsim_chem", - "params": [ - "conversion_efficiency", - "vial_efficiency", - "replacement_times" - ] - }, - "GLC": { - "blockClass": "GLC", - "importPath": "pathsim_chem", - "params": [ - "P_in", - "L", - "D", - "T", - "BCs", - "g", - "initial_nb_of_elements" - ] - }, - "CSTR": { - "blockClass": "CSTR", - "importPath": "pathsim_chem", - "params": [ - "V", - "F", - "k0", - "Ea", - "n", - "dH_rxn", - "rho", - "Cp", - "UA", - "C_A0", - "T0" - ] - }, - "PFR": { - "blockClass": "PFR", - "importPath": "pathsim_chem", - "params": [ - "N_cells", - "V", - "F", - "k0", - "Ea", - "n", - "dH_rxn", - "rho", - "Cp", - "C0", - "T0" - ] - }, - "HeatExchanger": { - "blockClass": "HeatExchanger", - "importPath": "pathsim_chem", - "params": [ - "N_cells", - "F_h", - "F_c", - "V_h", - "V_c", - "UA", - "rho_h", - "Cp_h", - "rho_c", - "Cp_c", - "T_h0", - "T_c0" - ] - }, - "FlashDrum": { - "blockClass": "FlashDrum", - "importPath": "pathsim_chem", - "params": [ - "holdup", - "antoine_A", - "antoine_B", - "antoine_C", - "N0" - ] - }, - "Mixer": { - "blockClass": "Mixer", - "importPath": "pathsim_chem", - "params": [] - }, - "Valve": { - "blockClass": "Valve", - "importPath": "pathsim_chem", - "params": [ - "Cv" - ] - }, - "Heater": { - "blockClass": "Heater", - "importPath": "pathsim_chem", - "params": [ - "rho", - "Cp" - ] - }, - "PointKinetics": { - "blockClass": "PointKinetics", - "importPath": "pathsim_chem", - "params": [ - "n0", - "Lambda", - "beta", - "lam" - ] } }, "events": { diff --git a/pyproject.toml b/pyproject.toml index 7a0b1461..836c18e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,7 @@ dependencies = [ "flask-cors>=4.0", "numpy", "waitress>=3.0", - "pathsim", - "pathsim-chem>=0.2.0", + "pathsim==0.20.0", ] [project.optional-dependencies] diff --git a/scripts/config/pathsim-chem/blocks.json b/scripts/config/pathsim-chem/blocks.json deleted file mode 100644 index 288a291d..00000000 --- a/scripts/config/pathsim-chem/blocks.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "../schemas/blocks.schema.json", - "toolbox": "pathsim-chem", - "importPath": "pathsim_chem", - - "categories": { - "Chemical": [ - "Process", - "ResidenceTime", - "Splitter", - "Bubbler4", - "GLC", - "CSTR", - "PFR", - "HeatExchanger", - "FlashDrum", - "Mixer", - "Valve", - "Heater", - "PointKinetics" - ] - } -} diff --git a/scripts/config/requirements-pyodide.txt b/scripts/config/requirements-pyodide.txt index 75190211..2e5efc5c 100644 --- a/scripts/config/requirements-pyodide.txt +++ b/scripts/config/requirements-pyodide.txt @@ -4,4 +4,3 @@ # For Pyodide runtime: "# optional" means installation failure won't block app pathsim -pathsim-chem>=0.2.0 # optional diff --git a/scripts/extract.py b/scripts/extract.py index bc473337..5ae23f8c 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -24,117 +24,20 @@ from pathlib import Path from typing import Any - -# Optional docutils for RST to HTML conversion -try: - from docutils.core import publish_parts - HAS_DOCUTILS = True -except ImportError: - HAS_DOCUTILS = False - print("Warning: docutils not installed, docstrings will not be converted to HTML") - - -# ============================================================================= -# Shared Utilities -# ============================================================================= - -def rst_to_html(rst_text: str) -> str: - """Convert RST docstring to HTML, preserving LaTeX math for KaTeX rendering.""" - if not rst_text or not HAS_DOCUTILS: - return "" - - # Clean the docstring - removes common leading whitespace from indented docstrings - cleaned = inspect.cleandoc(rst_text) - - try: - parts = publish_parts( - cleaned, - writer_name="html", - settings_overrides={ - "report_level": 5, - "halt_level": 5, - "initial_header_level": 3, - "math_output": "MathJax", - } - ) - return parts["body"] - except Exception as e: - print(f"Warning: Failed to convert docstring to HTML: {e}") - return "" - - -def extract_first_line(docstring: str) -> str: - """Extract first line/sentence from docstring as description.""" - if not docstring: - return "" - - lines = docstring.strip().split("\n") - first_line = "" - for line in lines: - stripped = line.strip() - if stripped: - first_line = stripped - break - - if ". " in first_line: - first_line = first_line.split(". ")[0] + "." - - return first_line - - -def extract_param_description(docstring: str, param_name: str) -> str: - """Extract parameter description from docstring (RST format).""" - if not docstring: - return "" - - pattern = rf"{param_name}\s*:\s*[^\n]*\n\s+(.+?)(?=\n\s*\w+\s*:|\n\n|$)" - match = re.search(pattern, docstring, re.DOTALL) - if match: - desc = match.group(1).strip() - desc = re.sub(r"\s+", " ", desc) - return desc - - return "" - - -def infer_param_type(value: Any, param_name: str = "") -> str: - """Infer parameter type from default value.""" - if param_name.startswith('func_') or param_name.startswith('func'): - return "callable" - if callable(value) and not isinstance(value, type): - return "callable" - if isinstance(value, bool): - return "boolean" - if isinstance(value, int): - return "integer" - if isinstance(value, float): - return "number" - if isinstance(value, str): - return "string" - if isinstance(value, (list, tuple)): - return "array" - if value is None: - return "any" - return "any" - - -def format_default(value: Any) -> str | None: - """Format default value for TypeScript.""" - if value is None: - return None - if callable(value) and not isinstance(value, type): - return None - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return repr(value) - if isinstance(value, str): - return f'"{value}"' - if isinstance(value, (list, tuple)): - return json.dumps(list(value)) - if isinstance(value, type): - return f'"{value.__name__}"' - return repr(value) +# Single source of truth for block/event introspection — also inlined into +# the runtime toolbox installer in src/lib/toolbox/python.ts via Vite ?raw. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pathview_introspect import ( # noqa: E402 + rst_to_html, + first_line as extract_first_line, + param_desc as extract_param_description, + infer_type as infer_param_type, + format_default, + process_port_labels, + extract_block, + extract_event, + extract_params_from_signature, +) def format_default_py(value: Any) -> str | None: diff --git a/scripts/generated/registry.json b/scripts/generated/registry.json index 11555c3d..7d4ee5fa 100644 --- a/scripts/generated/registry.json +++ b/scripts/generated/registry.json @@ -579,148 +579,6 @@ "blockClass": "Interface", "importPath": "pathsim.blocks", "params": [] - }, - "Process": { - "blockClass": "Process", - "importPath": "pathsim_chem", - "params": [ - "tau", - "initial_value", - "source_term" - ] - }, - "ResidenceTime": { - "blockClass": "ResidenceTime", - "importPath": "pathsim_chem", - "params": [ - "tau", - "betas", - "gammas", - "initial_value", - "source_term" - ] - }, - "Splitter": { - "blockClass": "Splitter", - "importPath": "pathsim_chem", - "params": [ - "fractions" - ] - }, - "Bubbler4": { - "blockClass": "Bubbler4", - "importPath": "pathsim_chem", - "params": [ - "conversion_efficiency", - "vial_efficiency", - "replacement_times" - ] - }, - "GLC": { - "blockClass": "GLC", - "importPath": "pathsim_chem", - "params": [ - "P_in", - "L", - "D", - "T", - "BCs", - "g", - "initial_nb_of_elements" - ] - }, - "CSTR": { - "blockClass": "CSTR", - "importPath": "pathsim_chem", - "params": [ - "V", - "F", - "k0", - "Ea", - "n", - "dH_rxn", - "rho", - "Cp", - "UA", - "C_A0", - "T0" - ] - }, - "PFR": { - "blockClass": "PFR", - "importPath": "pathsim_chem", - "params": [ - "N_cells", - "V", - "F", - "k0", - "Ea", - "n", - "dH_rxn", - "rho", - "Cp", - "C0", - "T0" - ] - }, - "HeatExchanger": { - "blockClass": "HeatExchanger", - "importPath": "pathsim_chem", - "params": [ - "N_cells", - "F_h", - "F_c", - "V_h", - "V_c", - "UA", - "rho_h", - "Cp_h", - "rho_c", - "Cp_c", - "T_h0", - "T_c0" - ] - }, - "FlashDrum": { - "blockClass": "FlashDrum", - "importPath": "pathsim_chem", - "params": [ - "holdup", - "antoine_A", - "antoine_B", - "antoine_C", - "N0" - ] - }, - "Mixer": { - "blockClass": "Mixer", - "importPath": "pathsim_chem", - "params": [] - }, - "Valve": { - "blockClass": "Valve", - "importPath": "pathsim_chem", - "params": [ - "Cv" - ] - }, - "Heater": { - "blockClass": "Heater", - "importPath": "pathsim_chem", - "params": [ - "rho", - "Cp" - ] - }, - "PointKinetics": { - "blockClass": "PointKinetics", - "importPath": "pathsim_chem", - "params": [ - "n0", - "Lambda", - "beta", - "lam" - ] } }, "events": { diff --git a/scripts/pathview_introspect.py b/scripts/pathview_introspect.py new file mode 100644 index 00000000..582e19bd --- /dev/null +++ b/scripts/pathview_introspect.py @@ -0,0 +1,263 @@ +""" +Shared block/event introspection used by both: + + - scripts/extract.py at build time (for built-in pathsim blocks) + - src/lib/toolbox/python.ts at runtime (for user-installed toolboxes, + inlined into Pyodide via Vite's ?raw import) + +Single source of truth for: RST-docstring parsing, parameter type inference, +default-value formatting, block/event class detection, and the canonical +extracted-metadata dict shape consumed by the TypeScript registry layer. +""" + +import inspect +import json +import re + +# --- Optional docutils for RST->HTML -------------------------------------- + +_publish_parts = None +_docutils_checked = False + + +def rst_to_html(rst): + """Convert an RST docstring to HTML using docutils when available. + + Returns "" if docutils isn't installed or conversion fails. Build-time + bundles docutils via requirements-build.txt; runtime falls back gracefully. + """ + global _publish_parts, _docutils_checked + if not rst: + return "" + if not _docutils_checked: + _docutils_checked = True + try: + from docutils.core import publish_parts # type: ignore + _publish_parts = publish_parts + except Exception: + _publish_parts = None + if _publish_parts is None: + return "" + try: + cleaned = inspect.cleandoc(rst) + parts = _publish_parts( + cleaned, + writer_name="html", + settings_overrides={ + "report_level": 5, + "halt_level": 5, + "initial_header_level": 3, + "math_output": "MathJax", + }, + ) + return parts.get("body") or "" + except Exception: + return "" + + +def first_line(docstring): + """First sentence of a docstring (used as the short description).""" + if not docstring: + return "" + for line in docstring.strip().split("\n"): + s = line.strip() + if not s: + continue + if ". " in s: + return s.split(". ")[0] + "." + return s + return "" + + +def param_desc(docstring, name): + """Extract a `:param name:` description from an RST docstring.""" + if not docstring: + return "" + pattern = ( + re.escape(name) + + r"\s*:\s*[^\n]*\n\s+(.+?)(?=\n\s*\w+\s*:|\n\n|$)" + ) + m = re.search(pattern, docstring, re.DOTALL) + if m: + return re.sub(r"\s+", " ", m.group(1).strip()) + return "" + + +# --- Parameter helpers ---------------------------------------------------- + + +def infer_type(value, name=""): + """Infer the param type for a default value, matching ParamType in TS.""" + if name and (name.startswith("func_") or name.startswith("func")): + return "callable" + if callable(value) and not isinstance(value, type): + return "callable" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, (list, tuple)): + return "array" + return "any" + + +def format_default(value): + """Format a default as a TypeScript-compatible source string (or None).""" + if value is None or value is inspect.Parameter.empty: + return None + if callable(value) and not isinstance(value, type): + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, (list, tuple)): + try: + return json.dumps(list(value)) + except Exception: + return repr(list(value)) + if isinstance(value, type): + return json.dumps(value.__name__) + try: + return repr(value) + except Exception: + return None + + +def extract_params_from_signature(cls, docstring): + """Extract parameters by introspecting the class __init__ signature.""" + out = [] + try: + sig = inspect.signature(cls.__init__) + except (TypeError, ValueError): + return out + for pname, p in sig.parameters.items(): + if pname == "self": + continue + if p.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + out.append({ + "name": pname, + "default": format_default(p.default), + "type": infer_type(p.default, pname), + "description": param_desc(docstring, pname), + }) + return out + + +# --- Port-label normalisation -------------------------------------------- + + +def process_port_labels(labels): + """Normalise the `*_port_labels` value into the canonical shape: + + - None -> None (variable/unlimited ports) + - {} -> [] (no ports of this type) + - dict -> sorted list of names + - list -> the list itself + """ + if labels is None: + return None + if isinstance(labels, dict): + if not labels: + return [] + return [name for name, _ in sorted(labels.items(), key=lambda x: x[1])] + if isinstance(labels, (list, tuple)): + return list(labels) + return None + + +# --- Class detection (runtime needs heuristics) -------------------------- + + +def is_block(cls): + """Best-effort check: subclass of pathsim's Block.""" + if not inspect.isclass(cls): + return False + for base in cls.__mro__[1:]: + if base.__name__ == "Block" and base.__module__.startswith("pathsim"): + return True + return False + + +def is_event(cls): + """Best-effort check: pathsim Event subclass or *Event-named class.""" + if not inspect.isclass(cls): + return False + name = cls.__name__ + if name.startswith("_"): + return False + for base in cls.__mro__[1:]: + if base.__name__.endswith("Event") and "pathsim" in base.__module__: + return True + return name.endswith("Event") + + +# --- Canonical extraction ------------------------------------------------ + + +def extract_block(cls): + """Extract canonical block metadata from a class. + + Uses `cls.info()` when available (the convention for pathsim Block + subclasses); falls back to signature introspection otherwise. + """ + raw_doc = cls.__doc__ or "" + + info = None + info_fn = getattr(cls, "info", None) + if callable(info_fn): + try: + info = info_fn() + except Exception: + info = None + + if info is not None: + rst = (info.get("description") or raw_doc).strip() + params = [] + for pname, meta in (info.get("parameters") or {}).items(): + default = meta.get("default") if isinstance(meta, dict) else None + params.append({ + "name": pname, + "default": format_default(default), + "type": infer_type(default, pname), + "description": param_desc(rst, pname), + }) + return { + "className": cls.__name__, + "description": first_line(rst), + "docstringHtml": rst_to_html(rst), + "inputs": process_port_labels(info.get("input_port_labels")), + "outputs": process_port_labels(info.get("output_port_labels")), + "params": params, + } + + # Fallback: signature introspection + class attribute port labels + return { + "className": cls.__name__, + "description": first_line(raw_doc), + "docstringHtml": rst_to_html(raw_doc), + "inputs": process_port_labels(getattr(cls, "input_port_labels", None)), + "outputs": process_port_labels(getattr(cls, "output_port_labels", None)), + "params": extract_params_from_signature(cls, raw_doc), + } + + +def extract_event(cls): + """Extract canonical event metadata from a class.""" + raw_doc = cls.__doc__ or "" + return { + "className": cls.__name__, + "description": first_line(raw_doc), + "docstringHtml": rst_to_html(raw_doc), + "params": extract_params_from_signature(cls, raw_doc), + } diff --git a/src/app.css b/src/app.css index 042b823e..ae20dca8 100644 --- a/src/app.css +++ b/src/app.css @@ -242,6 +242,65 @@ input::placeholder { color: var(--text-muted); } +/* Checkbox — overrides the generic input styles above */ +input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + margin: 0; + padding: 0; + flex-shrink: 0; + background: var(--surface-raised); + border: 1px solid var(--border-focus); + border-radius: 3px; + cursor: pointer; + position: relative; + display: inline-block; + vertical-align: middle; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +input[type="checkbox"]:hover { + border-color: var(--accent); +} + +input[type="checkbox"]:checked { + background: color-mix(in srgb, var(--accent) 18%, var(--surface-raised)); + border-color: var(--accent); +} + +input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + inset: 0; + background: var(--accent); + -webkit-mask: url("data:image/svg+xml;utf8,") center / 10px 10px no-repeat; + mask: url("data:image/svg+xml;utf8,") center / 10px 10px no-repeat; +} + +input[type="checkbox"]:indeterminate { + background: var(--surface-raised); + border-color: var(--accent); +} + +input[type="checkbox"]:indeterminate::after { + content: ""; + position: absolute; + left: 3px; + right: 3px; + top: 50%; + height: 2px; + background: var(--accent); + transform: translateY(-50%); + border-radius: 1px; +} + +input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* Panel style (solid background) */ .glass-panel { background: var(--surface); diff --git a/src/app.d.ts b/src/app.d.ts index da08e6da..ef3fdab7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -10,4 +10,9 @@ declare global { } } +declare module '*?raw' { + const content: string; + export default content; +} + export {}; diff --git a/src/lib/components/dialogs/ToolboxManagerDialog.svelte b/src/lib/components/dialogs/ToolboxManagerDialog.svelte new file mode 100644 index 00000000..0a6c6423 --- /dev/null +++ b/src/lib/components/dialogs/ToolboxManagerDialog.svelte @@ -0,0 +1,1150 @@ + + + + +{#if open} + +{/if} + + diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 0fded88a..f4bcf493 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -461,6 +461,7 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} + class:missing-type={!typeDef && data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE} data-rotation={rotation} style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -520,6 +521,8 @@ {/if} {#if typeDef} {typeDef.name} + {:else if data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE} + {data.type} (missing) {/if} @@ -758,6 +761,22 @@ margin-top: 2px; } + .node-type.missing { + color: var(--warning); + } + + /* Visual marker for nodes whose block type isn't registered (e.g. file + loaded with a toolbox dependency the user hasn't installed). */ + .node.missing-type { + --node-color: var(--warning); + opacity: 0.85; + } + + .node.missing-type .node-content, + .node.missing-type :global(.node-shape) { + border-style: dashed; + } + /* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */ .pinned-params { display: flex; diff --git a/src/lib/components/panels/EventsPanel.svelte b/src/lib/components/panels/EventsPanel.svelte index 752a490b..eb2acd0f 100644 --- a/src/lib/components/panels/EventsPanel.svelte +++ b/src/lib/components/panels/EventsPanel.svelte @@ -1,5 +1,5 @@