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.
Files changed (114) hide show
  1. flograph/__init__.py +0 -0
  2. flograph/__main__.py +5 -0
  3. flograph/app.py +43 -0
  4. flograph/core/__init__.py +26 -0
  5. flograph/core/datatypes.py +88 -0
  6. flograph/core/events.py +54 -0
  7. flograph/core/graph.py +414 -0
  8. flograph/core/node.py +91 -0
  9. flograph/core/params.py +69 -0
  10. flograph/core/ports.py +19 -0
  11. flograph/core/registry.py +160 -0
  12. flograph/core/script.py +172 -0
  13. flograph/core/serialization.py +212 -0
  14. flograph/core/spec.py +35 -0
  15. flograph/core/user_nodes.py +201 -0
  16. flograph/engine/__init__.py +19 -0
  17. flograph/engine/cache.py +70 -0
  18. flograph/engine/cache_persistence.py +142 -0
  19. flograph/engine/context.py +51 -0
  20. flograph/engine/errors.py +54 -0
  21. flograph/engine/headless.py +63 -0
  22. flograph/engine/introspect.py +55 -0
  23. flograph/engine/scheduler.py +206 -0
  24. flograph/engine/worker.py +135 -0
  25. flograph/nodes/__init__.py +0 -0
  26. flograph/nodes/io/__init__.py +0 -0
  27. flograph/nodes/io/read_csv.py +31 -0
  28. flograph/nodes/io/read_excel.py +35 -0
  29. flograph/nodes/io/read_json.py +33 -0
  30. flograph/nodes/io/read_parquet.py +30 -0
  31. flograph/nodes/io/read_sqlite.py +33 -0
  32. flograph/nodes/io/table.py +68 -0
  33. flograph/nodes/io/write_csv.py +24 -0
  34. flograph/nodes/io/write_excel.py +27 -0
  35. flograph/nodes/io/write_json.py +31 -0
  36. flograph/nodes/io/write_parquet.py +24 -0
  37. flograph/nodes/io/write_sqlite.py +35 -0
  38. flograph/nodes/scripting/__init__.py +0 -0
  39. flograph/nodes/scripting/node_template.py +68 -0
  40. flograph/nodes/scripting/python_script.py +22 -0
  41. flograph/nodes/transform/__init__.py +0 -0
  42. flograph/nodes/transform/concatenate.py +28 -0
  43. flograph/nodes/transform/convert_types.py +54 -0
  44. flograph/nodes/transform/duplicate_filter.py +34 -0
  45. flograph/nodes/transform/expression.py +29 -0
  46. flograph/nodes/transform/filter_rows.py +24 -0
  47. flograph/nodes/transform/group_by.py +46 -0
  48. flograph/nodes/transform/join.py +31 -0
  49. flograph/nodes/transform/missing_values.py +63 -0
  50. flograph/nodes/transform/pivot.py +49 -0
  51. flograph/nodes/transform/rename_columns.py +35 -0
  52. flograph/nodes/transform/row_sampling.py +37 -0
  53. flograph/nodes/transform/select_columns.py +28 -0
  54. flograph/nodes/transform/sort.py +27 -0
  55. flograph/nodes/transform/statistics.py +26 -0
  56. flograph/nodes/transform/string_manipulation.py +52 -0
  57. flograph/nodes/transform/unpivot.py +40 -0
  58. flograph/nodes/util/__init__.py +0 -0
  59. flograph/nodes/util/action_button.py +36 -0
  60. flograph/nodes/util/constant.py +27 -0
  61. flograph/nodes/util/note.py +25 -0
  62. flograph/nodes/util/reroute.py +14 -0
  63. flograph/nodes/viz/__init__.py +0 -0
  64. flograph/nodes/viz/card.py +64 -0
  65. flograph/nodes/viz/show_plot.py +72 -0
  66. flograph/nodes/viz/show_plotly.py +68 -0
  67. flograph/nodes/viz/show_table.py +25 -0
  68. flograph/nodes/viz/slicer.py +61 -0
  69. flograph/nodes/viz/table_spec.py +26 -0
  70. flograph/packages.py +89 -0
  71. flograph/paths.py +31 -0
  72. flograph/ui/__init__.py +0 -0
  73. flograph/ui/canvas/__init__.py +13 -0
  74. flograph/ui/canvas/base_view.py +228 -0
  75. flograph/ui/canvas/connection_item.py +98 -0
  76. flograph/ui/canvas/frame_item.py +241 -0
  77. flograph/ui/canvas/grid.py +49 -0
  78. flograph/ui/canvas/minimap.py +115 -0
  79. flograph/ui/canvas/node_item.py +1475 -0
  80. flograph/ui/canvas/palette.py +228 -0
  81. flograph/ui/canvas/scene.py +415 -0
  82. flograph/ui/canvas/view.py +140 -0
  83. flograph/ui/commands.py +405 -0
  84. flograph/ui/console/log_dock.py +80 -0
  85. flograph/ui/dashboard/__init__.py +10 -0
  86. flograph/ui/dashboard/dashboard_page.py +53 -0
  87. flograph/ui/dashboard/dashboard_scene.py +147 -0
  88. flograph/ui/dashboard/dashboard_view.py +56 -0
  89. flograph/ui/dashboard/page_bar.py +117 -0
  90. flograph/ui/dashboard/tile_item.py +674 -0
  91. flograph/ui/dashboard/visuals_list.py +69 -0
  92. flograph/ui/editor/code_editor.py +213 -0
  93. flograph/ui/editor/completion.py +183 -0
  94. flograph/ui/editor/editor_dock.py +188 -0
  95. flograph/ui/editor/highlighter.py +123 -0
  96. flograph/ui/editor/save_user_node_dialog.py +64 -0
  97. flograph/ui/inspector/figure_view.py +194 -0
  98. flograph/ui/inspector/inspector_dock.py +127 -0
  99. flograph/ui/inspector/object_view.py +28 -0
  100. flograph/ui/inspector/pandas_model.py +87 -0
  101. flograph/ui/inspector/plotly_view.py +91 -0
  102. flograph/ui/inspector/popup_view.py +76 -0
  103. flograph/ui/inspector/spec_view.py +26 -0
  104. flograph/ui/inspector/view_for.py +34 -0
  105. flograph/ui/mainwindow.py +1240 -0
  106. flograph/ui/packages_dialog.py +216 -0
  107. flograph/ui/properties/params_panel.py +286 -0
  108. flograph/ui/slicer_list.py +86 -0
  109. flograph/ui/theme.py +85 -0
  110. flograph-0.1.0.dist-info/METADATA +211 -0
  111. flograph-0.1.0.dist-info/RECORD +114 -0
  112. flograph-0.1.0.dist-info/WHEEL +4 -0
  113. flograph-0.1.0.dist-info/entry_points.txt +2 -0
  114. flograph-0.1.0.dist-info/licenses/LICENSE +21 -0
flograph/__init__.py ADDED
File without changes
flograph/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m flograph` as an entry point (mirrors the console script)."""
2
+ from flograph.app import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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
+ )
@@ -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