flograph 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.
- flograph/__init__.py +0 -0
- flograph/__main__.py +5 -0
- flograph/app.py +43 -0
- flograph/core/__init__.py +26 -0
- flograph/core/datatypes.py +88 -0
- flograph/core/events.py +54 -0
- flograph/core/graph.py +414 -0
- flograph/core/node.py +91 -0
- flograph/core/params.py +69 -0
- flograph/core/ports.py +19 -0
- flograph/core/registry.py +160 -0
- flograph/core/script.py +172 -0
- flograph/core/serialization.py +212 -0
- flograph/core/spec.py +35 -0
- flograph/core/user_nodes.py +201 -0
- flograph/engine/__init__.py +19 -0
- flograph/engine/cache.py +70 -0
- flograph/engine/cache_persistence.py +142 -0
- flograph/engine/context.py +51 -0
- flograph/engine/errors.py +54 -0
- flograph/engine/headless.py +63 -0
- flograph/engine/introspect.py +55 -0
- flograph/engine/scheduler.py +206 -0
- flograph/engine/worker.py +135 -0
- flograph/nodes/__init__.py +0 -0
- flograph/nodes/io/__init__.py +0 -0
- flograph/nodes/io/read_csv.py +31 -0
- flograph/nodes/io/read_excel.py +35 -0
- flograph/nodes/io/read_json.py +33 -0
- flograph/nodes/io/read_parquet.py +30 -0
- flograph/nodes/io/read_sqlite.py +33 -0
- flograph/nodes/io/table.py +68 -0
- flograph/nodes/io/write_csv.py +24 -0
- flograph/nodes/io/write_excel.py +27 -0
- flograph/nodes/io/write_json.py +31 -0
- flograph/nodes/io/write_parquet.py +24 -0
- flograph/nodes/io/write_sqlite.py +35 -0
- flograph/nodes/scripting/__init__.py +0 -0
- flograph/nodes/scripting/node_template.py +68 -0
- flograph/nodes/scripting/python_script.py +22 -0
- flograph/nodes/transform/__init__.py +0 -0
- flograph/nodes/transform/concatenate.py +28 -0
- flograph/nodes/transform/convert_types.py +54 -0
- flograph/nodes/transform/duplicate_filter.py +34 -0
- flograph/nodes/transform/expression.py +29 -0
- flograph/nodes/transform/filter_rows.py +24 -0
- flograph/nodes/transform/group_by.py +46 -0
- flograph/nodes/transform/join.py +31 -0
- flograph/nodes/transform/missing_values.py +63 -0
- flograph/nodes/transform/pivot.py +49 -0
- flograph/nodes/transform/rename_columns.py +35 -0
- flograph/nodes/transform/row_sampling.py +37 -0
- flograph/nodes/transform/select_columns.py +28 -0
- flograph/nodes/transform/sort.py +27 -0
- flograph/nodes/transform/statistics.py +26 -0
- flograph/nodes/transform/string_manipulation.py +52 -0
- flograph/nodes/transform/unpivot.py +40 -0
- flograph/nodes/util/__init__.py +0 -0
- flograph/nodes/util/action_button.py +36 -0
- flograph/nodes/util/constant.py +27 -0
- flograph/nodes/util/note.py +25 -0
- flograph/nodes/util/reroute.py +14 -0
- flograph/nodes/viz/__init__.py +0 -0
- flograph/nodes/viz/card.py +64 -0
- flograph/nodes/viz/show_plot.py +72 -0
- flograph/nodes/viz/show_plotly.py +68 -0
- flograph/nodes/viz/show_table.py +25 -0
- flograph/nodes/viz/slicer.py +61 -0
- flograph/nodes/viz/table_spec.py +26 -0
- flograph/packages.py +89 -0
- flograph/paths.py +31 -0
- flograph/ui/__init__.py +0 -0
- flograph/ui/canvas/__init__.py +13 -0
- flograph/ui/canvas/base_view.py +228 -0
- flograph/ui/canvas/connection_item.py +98 -0
- flograph/ui/canvas/frame_item.py +241 -0
- flograph/ui/canvas/grid.py +49 -0
- flograph/ui/canvas/minimap.py +115 -0
- flograph/ui/canvas/node_item.py +1475 -0
- flograph/ui/canvas/palette.py +228 -0
- flograph/ui/canvas/scene.py +415 -0
- flograph/ui/canvas/view.py +140 -0
- flograph/ui/commands.py +405 -0
- flograph/ui/console/log_dock.py +80 -0
- flograph/ui/dashboard/__init__.py +10 -0
- flograph/ui/dashboard/dashboard_page.py +53 -0
- flograph/ui/dashboard/dashboard_scene.py +147 -0
- flograph/ui/dashboard/dashboard_view.py +56 -0
- flograph/ui/dashboard/page_bar.py +117 -0
- flograph/ui/dashboard/tile_item.py +674 -0
- flograph/ui/dashboard/visuals_list.py +69 -0
- flograph/ui/editor/code_editor.py +213 -0
- flograph/ui/editor/completion.py +183 -0
- flograph/ui/editor/editor_dock.py +188 -0
- flograph/ui/editor/highlighter.py +123 -0
- flograph/ui/editor/save_user_node_dialog.py +64 -0
- flograph/ui/inspector/figure_view.py +194 -0
- flograph/ui/inspector/inspector_dock.py +127 -0
- flograph/ui/inspector/object_view.py +28 -0
- flograph/ui/inspector/pandas_model.py +87 -0
- flograph/ui/inspector/plotly_view.py +91 -0
- flograph/ui/inspector/popup_view.py +76 -0
- flograph/ui/inspector/spec_view.py +26 -0
- flograph/ui/inspector/view_for.py +34 -0
- flograph/ui/mainwindow.py +1240 -0
- flograph/ui/packages_dialog.py +216 -0
- flograph/ui/properties/params_panel.py +286 -0
- flograph/ui/slicer_list.py +86 -0
- flograph/ui/theme.py +85 -0
- flograph-0.1.0.dist-info/METADATA +211 -0
- flograph-0.1.0.dist-info/RECORD +114 -0
- flograph-0.1.0.dist-info/WHEEL +4 -0
- flograph-0.1.0.dist-info/entry_points.txt +2 -0
- flograph-0.1.0.dist-info/licenses/LICENSE +21 -0
flograph/__init__.py
ADDED
|
File without changes
|
flograph/__main__.py
ADDED
flograph/app.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Application entry point: QApplication, theme, registry, main window."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv: list[str] | None = None) -> int:
|
|
8
|
+
import matplotlib
|
|
9
|
+
matplotlib.use("QtAgg") # before any pyplot import, GUI-safe backend
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Qt
|
|
12
|
+
from PySide6.QtWidgets import QApplication
|
|
13
|
+
|
|
14
|
+
from flograph.core import NodeRegistry
|
|
15
|
+
from flograph.paths import user_nodes_dir
|
|
16
|
+
from flograph.ui.mainwindow import MainWindow
|
|
17
|
+
from flograph.ui.theme import apply_theme
|
|
18
|
+
|
|
19
|
+
# must be set before the QApplication exists: the Show Plotly card embeds
|
|
20
|
+
# Qt WebEngine, which needs shared GL contexts to composite
|
|
21
|
+
QApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
|
|
22
|
+
app = QApplication.instance() or QApplication(sys.argv if argv is None else argv)
|
|
23
|
+
app.setApplicationName("flograph")
|
|
24
|
+
app.setOrganizationName("flograph")
|
|
25
|
+
apply_theme(app)
|
|
26
|
+
|
|
27
|
+
registry = NodeRegistry()
|
|
28
|
+
registry.load_builtins()
|
|
29
|
+
registry.load_user_nodes(user_nodes_dir())
|
|
30
|
+
|
|
31
|
+
window = MainWindow(registry)
|
|
32
|
+
window.resize(1400, 900)
|
|
33
|
+
window.show()
|
|
34
|
+
|
|
35
|
+
args = app.arguments()[1:]
|
|
36
|
+
project = next((a for a in args if a.endswith(".flograph")), None)
|
|
37
|
+
if project:
|
|
38
|
+
window.open_path(project, confirm=False)
|
|
39
|
+
return app.exec()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""flograph.core — the Qt-free graph model.
|
|
2
|
+
|
|
3
|
+
Nothing in this package may import PySide6 (enforced by
|
|
4
|
+
tests/test_no_qt_in_core.py), and module import must not pull in pandas or
|
|
5
|
+
matplotlib either — heavy imports happen lazily inside functions.
|
|
6
|
+
"""
|
|
7
|
+
from .datatypes import PortType, can_connect, validate_value, WIRE_COLORS
|
|
8
|
+
from .events import Event, GraphEvents
|
|
9
|
+
from .graph import Connection, Frame, Graph, GraphError, Page, Tile
|
|
10
|
+
from .node import NodeInstance, NodeSpec, NodeStatus
|
|
11
|
+
from .params import ParamSpec
|
|
12
|
+
from .ports import PortDirection, PortSpec
|
|
13
|
+
from .registry import NodeRegistry, fuzzy_score
|
|
14
|
+
from .script import NodeScriptError, compile_run, node_filename, parse_spec
|
|
15
|
+
from . import serialization
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PortType", "can_connect", "validate_value", "WIRE_COLORS",
|
|
19
|
+
"Event", "GraphEvents",
|
|
20
|
+
"Connection", "Frame", "Graph", "GraphError", "Page", "Tile",
|
|
21
|
+
"NodeInstance", "NodeSpec", "NodeStatus",
|
|
22
|
+
"ParamSpec", "PortDirection", "PortSpec",
|
|
23
|
+
"NodeRegistry", "fuzzy_score",
|
|
24
|
+
"NodeScriptError", "compile_run", "node_filename", "parse_spec",
|
|
25
|
+
"serialization",
|
|
26
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Port type system: a small closed set of types with a table-driven
|
|
2
|
+
compatibility rule and per-type wire colors (hex strings — QColor lives in
|
|
3
|
+
ui.theme, not here).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PortType(str, Enum):
|
|
12
|
+
ANY = "any"
|
|
13
|
+
DATAFRAME = "dataframe"
|
|
14
|
+
SERIES = "series"
|
|
15
|
+
NUMBER = "number"
|
|
16
|
+
STRING = "string"
|
|
17
|
+
BOOL = "bool"
|
|
18
|
+
OBJECT = "object"
|
|
19
|
+
FIGURE = "figure"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Concrete types that may flow one-way into an OBJECT input.
|
|
23
|
+
_WIDENS_TO_OBJECT = frozenset({
|
|
24
|
+
PortType.DATAFRAME,
|
|
25
|
+
PortType.SERIES,
|
|
26
|
+
PortType.NUMBER,
|
|
27
|
+
PortType.STRING,
|
|
28
|
+
PortType.BOOL,
|
|
29
|
+
PortType.FIGURE,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
WIRE_COLORS: dict[PortType, str] = {
|
|
33
|
+
PortType.ANY: "#e8e8e8",
|
|
34
|
+
PortType.DATAFRAME: "#2dd4bf",
|
|
35
|
+
PortType.SERIES: "#7dd3c8",
|
|
36
|
+
PortType.NUMBER: "#4ade80",
|
|
37
|
+
PortType.STRING: "#fbbf24",
|
|
38
|
+
PortType.BOOL: "#f87171",
|
|
39
|
+
PortType.OBJECT: "#9ca3af",
|
|
40
|
+
PortType.FIGURE: "#c084fc",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def can_connect(out_type: PortType, in_type: PortType) -> bool:
|
|
45
|
+
"""May a wire run from an output of `out_type` to an input of `in_type`?"""
|
|
46
|
+
if out_type == in_type:
|
|
47
|
+
return True
|
|
48
|
+
if PortType.ANY in (out_type, in_type):
|
|
49
|
+
return True
|
|
50
|
+
if in_type == PortType.OBJECT and out_type in _WIDENS_TO_OBJECT:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_value(value: Any, port_type: PortType) -> Optional[str]:
|
|
56
|
+
"""Check a runtime value against a declared port type.
|
|
57
|
+
|
|
58
|
+
Returns an error message, or None if the value is acceptable. Imports of
|
|
59
|
+
pandas/matplotlib happen lazily so flograph.core stays import-light.
|
|
60
|
+
"""
|
|
61
|
+
if port_type in (PortType.ANY, PortType.OBJECT):
|
|
62
|
+
return None
|
|
63
|
+
if value is None:
|
|
64
|
+
return f"got None for a port of type '{port_type.value}'"
|
|
65
|
+
if port_type == PortType.NUMBER:
|
|
66
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
67
|
+
return _type_error(value, port_type)
|
|
68
|
+
return None
|
|
69
|
+
if port_type == PortType.STRING:
|
|
70
|
+
return None if isinstance(value, str) else _type_error(value, port_type)
|
|
71
|
+
if port_type == PortType.BOOL:
|
|
72
|
+
return None if isinstance(value, bool) else _type_error(value, port_type)
|
|
73
|
+
if port_type == PortType.DATAFRAME:
|
|
74
|
+
import pandas as pd
|
|
75
|
+
return None if isinstance(value, pd.DataFrame) else _type_error(value, port_type)
|
|
76
|
+
if port_type == PortType.SERIES:
|
|
77
|
+
import pandas as pd
|
|
78
|
+
return None if isinstance(value, pd.Series) else _type_error(value, port_type)
|
|
79
|
+
if port_type == PortType.FIGURE:
|
|
80
|
+
from matplotlib.figure import Figure
|
|
81
|
+
return None if isinstance(value, Figure) else _type_error(value, port_type)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _type_error(value: Any, port_type: PortType) -> str:
|
|
86
|
+
return (
|
|
87
|
+
f"got {type(value).__name__!r} for a port of type '{port_type.value}'"
|
|
88
|
+
)
|
flograph/core/events.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Qt-free observer primitives.
|
|
2
|
+
|
|
3
|
+
The core model must not depend on Qt, but the UI and engine need change
|
|
4
|
+
notification. `Event` is a minimal callback list; `GraphEvents` bundles one
|
|
5
|
+
Event per kind of graph mutation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Event:
|
|
14
|
+
__slots__ = ("_subscribers",)
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._subscribers: list[Callable[..., Any]] = []
|
|
18
|
+
|
|
19
|
+
def connect(self, callback: Callable[..., Any]) -> None:
|
|
20
|
+
if callback not in self._subscribers:
|
|
21
|
+
self._subscribers.append(callback)
|
|
22
|
+
|
|
23
|
+
def disconnect(self, callback: Callable[..., Any]) -> None:
|
|
24
|
+
with contextlib.suppress(ValueError):
|
|
25
|
+
self._subscribers.remove(callback)
|
|
26
|
+
|
|
27
|
+
def emit(self, *args: Any, **kwargs: Any) -> None:
|
|
28
|
+
for callback in list(self._subscribers):
|
|
29
|
+
callback(*args, **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GraphEvents:
|
|
33
|
+
"""One Event per graph mutation. Payloads documented per attribute."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.node_added = Event() # (node: NodeInstance)
|
|
37
|
+
self.node_removed = Event() # (node_id: str)
|
|
38
|
+
self.connected = Event() # (conn: Connection)
|
|
39
|
+
self.disconnected = Event() # (conn: Connection)
|
|
40
|
+
self.node_moved = Event() # (node_id: str, pos: tuple[float, float])
|
|
41
|
+
self.param_changed = Event() # (node_id: str, name: str, value: Any)
|
|
42
|
+
self.code_changed = Event() # (node_id: str)
|
|
43
|
+
self.label_changed = Event() # (node_id: str)
|
|
44
|
+
self.dirty_changed = Event() # (node_id: str, dirty: bool)
|
|
45
|
+
self.status_changed = Event() # (node_id: str, status: NodeStatus, message: str)
|
|
46
|
+
self.frame_added = Event() # (frame: Frame)
|
|
47
|
+
self.frame_removed = Event() # (frame_id: str)
|
|
48
|
+
self.frame_changed = Event() # (frame: Frame)
|
|
49
|
+
self.page_added = Event() # (page: Page)
|
|
50
|
+
self.page_removed = Event() # (page_id: str)
|
|
51
|
+
self.page_changed = Event() # (page: Page)
|
|
52
|
+
self.tile_added = Event() # (page_id: str, tile: Tile)
|
|
53
|
+
self.tile_removed = Event() # (page_id: str, tile_id: str)
|
|
54
|
+
self.tile_changed = Event() # (page_id: str, tile: Tile)
|
flograph/core/graph.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""The graph model: nodes, connections, frames, and every invariant-preserving
|
|
2
|
+
mutation. Pure Python, no Qt. The UI mutates the graph exclusively through
|
|
3
|
+
QUndoCommands that call these methods; the scene and engine react to
|
|
4
|
+
`graph.events`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Iterable, Optional
|
|
11
|
+
|
|
12
|
+
from .datatypes import can_connect
|
|
13
|
+
from .events import GraphEvents
|
|
14
|
+
from .node import NodeInstance, NodeStatus, NodeSpec
|
|
15
|
+
from .ports import PortDirection
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GraphError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Connection:
|
|
24
|
+
id: str
|
|
25
|
+
src_node: str
|
|
26
|
+
src_port: str
|
|
27
|
+
dst_node: str
|
|
28
|
+
dst_port: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Frame:
|
|
33
|
+
id: str
|
|
34
|
+
title: str = "Frame"
|
|
35
|
+
rect: tuple[float, float, float, float] = (0.0, 0.0, 300.0, 200.0)
|
|
36
|
+
color: str = "#33415c"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Tile:
|
|
41
|
+
"""A dashboard tile: a placed view of one node's output on a Page.
|
|
42
|
+
|
|
43
|
+
`node_id` may dangle (the node was deleted, or the file references a
|
|
44
|
+
node that no longer loads) — the UI shows a placeholder rather than the
|
|
45
|
+
graph rejecting the tile, which keeps undo orderings and old files safe.
|
|
46
|
+
"""
|
|
47
|
+
id: str
|
|
48
|
+
node_id: str
|
|
49
|
+
port: Optional[str] = None # output port to render; None for action buttons
|
|
50
|
+
rect: tuple[float, float, float, float] = (0.0, 0.0, 420.0, 320.0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Page:
|
|
55
|
+
"""A dashboard page: a named infinite canvas of tiles."""
|
|
56
|
+
id: str
|
|
57
|
+
title: str = "Page"
|
|
58
|
+
tiles: dict[str, Tile] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Graph:
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.nodes: dict[str, NodeInstance] = {}
|
|
64
|
+
self.connections: dict[str, Connection] = {}
|
|
65
|
+
self.frames: dict[str, Frame] = {}
|
|
66
|
+
self.pages: dict[str, Page] = {}
|
|
67
|
+
self.events = GraphEvents()
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------- nodes
|
|
70
|
+
|
|
71
|
+
def node(self, node_id: str) -> NodeInstance:
|
|
72
|
+
try:
|
|
73
|
+
return self.nodes[node_id]
|
|
74
|
+
except KeyError:
|
|
75
|
+
raise GraphError(f"no node with id {node_id!r}") from None
|
|
76
|
+
|
|
77
|
+
def add_node(self, node: NodeInstance) -> NodeInstance:
|
|
78
|
+
if node.id in self.nodes:
|
|
79
|
+
raise GraphError(f"node id {node.id!r} already in graph")
|
|
80
|
+
self.nodes[node.id] = node
|
|
81
|
+
self.events.node_added.emit(node)
|
|
82
|
+
return node
|
|
83
|
+
|
|
84
|
+
def remove_node(self, node_id: str) -> tuple[NodeInstance, list[Connection]]:
|
|
85
|
+
"""Remove a node and every connection touching it.
|
|
86
|
+
|
|
87
|
+
Returns (node, removed_connections) so an undo command can restore
|
|
88
|
+
both.
|
|
89
|
+
"""
|
|
90
|
+
node = self.node(node_id)
|
|
91
|
+
removed = [
|
|
92
|
+
c for c in self.connections.values()
|
|
93
|
+
if node_id in (c.src_node, c.dst_node)
|
|
94
|
+
]
|
|
95
|
+
for conn in removed:
|
|
96
|
+
self.disconnect(conn.id)
|
|
97
|
+
del self.nodes[node_id]
|
|
98
|
+
self.events.node_removed.emit(node_id)
|
|
99
|
+
return node, removed
|
|
100
|
+
|
|
101
|
+
def move_node(self, node_id: str, pos: tuple[float, float]) -> None:
|
|
102
|
+
node = self.node(node_id)
|
|
103
|
+
node.pos = (float(pos[0]), float(pos[1]))
|
|
104
|
+
self.events.node_moved.emit(node_id, node.pos)
|
|
105
|
+
|
|
106
|
+
def set_label(self, node_id: str, label: Optional[str]) -> None:
|
|
107
|
+
node = self.node(node_id)
|
|
108
|
+
node.label_override = label or None
|
|
109
|
+
self.events.label_changed.emit(node_id)
|
|
110
|
+
|
|
111
|
+
def set_param(self, node_id: str, name: str, value: Any) -> None:
|
|
112
|
+
node = self.node(node_id)
|
|
113
|
+
if node.spec.param(name) is None:
|
|
114
|
+
raise GraphError(f"node {node.label!r} has no param {name!r}")
|
|
115
|
+
node.params[name] = value
|
|
116
|
+
self.events.param_changed.emit(node_id, name, value)
|
|
117
|
+
self.mark_dirty(node_id)
|
|
118
|
+
|
|
119
|
+
def set_code(self, node_id: str, source: str) -> list[Connection]:
|
|
120
|
+
"""Apply new code to a node: re-parse its spec, drop connections whose
|
|
121
|
+
ports vanished or became incompatible.
|
|
122
|
+
|
|
123
|
+
Raises NodeScriptError if the source doesn't satisfy the contract.
|
|
124
|
+
Returns the dropped connections (for undo).
|
|
125
|
+
"""
|
|
126
|
+
from .script import parse_spec # local import: avoid cycle
|
|
127
|
+
|
|
128
|
+
node = self.node(node_id)
|
|
129
|
+
new_spec = parse_spec(source, node.spec.type_id, builtin=False)
|
|
130
|
+
return self.apply_spec(node_id, source, new_spec)
|
|
131
|
+
|
|
132
|
+
def apply_spec(self, node_id: str, code_override: Optional[str],
|
|
133
|
+
spec: NodeSpec) -> list[Connection]:
|
|
134
|
+
"""Swap a node's spec (fork or reset-to-library), dropping connections
|
|
135
|
+
the new port set can't carry. Returns the dropped connections."""
|
|
136
|
+
node = self.node(node_id)
|
|
137
|
+
node.code_override = code_override
|
|
138
|
+
node.spec = spec
|
|
139
|
+
# keep param values that still exist; adopt defaults for new ones
|
|
140
|
+
node.params = {**spec.default_params(),
|
|
141
|
+
**{k: v for k, v in node.params.items() if spec.param(k)}}
|
|
142
|
+
removed = [c for c in self._connections_of(node_id)
|
|
143
|
+
if not self._still_valid(c)]
|
|
144
|
+
for conn in removed:
|
|
145
|
+
self.disconnect(conn.id)
|
|
146
|
+
self.events.code_changed.emit(node_id)
|
|
147
|
+
self.mark_dirty(node_id)
|
|
148
|
+
return removed
|
|
149
|
+
|
|
150
|
+
def restore_spec(self, node_id: str, code_override: Optional[str], spec: NodeSpec) -> None:
|
|
151
|
+
"""Low-level: put back a previous spec/override pair (undo of set_code
|
|
152
|
+
or 'reset to library'). Caller restores dropped connections itself."""
|
|
153
|
+
node = self.node(node_id)
|
|
154
|
+
node.code_override = code_override
|
|
155
|
+
node.spec = spec
|
|
156
|
+
node.params = {**spec.default_params(),
|
|
157
|
+
**{k: v for k, v in node.params.items() if spec.param(k)}}
|
|
158
|
+
self.events.code_changed.emit(node_id)
|
|
159
|
+
self.mark_dirty(node_id)
|
|
160
|
+
|
|
161
|
+
# ----------------------------------------------------------- connections
|
|
162
|
+
|
|
163
|
+
def connect(
|
|
164
|
+
self,
|
|
165
|
+
src_node: str,
|
|
166
|
+
src_port: str,
|
|
167
|
+
dst_node: str,
|
|
168
|
+
dst_port: str,
|
|
169
|
+
conn_id: Optional[str] = None,
|
|
170
|
+
) -> tuple[Connection, Optional[Connection]]:
|
|
171
|
+
"""Create a connection, validating everything.
|
|
172
|
+
|
|
173
|
+
An input port holds at most one connection: an existing one is
|
|
174
|
+
disconnected ("displaced") and returned so undo can restore it.
|
|
175
|
+
"""
|
|
176
|
+
src = self.node(src_node)
|
|
177
|
+
dst = self.node(dst_node)
|
|
178
|
+
out_spec = src.spec.output(src_port)
|
|
179
|
+
in_spec = dst.spec.input(dst_port)
|
|
180
|
+
if out_spec is None:
|
|
181
|
+
raise GraphError(f"node {src.label!r} has no output port {src_port!r}")
|
|
182
|
+
if in_spec is None:
|
|
183
|
+
raise GraphError(f"node {dst.label!r} has no input port {dst_port!r}")
|
|
184
|
+
if not can_connect(out_spec.type, in_spec.type):
|
|
185
|
+
raise GraphError(
|
|
186
|
+
f"cannot connect {out_spec.type.value} -> {in_spec.type.value}"
|
|
187
|
+
)
|
|
188
|
+
if self.would_cycle(src_node, dst_node):
|
|
189
|
+
raise GraphError("connection would create a cycle")
|
|
190
|
+
|
|
191
|
+
displaced = self.input_connection(dst_node, dst_port)
|
|
192
|
+
if displaced is not None:
|
|
193
|
+
self.disconnect(displaced.id)
|
|
194
|
+
|
|
195
|
+
conn = Connection(
|
|
196
|
+
id=conn_id or uuid.uuid4().hex,
|
|
197
|
+
src_node=src_node, src_port=src_port,
|
|
198
|
+
dst_node=dst_node, dst_port=dst_port,
|
|
199
|
+
)
|
|
200
|
+
self.connections[conn.id] = conn
|
|
201
|
+
self.events.connected.emit(conn)
|
|
202
|
+
self.mark_dirty(dst_node)
|
|
203
|
+
return conn, displaced
|
|
204
|
+
|
|
205
|
+
def disconnect(self, conn_id: str) -> Connection:
|
|
206
|
+
conn = self.connections.pop(conn_id, None)
|
|
207
|
+
if conn is None:
|
|
208
|
+
raise GraphError(f"no connection with id {conn_id!r}")
|
|
209
|
+
self.events.disconnected.emit(conn)
|
|
210
|
+
if conn.dst_node in self.nodes:
|
|
211
|
+
self.mark_dirty(conn.dst_node)
|
|
212
|
+
return conn
|
|
213
|
+
|
|
214
|
+
def input_connection(self, node_id: str, port: str) -> Optional[Connection]:
|
|
215
|
+
return next(
|
|
216
|
+
(c for c in self.connections.values()
|
|
217
|
+
if c.dst_node == node_id and c.dst_port == port),
|
|
218
|
+
None,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def in_connections(self, node_id: str) -> list[Connection]:
|
|
222
|
+
return [c for c in self.connections.values() if c.dst_node == node_id]
|
|
223
|
+
|
|
224
|
+
def out_connections(self, node_id: str) -> list[Connection]:
|
|
225
|
+
return [c for c in self.connections.values() if c.src_node == node_id]
|
|
226
|
+
|
|
227
|
+
def _connections_of(self, node_id: str) -> list[Connection]:
|
|
228
|
+
return [c for c in self.connections.values()
|
|
229
|
+
if node_id in (c.src_node, c.dst_node)]
|
|
230
|
+
|
|
231
|
+
def _still_valid(self, conn: Connection) -> bool:
|
|
232
|
+
src = self.nodes.get(conn.src_node)
|
|
233
|
+
dst = self.nodes.get(conn.dst_node)
|
|
234
|
+
if src is None or dst is None:
|
|
235
|
+
return False
|
|
236
|
+
out_spec = src.spec.output(conn.src_port)
|
|
237
|
+
in_spec = dst.spec.input(conn.dst_port)
|
|
238
|
+
return (
|
|
239
|
+
out_spec is not None
|
|
240
|
+
and in_spec is not None
|
|
241
|
+
and can_connect(out_spec.type, in_spec.type)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------- topology
|
|
245
|
+
|
|
246
|
+
def successors(self, node_id: str) -> set[str]:
|
|
247
|
+
return {c.dst_node for c in self.connections.values() if c.src_node == node_id}
|
|
248
|
+
|
|
249
|
+
def predecessors(self, node_id: str) -> set[str]:
|
|
250
|
+
return {c.src_node for c in self.connections.values() if c.dst_node == node_id}
|
|
251
|
+
|
|
252
|
+
def would_cycle(self, src_node: str, dst_node: str) -> bool:
|
|
253
|
+
"""Would a wire src_node -> dst_node close a cycle? True iff src_node
|
|
254
|
+
is reachable downstream from dst_node (or they are the same node)."""
|
|
255
|
+
if src_node == dst_node:
|
|
256
|
+
return True
|
|
257
|
+
return src_node in self.downstream(dst_node)
|
|
258
|
+
|
|
259
|
+
def downstream(self, node_id: str) -> set[str]:
|
|
260
|
+
"""All nodes strictly downstream of node_id."""
|
|
261
|
+
seen: set[str] = set()
|
|
262
|
+
stack = [node_id]
|
|
263
|
+
while stack:
|
|
264
|
+
for nxt in self.successors(stack.pop()):
|
|
265
|
+
if nxt not in seen:
|
|
266
|
+
seen.add(nxt)
|
|
267
|
+
stack.append(nxt)
|
|
268
|
+
return seen
|
|
269
|
+
|
|
270
|
+
def upstream(self, node_id: str) -> set[str]:
|
|
271
|
+
"""All nodes strictly upstream of node_id."""
|
|
272
|
+
seen: set[str] = set()
|
|
273
|
+
stack = [node_id]
|
|
274
|
+
while stack:
|
|
275
|
+
for prev in self.predecessors(stack.pop()):
|
|
276
|
+
if prev not in seen:
|
|
277
|
+
seen.add(prev)
|
|
278
|
+
stack.append(prev)
|
|
279
|
+
return seen
|
|
280
|
+
|
|
281
|
+
def topo_order(self, subset: Optional[Iterable[str]] = None) -> list[str]:
|
|
282
|
+
"""Kahn's algorithm over the whole graph (or an induced subgraph),
|
|
283
|
+
deterministic in node insertion order."""
|
|
284
|
+
ids = list(self.nodes) if subset is None else [
|
|
285
|
+
n for n in self.nodes if n in set(subset)
|
|
286
|
+
]
|
|
287
|
+
id_set = set(ids)
|
|
288
|
+
indegree = {
|
|
289
|
+
n: sum(1 for p in self.predecessors(n) if p in id_set) for n in ids
|
|
290
|
+
}
|
|
291
|
+
queue = [n for n in ids if indegree[n] == 0]
|
|
292
|
+
order: list[str] = []
|
|
293
|
+
while queue:
|
|
294
|
+
current = queue.pop(0)
|
|
295
|
+
order.append(current)
|
|
296
|
+
in_subset = [n for n in self.successors(current) if n in id_set]
|
|
297
|
+
for nxt in sorted(in_subset, key=ids.index):
|
|
298
|
+
indegree[nxt] -= 1
|
|
299
|
+
if indegree[nxt] == 0:
|
|
300
|
+
queue.append(nxt)
|
|
301
|
+
if len(order) != len(ids):
|
|
302
|
+
raise GraphError("graph contains a cycle")
|
|
303
|
+
return order
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------- dirty & status
|
|
306
|
+
|
|
307
|
+
def mark_dirty(self, node_id: str) -> None:
|
|
308
|
+
"""Mark a node and everything downstream of it dirty."""
|
|
309
|
+
for nid in [node_id, *self.downstream(node_id)]:
|
|
310
|
+
node = self.nodes[nid]
|
|
311
|
+
if not node.dirty:
|
|
312
|
+
node.dirty = True
|
|
313
|
+
self.events.dirty_changed.emit(nid, True)
|
|
314
|
+
|
|
315
|
+
def mark_clean(self, node_id: str) -> None:
|
|
316
|
+
node = self.node(node_id)
|
|
317
|
+
if node.dirty:
|
|
318
|
+
node.dirty = False
|
|
319
|
+
self.events.dirty_changed.emit(node_id, False)
|
|
320
|
+
|
|
321
|
+
def set_status(self, node_id: str, status: NodeStatus, message: str = "") -> None:
|
|
322
|
+
node = self.node(node_id)
|
|
323
|
+
node.status = status
|
|
324
|
+
node.status_message = message
|
|
325
|
+
self.events.status_changed.emit(node_id, status, message)
|
|
326
|
+
|
|
327
|
+
# --------------------------------------------------------------- frames
|
|
328
|
+
|
|
329
|
+
def add_frame(self, frame: Frame) -> Frame:
|
|
330
|
+
if frame.id in self.frames:
|
|
331
|
+
raise GraphError(f"frame id {frame.id!r} already in graph")
|
|
332
|
+
self.frames[frame.id] = frame
|
|
333
|
+
self.events.frame_added.emit(frame)
|
|
334
|
+
return frame
|
|
335
|
+
|
|
336
|
+
def remove_frame(self, frame_id: str) -> Frame:
|
|
337
|
+
frame = self.frames.pop(frame_id, None)
|
|
338
|
+
if frame is None:
|
|
339
|
+
raise GraphError(f"no frame with id {frame_id!r}")
|
|
340
|
+
self.events.frame_removed.emit(frame_id)
|
|
341
|
+
return frame
|
|
342
|
+
|
|
343
|
+
def update_frame(self, frame_id: str, *, title: Optional[str] = None,
|
|
344
|
+
rect: Optional[tuple[float, float, float, float]] = None,
|
|
345
|
+
color: Optional[str] = None) -> Frame:
|
|
346
|
+
frame = self.frames.get(frame_id)
|
|
347
|
+
if frame is None:
|
|
348
|
+
raise GraphError(f"no frame with id {frame_id!r}")
|
|
349
|
+
if title is not None:
|
|
350
|
+
frame.title = title
|
|
351
|
+
if rect is not None:
|
|
352
|
+
frame.rect = tuple(float(v) for v in rect) # type: ignore[assignment]
|
|
353
|
+
if color is not None:
|
|
354
|
+
frame.color = color
|
|
355
|
+
self.events.frame_changed.emit(frame)
|
|
356
|
+
return frame
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------- pages
|
|
359
|
+
|
|
360
|
+
def page(self, page_id: str) -> Page:
|
|
361
|
+
try:
|
|
362
|
+
return self.pages[page_id]
|
|
363
|
+
except KeyError:
|
|
364
|
+
raise GraphError(f"no page with id {page_id!r}") from None
|
|
365
|
+
|
|
366
|
+
def add_page(self, page: Page) -> Page:
|
|
367
|
+
if page.id in self.pages:
|
|
368
|
+
raise GraphError(f"page id {page.id!r} already in graph")
|
|
369
|
+
self.pages[page.id] = page
|
|
370
|
+
self.events.page_added.emit(page)
|
|
371
|
+
return page
|
|
372
|
+
|
|
373
|
+
def remove_page(self, page_id: str) -> Page:
|
|
374
|
+
page = self.pages.pop(page_id, None)
|
|
375
|
+
if page is None:
|
|
376
|
+
raise GraphError(f"no page with id {page_id!r}")
|
|
377
|
+
self.events.page_removed.emit(page_id)
|
|
378
|
+
return page
|
|
379
|
+
|
|
380
|
+
def update_page(self, page_id: str, *, title: Optional[str] = None) -> Page:
|
|
381
|
+
page = self.page(page_id)
|
|
382
|
+
if title is not None:
|
|
383
|
+
page.title = title
|
|
384
|
+
self.events.page_changed.emit(page)
|
|
385
|
+
return page
|
|
386
|
+
|
|
387
|
+
def add_tile(self, page_id: str, tile: Tile) -> Tile:
|
|
388
|
+
# no node_id validation: dangling refs are legal (placeholder in UI)
|
|
389
|
+
page = self.page(page_id)
|
|
390
|
+
if tile.id in page.tiles:
|
|
391
|
+
raise GraphError(f"tile id {tile.id!r} already on page {page_id!r}")
|
|
392
|
+
page.tiles[tile.id] = tile
|
|
393
|
+
self.events.tile_added.emit(page_id, tile)
|
|
394
|
+
return tile
|
|
395
|
+
|
|
396
|
+
def remove_tile(self, page_id: str, tile_id: str) -> Tile:
|
|
397
|
+
page = self.page(page_id)
|
|
398
|
+
tile = page.tiles.pop(tile_id, None)
|
|
399
|
+
if tile is None:
|
|
400
|
+
raise GraphError(f"no tile with id {tile_id!r} on page {page_id!r}")
|
|
401
|
+
self.events.tile_removed.emit(page_id, tile_id)
|
|
402
|
+
return tile
|
|
403
|
+
|
|
404
|
+
def update_tile(self, page_id: str, tile_id: str, *,
|
|
405
|
+
rect: Optional[tuple[float, float, float, float]] = None,
|
|
406
|
+
) -> Tile:
|
|
407
|
+
page = self.page(page_id)
|
|
408
|
+
tile = page.tiles.get(tile_id)
|
|
409
|
+
if tile is None:
|
|
410
|
+
raise GraphError(f"no tile with id {tile_id!r} on page {page_id!r}")
|
|
411
|
+
if rect is not None:
|
|
412
|
+
tile.rect = tuple(float(v) for v in rect) # type: ignore[assignment]
|
|
413
|
+
self.events.tile_changed.emit(page_id, tile)
|
|
414
|
+
return tile
|