chartflow 0.1__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 (79) hide show
  1. chartflow/__init__.py +28 -0
  2. chartflow/_verify_importexport.py +169 -0
  3. chartflow/edit/__init__.py +119 -0
  4. chartflow/edit/code_editor.py +1007 -0
  5. chartflow/edit/code_folder.py +523 -0
  6. chartflow/edit/completer.py +2652 -0
  7. chartflow/edit/context_analyzer.py +521 -0
  8. chartflow/edit/demo.py +469 -0
  9. chartflow/edit/line_number_area.py +548 -0
  10. chartflow/edit/minimap.py +581 -0
  11. chartflow/edit/python_editor.py +509 -0
  12. chartflow/edit/syntax_highlighter.py +291 -0
  13. chartflow/edit/test_editor.py +190 -0
  14. chartflow/flow/__init__.py +31 -0
  15. chartflow/flow/_demo.py +98 -0
  16. chartflow/flow/chart.py +1101 -0
  17. chartflow/flow/editor.py +375 -0
  18. chartflow/flow/flow.py +6 -0
  19. chartflow/flow/qchart.py +1250 -0
  20. chartflow/flow/test/__init__.py +0 -0
  21. chartflow/flow/test/test_chart.py +537 -0
  22. chartflow/flow/test_decorator_sync.py +102 -0
  23. chartflow/fsm/__init__.py +84 -0
  24. chartflow/fsm/decorators.py +395 -0
  25. chartflow/fsm/demo.py +381 -0
  26. chartflow/fsm/demo_pyqt6.py +881 -0
  27. chartflow/fsm/demo_tree.py +46 -0
  28. chartflow/fsm/logic.py +447 -0
  29. chartflow/fsm/meta.py +569 -0
  30. chartflow/fsm/scheduler.py +427 -0
  31. chartflow/fsm/stage.py +2079 -0
  32. chartflow/fsm/stdio.py +66 -0
  33. chartflow/fsm/test/__init__.py +7 -0
  34. chartflow/fsm/test/test_decorators.py +210 -0
  35. chartflow/fsm/test/test_events.py +202 -0
  36. chartflow/fsm/test/test_integration.py +596 -0
  37. chartflow/fsm/test/test_logic.py +371 -0
  38. chartflow/fsm/test/test_meta.py +192 -0
  39. chartflow/fsm/test/test_runtime_param.py +336 -0
  40. chartflow/fsm/test/test_scheduler.py +280 -0
  41. chartflow/fsm/test/test_stage.py +314 -0
  42. chartflow/fsm/test_behavior_events.py +263 -0
  43. chartflow/fsm/test_nested_parallel.py +80 -0
  44. chartflow/fsm/test_stage_spec.py +448 -0
  45. chartflow/fsm/utils.py +34 -0
  46. chartflow/node/__init__.py +66 -0
  47. chartflow/node/_node_base.py +727 -0
  48. chartflow/node/_node_interaction.py +488 -0
  49. chartflow/node/_node_interface.py +426 -0
  50. chartflow/node/_node_markers.py +648 -0
  51. chartflow/node/_node_ports.py +536 -0
  52. chartflow/node/_port_interface.py +111 -0
  53. chartflow/node/arrow_item.py +287 -0
  54. chartflow/node/color_utils.py +113 -0
  55. chartflow/node/connection_item.py +939 -0
  56. chartflow/node/demo.py +258 -0
  57. chartflow/node/demo_arrow_drag.py +46 -0
  58. chartflow/node/demo_decorator.py +63 -0
  59. chartflow/node/demo_ports.py +69 -0
  60. chartflow/node/enums.py +35 -0
  61. chartflow/node/example.py +84 -0
  62. chartflow/node/layout_utils.py +307 -0
  63. chartflow/node/main_window.py +196 -0
  64. chartflow/node/menu_bar.py +240 -0
  65. chartflow/node/node_canvas.py +1730 -0
  66. chartflow/node/node_item.py +754 -0
  67. chartflow/node/popmenu.py +472 -0
  68. chartflow/node/port_item.py +591 -0
  69. chartflow/node/qnode_editor.py +73 -0
  70. chartflow/node/selection_rect.py +53 -0
  71. chartflow/node/style_panel.py +615 -0
  72. chartflow/node/styles.py +63 -0
  73. chartflow/node/test_qnode.py +2336 -0
  74. chartflow/showcase.py +528 -0
  75. chartflow/theme.py +508 -0
  76. chartflow-0.1.dist-info/METADATA +23 -0
  77. chartflow-0.1.dist-info/RECORD +79 -0
  78. chartflow-0.1.dist-info/WHEEL +5 -0
  79. chartflow-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1101 @@
1
+ from __future__ import annotations
2
+ """
3
+
4
+
5
+ qflow.chart - IChart interface for qnode <-> qfsm conversion
6
+
7
+ This module provides the IChart interface that serves as a data carrier
8
+ describing a node graph and its logic, enabling conversion between:
9
+ - qnode JSON format (visual node graph)
10
+ - qfsm Stage/Logic classes (finite state machine)
11
+ """
12
+
13
+ from chartflow.fsm.stage import Stage
14
+ from chartflow.fsm.logic import Logic
15
+ import ast
16
+ import inspect
17
+ from PyQt6.QtWidgets import QApplication
18
+ from PyQt6.QtCore import Qt, QRectF, QPointF
19
+ from PyQt6.QtGui import QPainter, QPixmap, QColor
20
+ from chartflow.node import QNodeCanvas, NodeShape, ConnectionType
21
+ from chartflow.node.layout_utils import DenseLayoutEngine, ViewportFitter
22
+ from PIL import Image
23
+ import tempfile
24
+ import os
25
+ import random
26
+ from PyQt6.QtGui import QColor
27
+
28
+
29
+
30
+ from abc import ABC, abstractmethod
31
+ from typing import Any, ClassVar, Type, TYPE_CHECKING, Union
32
+ from dataclasses import dataclass, field
33
+
34
+ @dataclass
35
+ class ChartNode:
36
+ """
37
+ Data class representing a node in the chart.
38
+
39
+ Attributes:
40
+ nid: Unique node identifier
41
+ name: Node display name
42
+ ntype: Node type (state, logic, entry, exit, etc.)
43
+ shape: Visual shape (ellipse, rectangle, diamond)
44
+ data: Custom node data
45
+ states: List of state names if this is a compound node
46
+ custom: Custom marker configuration
47
+ """
48
+ nid: str
49
+ name: str
50
+ ntype: str = "state"
51
+ shape: str = "rectangle"
52
+ data: dict[str, Any] = field(default_factory=dict)
53
+ states: list[str] = field(default_factory=list)
54
+ custom: dict[str, Any] = field(default_factory=dict)
55
+
56
+
57
+ @dataclass
58
+ class ChartEdge:
59
+ """
60
+ Data class representing an edge/connection in the chart.
61
+
62
+ Attributes:
63
+ eid: Unique edge identifier
64
+ src: Source node ID
65
+ dst: Destination node ID
66
+ label: Edge label/condition
67
+ etype: Edge type (transition, event, flow)
68
+ bidirectional: Whether edge is bidirectional
69
+ """
70
+ eid: str
71
+ src: str
72
+ dst: str
73
+ label: str = ""
74
+ etype: str = "transition"
75
+ bidirectional: bool = False
76
+
77
+
78
+ @dataclass
79
+ class ChartLogic:
80
+ """
81
+ Data class representing logic configuration for a node.
82
+
83
+ Attributes:
84
+ node_id: Associated node ID
85
+ logic_type: Logic class type (Stage, Logic, etc.)
86
+ priority: Execution priority for Logic instances
87
+ decorators: List of decorators to apply
88
+ states: State method definitions
89
+ listeners: Event listener definitions
90
+ """
91
+ node_id: str
92
+ logic_type: str = "Stage"
93
+ priority: int = 0
94
+ decorators: list[dict[str, Any]] = field(default_factory=list)
95
+ states: dict[str, str] = field(default_factory=dict)
96
+ listeners: list[dict[str, Any]] = field(default_factory=list)
97
+
98
+
99
+ class IChart(ABC):
100
+ """
101
+ Interface for chart data conversion between qnode and qfsm.
102
+
103
+ IChart serves as a bridge between:
104
+ - qnode: Visual node graph representation (JSON)
105
+ - qfsm: Finite state machine implementation (Stage/Logic)
106
+
107
+ Implementations should handle:
108
+ 1. Parsing qnode JSON into internal node/edge representation
109
+ 2. Generating qfsm-compatible class definitions
110
+ 3. Converting visual graphs to executable state machines
111
+
112
+ Example:
113
+ # Load from chartflow.node JSON
114
+ chart = MyChart()
115
+ chart.fromJson(json_data)
116
+
117
+ # Generate qfsm Stage class
118
+ stage_code = chart.toFsmStage()
119
+
120
+ # Or create runtime instance
121
+ stage = chart.createStage()
122
+ """
123
+
124
+ # Class-level metadata
125
+ NAME: ClassVar[str] = "IChart"
126
+ VERSION: ClassVar[str] = "1.0"
127
+
128
+ def __init__(
129
+ self,
130
+ src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic'] | None = None
131
+ ):
132
+ """
133
+ Initialize chart, optionally from existing data.
134
+
135
+ Args:
136
+ src: Source data to initialize from:
137
+ - dict: JSON data (qnode format or extractNodes format)
138
+ - Stage: Parse from Stage instance or class
139
+ - Logic: Parse from Logic instance or class
140
+ - None: Create empty chart
141
+
142
+ Note:
143
+ IChart is an abstract base class. Use Chart for concrete implementation.
144
+ """
145
+ self._nodes: dict[str, ChartNode] = {}
146
+ self._edges: dict[str, ChartEdge] = {}
147
+ self._logics: dict[str, ChartLogic] = {}
148
+ self._metadata: dict[str, Any] = {}
149
+
150
+ if src is not None:
151
+ self._initFrom(src)
152
+
153
+ def _initFrom(
154
+ self, src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic']
155
+ ) -> None:
156
+ """Initialize chart from various source types (to be implemented by subclasses)."""
157
+ raise NotImplementedError("Subclasses must implement _initFrom")
158
+
159
+ # -------------------------------------------------------------------------
160
+ # Properties
161
+ # -------------------------------------------------------------------------
162
+
163
+ @property
164
+ def nodes(self) -> list[ChartNode]:
165
+ """Get all nodes in the chart."""
166
+ return list(self._nodes.values())
167
+
168
+ @property
169
+ def edges(self) -> list[ChartEdge]:
170
+ """Get all edges in the chart."""
171
+ return list(self._edges.values())
172
+
173
+ @property
174
+ def logics(self) -> list[ChartLogic]:
175
+ """Get all logic configurations."""
176
+ return list(self._logics.values())
177
+
178
+ @property
179
+ def startnode(self) -> str | None:
180
+ """Get start node ID."""
181
+ return self._metadata.get("startnode")
182
+
183
+ @startnode.setter
184
+ def startnode(self, nid: str | None) -> None:
185
+ """Set start node ID."""
186
+ self._metadata["startnode"] = nid
187
+
188
+ # -------------------------------------------------------------------------
189
+ # Node/Edge Management
190
+ # -------------------------------------------------------------------------
191
+
192
+ def addNode(self, node: ChartNode) -> None:
193
+ """Add a node to the chart."""
194
+ self._nodes[node.nid] = node
195
+
196
+ def removeNode(self, nid: str) -> ChartNode | None:
197
+ """Remove a node and return it, or None if not found."""
198
+ return self._nodes.pop(nid, None)
199
+
200
+ def getNode(self, nid: str) -> ChartNode | None:
201
+ """Get node by ID."""
202
+ return self._nodes.get(nid)
203
+
204
+ def addEdge(self, edge: ChartEdge) -> None:
205
+ """Add an edge to the chart."""
206
+ self._edges[edge.eid] = edge
207
+
208
+ def removeEdge(self, eid: str) -> ChartEdge | None:
209
+ """Remove an edge and return it, or None if not found."""
210
+ return self._edges.pop(eid, None)
211
+
212
+ def getEdge(self, eid: str) -> ChartEdge | None:
213
+ """Get edge by ID."""
214
+ return self._edges.get(eid)
215
+
216
+ def addLogic(self, logic: ChartLogic) -> None:
217
+ """Add logic configuration for a node."""
218
+ self._logics[logic.node_id] = logic
219
+
220
+ def getLogic(self, nid: str) -> ChartLogic | None:
221
+ """Get logic configuration for node."""
222
+ return self._logics.get(nid)
223
+
224
+ # -------------------------------------------------------------------------
225
+ # Abstract Methods - Import/Export
226
+ # -------------------------------------------------------------------------
227
+
228
+ @abstractmethod
229
+ def fromJson(self, data: dict[str, Any]) -> IChart:
230
+ """
231
+ Load chart from chartflow.node JSON format.
232
+ When converting to visual representation, auto-layout should be applied.
233
+
234
+ Args:
235
+ data: qnode exported JSON as dict
236
+
237
+ Returns:
238
+ Self for chaining
239
+ """
240
+ pass
241
+
242
+ @abstractmethod
243
+ def toJson(self) -> dict[str, Any]:
244
+ """
245
+ Export chart to qnode JSON format.
246
+
247
+ Returns:
248
+ Dict compatible with qnode import
249
+ """
250
+ pass
251
+
252
+ @abstractmethod
253
+ def toStage(self, inst: Any | None = None) -> 'Stage':
254
+ """
255
+ Create qfsm Stage instance from chart.
256
+
257
+ Args:
258
+ inst: Optional bound instance
259
+
260
+ Returns:
261
+ qfsm Stage instance
262
+ """
263
+ pass
264
+
265
+ @abstractmethod
266
+ def toLogic(self, inst: Any | None = None) -> 'Logic':
267
+ """
268
+ Create qfsm Logic instance from chart.
269
+
270
+ Args:
271
+ inst: Optional bound instance
272
+
273
+ Returns:
274
+ qfsm Logic instance
275
+ """
276
+ pass
277
+
278
+ @abstractmethod
279
+ def fromStage(self, stage: 'Stage') -> IChart:
280
+ """
281
+ Load chart from chartflow.fsm Stage instance.
282
+
283
+ Args:
284
+ stage: qfsm Stage instance to parse
285
+
286
+ Returns:
287
+ Self for chaining
288
+ """
289
+ pass
290
+
291
+ @abstractmethod
292
+ def fromLogic(self, logic: 'Logic') -> IChart:
293
+ """
294
+ Load chart from chartflow.fsm Logic instance.
295
+
296
+ Args:
297
+ logic: qfsm Logic instance to parse
298
+
299
+ Returns:
300
+ Self for chaining
301
+ """
302
+ pass
303
+
304
+ @abstractmethod
305
+ def toImage(self, width: int = 800, height: int = 600) -> Any:
306
+ """
307
+ Render chart to an image.
308
+
309
+ Args:
310
+ width: Image width in pixels
311
+ height: Image height in pixels
312
+
313
+ Returns:
314
+ PIL Image object
315
+ """
316
+ pass
317
+
318
+ # -------------------------------------------------------------------------
319
+ # Utility Methods
320
+ # -------------------------------------------------------------------------
321
+
322
+ def clearChart(self) -> None:
323
+ """Clear all nodes, edges and logics."""
324
+ self._nodes.clear()
325
+ self._edges.clear()
326
+ self._logics.clear()
327
+ self._metadata.clear()
328
+
329
+ def getOutgoing(self, nid: str) -> list[ChartEdge]:
330
+ """Get all outgoing edges from a node."""
331
+ return [e for e in self._edges.values() if e.src == nid]
332
+
333
+ def getIncoming(self, nid: str) -> list[ChartEdge]:
334
+ """Get all incoming edges to a node."""
335
+ return [e for e in self._edges.values() if e.dst == nid]
336
+
337
+ def getConnected(self, nid: str) -> list[str]:
338
+ """Get IDs of all nodes connected to given node."""
339
+ connected = set()
340
+ for edge in self._edges.values():
341
+ if edge.src == nid:
342
+ connected.add(edge.dst)
343
+ elif edge.dst == nid:
344
+ connected.add(edge.src)
345
+ return list(connected)
346
+
347
+ def validChart(self) -> tuple[bool, str]:
348
+ """
349
+ Validate chart integrity.
350
+
351
+ Returns:
352
+ Tuple of (is_valid, error_message)
353
+ """
354
+ # Check start node exists
355
+ if self.startnode and self.startnode not in self._nodes:
356
+ return False, f"Start node '{self.startnode}' not found"
357
+
358
+ # Check edge endpoints exist
359
+ for edge in self._edges.values():
360
+ if edge.src not in self._nodes:
361
+ return False, f"Edge {edge.eid}: source '{edge.src}' not found"
362
+ if edge.dst not in self._nodes:
363
+ return False, f"Edge {edge.eid}: destination '{edge.dst}' not found"
364
+
365
+ # Check logic references exist
366
+ for logic in self._logics.values():
367
+ if logic.node_id not in self._nodes:
368
+ return False, f"Logic for non-existent node '{logic.node_id}'"
369
+
370
+ return True, ""
371
+
372
+ def __repr__(self) -> str:
373
+ return f"{self.__class__.__name__}(nodes={len(self._nodes)}, edges={len(self._edges)})"
374
+
375
+
376
+ class Chart(IChart):
377
+ """
378
+ Chart implementation of IChart interface.
379
+
380
+ Provides bidirectional conversion between:
381
+ - qnode JSON format (visual node graph)
382
+ - qfsm Stage/Logic instances (executable state machines)
383
+
384
+ Example:
385
+ # Load from chartflow.node JSON
386
+ chart = Chart()
387
+ chart.fromJson(json_data)
388
+
389
+ # Convert to Stage instance
390
+ stage = chart.toStage()
391
+
392
+ # Or convert from existing Stage
393
+ chart2 = Chart()
394
+ chart2.fromStage(existing_stage)
395
+ json_data = chart2.toJson()
396
+ """
397
+
398
+ NAME = "Chart"
399
+ VERSION = "1.0"
400
+
401
+ def __init__(
402
+ self,
403
+ src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic'] | None = None
404
+ ):
405
+ """
406
+ Initialize Chart, optionally from existing data.
407
+
408
+ Args:
409
+ src: Source data to initialize from:
410
+ - dict: JSON data (qnode format or extractNodes format)
411
+ - Stage: Parse from Stage instance or class
412
+ - Logic: Parse from Logic instance or class
413
+ - None: Create empty chart
414
+
415
+ Example:
416
+ # From JSON
417
+ chart = Chart(json_data)
418
+
419
+ # From Stage instance
420
+ chart = Chart(my_stage)
421
+
422
+ # From Stage class (no instantiation)
423
+ chart = Chart(MyStage)
424
+
425
+ # From Logic instance
426
+ chart = Chart(my_logic)
427
+
428
+ # From Logic class (no instantiation)
429
+ chart = Chart(MyLogic)
430
+
431
+ # Empty
432
+ chart = Chart()
433
+ """
434
+ super().__init__(None) # Don't init from src in parent, handle here
435
+ self._stageClass: Type['Stage'] | None = None
436
+ self._logicClass: Type['Logic'] | None = None
437
+
438
+ if src is not None:
439
+ self._initFrom(src)
440
+
441
+ def _initFrom(
442
+ self, src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic']
443
+ ) -> None:
444
+ """Initialize chart from various source types."""
445
+ if isinstance(src, dict):
446
+ self.fromJson(src)
447
+ return
448
+
449
+
450
+ # Check if it's a Logic (instance or class)
451
+ if isinstance(src, Logic) or (isinstance(src, type) and issubclass(src, Logic)):
452
+ self.fromLogic(src)
453
+ # Check if it's a Stage (instance or class)
454
+ elif isinstance(src, Stage) or (isinstance(src, type) and issubclass(src, Stage)):
455
+ self.fromStage(src)
456
+ else:
457
+ raise TypeError(f"Unsupported source type: {type(src).__name__}")
458
+
459
+ def fromJson(self, data: dict[str, Any]) -> 'Chart':
460
+ """
461
+ Load chart from JSON format.
462
+
463
+ Supports two formats:
464
+ 1. Standard qnode JSON: {"nodes": [...], "connections": [...]}
465
+ 2. extractNodes format: {state_name: {code, targets, decorates}}
466
+
467
+ Args:
468
+ data: JSON data as dict
469
+
470
+ Returns:
471
+ Self for chaining
472
+ """
473
+ self.clearChart()
474
+
475
+ # Detect format: extractNodes format has no "nodes" key
476
+ if "nodes" not in data and "connections" not in data:
477
+ self._fromExtractNodesFormat(data)
478
+ return self
479
+
480
+ # Standard qnode JSON format
481
+ # Load start node
482
+ if "start_node_id" in data:
483
+ start_id = data["start_node_id"]
484
+ if start_id is not None:
485
+ self.startnode = str(start_id)
486
+
487
+ # Load nodes
488
+ for node_data in data.get("nodes", []):
489
+ node = ChartNode(
490
+ nid=str(node_data["id"]),
491
+ name=node_data.get("name", ""),
492
+ ntype=node_data.get("type", "state"),
493
+ shape=node_data.get("shape", "rectangle").lower(),
494
+ data=node_data.get("data", {}),
495
+ states=node_data.get("data", {}).get("states", []),
496
+ custom=node_data.get("custom", {})
497
+ )
498
+ self.addNode(node)
499
+
500
+ # Load connections as edges
501
+ for conn_data in data.get("connections", []):
502
+ edge = ChartEdge(
503
+ eid=f"edge_{conn_data['source_id']}_{conn_data['target_id']}",
504
+ src=str(conn_data["source_id"]),
505
+ dst=str(conn_data["target_id"]),
506
+ label=conn_data.get("label", ""),
507
+ etype=conn_data.get("connection_type", "transition").lower(),
508
+ bidirectional=conn_data.get("is_bidirectional", False)
509
+ )
510
+ self.addEdge(edge)
511
+
512
+ return self
513
+
514
+ def _fromExtractNodesFormat(self, data: dict[str, Any]) -> None:
515
+ """
516
+ Load from extractNodes() format: {state_name: {code, targets, decorates}}.
517
+
518
+ Compatible with old _importDict() behavior.
519
+ """
520
+ node_map = {} # name -> nid
521
+
522
+ # Create nodes
523
+ for i, (state_name, info) in enumerate(data.items()):
524
+ nid = f"state_{i}"
525
+ node_map[state_name] = nid
526
+
527
+ node_data = {
528
+ "code": info.get("code", ""),
529
+ "targets": info.get("targets", []),
530
+ "decorates": info.get("decorates", [])
531
+ }
532
+
533
+ node = ChartNode(
534
+ nid=nid,
535
+ name=state_name,
536
+ ntype="state",
537
+ shape="ellipse",
538
+ data=node_data
539
+ )
540
+ self.addNode(node)
541
+
542
+ # Create edges from targets
543
+ for state_name, info in data.items():
544
+ src_nid = node_map[state_name]
545
+ for target in info.get("targets", []):
546
+ if target in node_map:
547
+ dst_nid = node_map[target]
548
+ edge = ChartEdge(
549
+ eid=f"edge_{state_name}_{target}",
550
+ src=src_nid,
551
+ dst=dst_nid,
552
+ label=f"{state_name}->{target}",
553
+ etype="transition"
554
+ )
555
+ self.addEdge(edge)
556
+
557
+ # Set start node
558
+ if data:
559
+ self.startnode = "state_0"
560
+
561
+ def toJson(self) -> dict[str, Any]:
562
+ """
563
+ Export chart to qnode JSON format.
564
+
565
+ Returns:
566
+ Dict compatible with qnode import
567
+ """
568
+ nodes = []
569
+ for node in self._nodes.values():
570
+ node_data = {
571
+ "id": node.nid,
572
+ "name": node.name,
573
+ "shape": node.shape.upper(),
574
+ "type": node.ntype,
575
+ "data": node.data.copy()
576
+ }
577
+ # 添加 custom 标记配置(如果有)
578
+ if node.custom:
579
+ node_data["custom"] = node.custom.copy()
580
+ nodes.append(node_data)
581
+
582
+ connections = []
583
+ for edge in self._edges.values():
584
+ conn_data = {
585
+ "source_id": edge.src,
586
+ "target_id": edge.dst,
587
+ "label": edge.label,
588
+ "is_bidirectional": edge.bidirectional,
589
+ "connection_type": edge.etype.upper()
590
+ }
591
+ connections.append(conn_data)
592
+
593
+ return {
594
+ "nodes": nodes,
595
+ "connections": connections,
596
+ "start_node_id": self.startnode
597
+ }
598
+
599
+ def toStage(self, inst: Any | None = None) -> 'Stage':
600
+ """
601
+ Create qfsm Stage instance from chart.
602
+
603
+ Dynamically creates a Stage subclass based on the chart structure
604
+ and instantiates it.
605
+
606
+ Args:
607
+ inst: Optional bound instance
608
+
609
+ Returns:
610
+ qfsm Stage instance
611
+ """
612
+ if self._stageClass is None:
613
+ self._stageClass = self._createStageClass()
614
+ return self._stageClass(inst)
615
+
616
+ def toLogic(self, inst: Any | None = None) -> 'Logic':
617
+ """
618
+ Create qfsm Logic instance from chart.
619
+
620
+ Dynamically creates a Logic subclass based on the chart structure
621
+ and instantiates it.
622
+
623
+ Args:
624
+ inst: Optional bound instance
625
+
626
+ Returns:
627
+ qfsm Logic instance
628
+ """
629
+ if self._logicClass is None:
630
+ self._logicClass = self._createLogicClass()
631
+ return self._logicClass(inst)
632
+
633
+ def fromStage(self, src: 'Stage' | Type['Stage']) -> 'Chart':
634
+ """
635
+ Load chart from chartflow.fsm Stage instance or class.
636
+
637
+ Parses the Stage's states, code, targets, and decorates into nodes and edges.
638
+ Output format compatible with extractNodes().
639
+
640
+ Args:
641
+ src: qfsm Stage instance or class to parse
642
+
643
+ Returns:
644
+ Self for chaining
645
+
646
+ Example:
647
+ # From instance
648
+ chart = Chart(my_stage)
649
+
650
+ # From class (without instantiation)
651
+ chart = Chart(MyStage)
652
+ """
653
+
654
+ self.clearChart()
655
+
656
+ # Handle class vs instance
657
+ if isinstance(src, type):
658
+ stage_class = src
659
+ stage_instance = None
660
+ else:
661
+ stage_class = src.__class__
662
+ stage_instance = src
663
+
664
+ states = getattr(stage_class, "__states__", [])
665
+
666
+ # Get source code for AST analysis
667
+ try:
668
+ source_file = inspect.getsourcefile(stage_class)
669
+ if source_file:
670
+ with open(source_file, 'r', encoding='utf-8') as f:
671
+ source = f.read()
672
+ module_ast = ast.parse(source)
673
+
674
+ # Find class definition
675
+ class_node = None
676
+ for node in ast.walk(module_ast):
677
+ if isinstance(node, ast.ClassDef) and node.name == stage_class.__name__:
678
+ class_node = node
679
+ break
680
+ else:
681
+ class_node = None
682
+ source = ""
683
+ except Exception:
684
+ class_node = None
685
+ source = ""
686
+
687
+ # Build state -> method node mapping
688
+ method_nodes = {}
689
+ if class_node:
690
+ for node in class_node.body:
691
+ if isinstance(node, ast.FunctionDef) and node.name in states:
692
+ method_nodes[node.name] = node
693
+
694
+ # Create node for each state with full data
695
+ for i, state_name in enumerate(states):
696
+ node_data = {"is_recursive": state_name in getattr(stage_class, "__recursive__", set())}
697
+
698
+ # Extract code, targets, decorates from AST
699
+ func_node = method_nodes.get(state_name)
700
+ if func_node and source:
701
+ # Extract function body code
702
+ node_data["code"] = self._extractFunctionBody(source, func_node)
703
+
704
+ # Extract targets (return string constants)
705
+ node_data["targets"] = self._extractStaticTargets(func_node, state_name)
706
+
707
+ # Extract decorates (excluding property)
708
+ node_data["decorates"] = self._extractDecorators(func_node)
709
+
710
+ node = ChartNode(
711
+ nid=f"state_{i}",
712
+ name=state_name,
713
+ ntype="state",
714
+ shape="ellipse",
715
+ data=node_data
716
+ )
717
+ self.addNode(node)
718
+
719
+ # Create edges from targets
720
+ for target in node_data.get("targets", []):
721
+ if target in states:
722
+ edge = ChartEdge(
723
+ eid=f"edge_{state_name}_{target}",
724
+ src=f"state_{i}",
725
+ dst=f"state_{states.index(target)}",
726
+ label=f"{state_name}->{target}",
727
+ etype="transition"
728
+ )
729
+ self.addEdge(edge)
730
+
731
+ # Set start node to first state if exists
732
+ if states:
733
+ self.startnode = "state_0"
734
+
735
+ return self
736
+
737
+ def fromLogic(self, src: 'Logic' | Type['Logic']) -> 'Chart':
738
+ """
739
+ Load chart from chartflow.fsm Logic instance or class.
740
+
741
+ Parses the Logic's states and properties into nodes and logic config.
742
+
743
+ Args:
744
+ src: qfsm Logic instance or class to parse
745
+
746
+ Returns:
747
+ Self for chaining
748
+
749
+ Example:
750
+ # From instance
751
+ chart = Chart(my_logic)
752
+
753
+ # From class (without instantiation)
754
+ chart = Chart(MyLogic)
755
+ """
756
+ # Handle class vs instance
757
+ if isinstance(src, type):
758
+ logic_class = src
759
+ else:
760
+ logic_class = src.__class__
761
+
762
+ self.fromStage(src)
763
+
764
+ # Add logic-specific metadata
765
+ for node in self._nodes.values():
766
+ node.data["priority"] = getattr(logic_class, "PRIORITY", 0)
767
+ node.data["logic_name"] = getattr(logic_class, "NAME", "Logic")
768
+
769
+ return self
770
+
771
+ def _createStageClass(self) -> Type['Stage']:
772
+ """
773
+ Dynamically create a Stage subclass from chart nodes/edges.
774
+
775
+ Returns:
776
+ New Stage subclass
777
+ """
778
+ # Build state methods from nodes
779
+ methods: dict[str, Any] = {}
780
+
781
+ for node in self._nodes.values():
782
+ if node.ntype == "state":
783
+ # Find outgoing transitions for this state
784
+ outgoing = self.getOutgoing(node.nid)
785
+ if outgoing:
786
+ # Create state method that returns target state
787
+ target_edge = outgoing[0]
788
+ target_node = self._nodes.get(target_edge.dst)
789
+ if target_node:
790
+ def makeStateMethod(target: str):
791
+ def stateMethod(self, inst: Any) -> str | None:
792
+ return target
793
+ return stateMethod
794
+ methods[node.name] = makeStateMethod(target_node.name)
795
+ else:
796
+ # Terminal state
797
+ def makeTerminalState():
798
+ def terminalState(self, inst: Any) -> None:
799
+ return None
800
+ return terminalState
801
+ methods[node.name] = makeTerminalState()
802
+
803
+ # Create the class
804
+ class_name = "ChartStage"
805
+ return type(
806
+ class_name,
807
+ (Stage,),
808
+ {
809
+ "NAME": class_name,
810
+ **methods
811
+ }
812
+ )
813
+
814
+ def _createLogicClass(self) -> Type['Logic']:
815
+ """
816
+ Dynamically create a Logic subclass from chart nodes/edges.
817
+
818
+ Returns:
819
+ New Logic subclass
820
+ """
821
+ # Get base Stage class methods
822
+ stage_class = self._createStageClass()
823
+
824
+ # Extract methods from stage class (excluding __states__ etc)
825
+ methods = {}
826
+ for attr_name in dir(stage_class):
827
+ if not attr_name.startswith("__") and not attr_name.endswith("__"):
828
+ attr = getattr(stage_class, attr_name)
829
+ if callable(attr) and not isinstance(attr, type):
830
+ methods[attr_name] = attr
831
+
832
+ # Create Logic subclass
833
+ class_name = "ChartLogic"
834
+ return type(
835
+ class_name,
836
+ (Logic,),
837
+ {
838
+ "NAME": class_name,
839
+ "PRIORITY": 0,
840
+ **methods
841
+ }
842
+ )
843
+
844
+ def _extractFunctionBody(self, source: str, func_node: Any) -> str:
845
+ """Extract function body from source, removing def line and common indent."""
846
+ start_line = func_node.lineno
847
+ end_line = getattr(func_node, 'end_lineno', start_line)
848
+
849
+ lines = source.splitlines()
850
+ if start_line > len(lines) or end_line > len(lines):
851
+ return ""
852
+
853
+ func_lines = lines[start_line - 1:end_line]
854
+ body_lines = func_lines[1:] # Remove def line
855
+
856
+ if not body_lines:
857
+ return ""
858
+
859
+ # Calculate common indent
860
+ first_body = body_lines[0]
861
+ indent = len(first_body) - len(first_body.lstrip())
862
+
863
+ cleaned = []
864
+ for line in body_lines:
865
+ if line.strip() == "":
866
+ cleaned.append("")
867
+ elif len(line) >= indent:
868
+ cleaned.append(line[indent:])
869
+ else:
870
+ cleaned.append(line)
871
+
872
+ return "\n".join(cleaned).rstrip()
873
+
874
+ def _extractStaticTargets(self, func_node: Any, current_state: str) -> list[str]:
875
+ """Extract return string constants as targets."""
876
+ targets = set()
877
+
878
+ def _visit(node):
879
+ if isinstance(node, ast.Return) and node.value:
880
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
881
+ if node.value.value != current_state:
882
+ targets.add(node.value.value)
883
+ elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
884
+ if node.value.s != current_state:
885
+ targets.add(node.value.s)
886
+ elif isinstance(node, (ast.If, ast.IfExp)):
887
+ for child in ast.iter_child_nodes(node):
888
+ _visit(child)
889
+ else:
890
+ for child in ast.iter_child_nodes(node):
891
+ _visit(child)
892
+
893
+ for stmt in func_node.body:
894
+ _visit(stmt)
895
+
896
+ return sorted(list(targets))
897
+
898
+ def _extractDecorators(self, func_node: Any) -> list[str]:
899
+ """Extract decorator names (excluding property)."""
900
+ decorates = []
901
+ for dec in func_node.decorator_list:
902
+ # Skip property decorator
903
+ if isinstance(dec, ast.Name) and dec.id == 'property':
904
+ continue
905
+ if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == 'property':
906
+ continue
907
+
908
+ # Get decorator name
909
+ name = self._getDecoratorName(dec)
910
+ if name:
911
+ decorates.append(name)
912
+ return decorates
913
+
914
+ def _getDecoratorName(self, decorator: Any) -> str | None:
915
+ """Get decorator name from AST node."""
916
+ if isinstance(decorator, ast.Name):
917
+ return decorator.id
918
+ elif isinstance(decorator, ast.Attribute):
919
+ return decorator.attr
920
+ elif isinstance(decorator, ast.Call):
921
+ if isinstance(decorator.func, ast.Name):
922
+ return decorator.func.id
923
+ elif isinstance(decorator.func, ast.Attribute):
924
+ return decorator.func.attr
925
+ return None
926
+
927
+ def toImage(self, width: int = 1200, height: int = 800) -> Any:
928
+ """
929
+ Render chart to a PNG image using qnode's PyQt6 components.
930
+
931
+ Uses off-screen rendering with NodeCanvas for high-quality output.
932
+
933
+ Args:
934
+ width: Image width in pixels
935
+ height: Image height in pixels
936
+
937
+ Returns:
938
+ PIL Image object
939
+ """
940
+
941
+ # Create QApplication if needed (for off-screen rendering)
942
+ app = QApplication.instance()
943
+ if app is None:
944
+ app = QApplication([])
945
+
946
+ # Create canvas in off-screen mode
947
+ canvas = QNodeCanvas()
948
+ canvas.setRenderHints(
949
+ QPainter.RenderHint.Antialiasing |
950
+ QPainter.RenderHint.SmoothPixmapTransform |
951
+ QPainter.RenderHint.TextAntialiasing
952
+ )
953
+
954
+ # Prepare export data
955
+ export_data = self.toJson()
956
+
957
+ # Map shape strings to NodeShape
958
+ shape_map = {
959
+ "ellipse": NodeShape.ELLIPSE,
960
+ "rectangle": NodeShape.RECTANGLE,
961
+ "diamond": NodeShape.DIAMOND
962
+ }
963
+
964
+ # Create node mapping
965
+ node_map = {}
966
+
967
+ # Add nodes with random initial positions (layout will fix them)
968
+ for node_data in export_data.get("nodes", []):
969
+ nid = node_data["id"]
970
+ name = node_data.get("name", "Unnamed")
971
+ shape_name = node_data.get("shape", "ELLIPSE").lower()
972
+ shape = shape_map.get(shape_name, NodeShape.ELLIPSE)
973
+
974
+ # Random initial position (will be adjusted by layout)
975
+ pos = QPointF(random.randint(-400, 400), random.randint(-300, 300))
976
+
977
+ node = canvas.addNode(name, pos, shape)
978
+ node_data_dict = node_data.get("data", {})
979
+ node.data.update(node_data_dict)
980
+
981
+ # 同步装饰器信息到节点的 decorator 属性
982
+ # 支持 data["decorates"] (来自 Stage/Logic 解析) 或 data["decorator"]
983
+ decorates = node_data_dict.get("decorates", [])
984
+ if decorates:
985
+ # 取第一个装饰器作为节点的主要装饰器
986
+ # 支持格式: ["recursive"] 或 [{"type": "recursive"}]
987
+ first_decorator = decorates[0]
988
+ if isinstance(first_decorator, dict):
989
+ node.decorator = first_decorator.get("type", "")
990
+ elif isinstance(first_decorator, str):
991
+ node.decorator = first_decorator
992
+ elif "decorator" in node_data_dict:
993
+ node.decorator = node_data_dict["decorator"]
994
+
995
+ # 同步 custom 标记配置
996
+ custom_data = node_data.get("custom", {})
997
+ if custom_data:
998
+
999
+ def parseColor(value):
1000
+ if isinstance(value, str):
1001
+ return QColor(value)
1002
+ return None
1003
+
1004
+ node.setCustomMarkerStyle(
1005
+ text=custom_data.get("text"),
1006
+ bg_color=parseColor(custom_data.get("bg_color")),
1007
+ fg_color=parseColor(custom_data.get("fg_color")),
1008
+ border_color=parseColor(custom_data.get("border_color")),
1009
+ border_width=custom_data.get("border_width"),
1010
+ radius=custom_data.get("radius")
1011
+ )
1012
+
1013
+ node_map[nid] = node
1014
+
1015
+ # Add connections
1016
+ for conn_data in export_data.get("connections", []):
1017
+ src_id = conn_data.get("source_id")
1018
+ dst_id = conn_data.get("target_id")
1019
+
1020
+ if src_id in node_map and dst_id in node_map:
1021
+ is_bidir = conn_data.get("is_bidirectional", False)
1022
+ conn_type = ConnectionType.BEZIER # Always use bezier for nice curves
1023
+
1024
+ conn = canvas.addConnection(
1025
+ node_map[src_id], node_map[dst_id],
1026
+ is_bidirectional=is_bidir,
1027
+ connection_type=conn_type
1028
+ )
1029
+ # Set label if available
1030
+ label = conn_data.get("label", "")
1031
+ if label and hasattr(conn, '_label_item'):
1032
+ conn._label_item.setPlainText(label)
1033
+
1034
+ # Apply auto layout
1035
+ if len(node_map) > 0:
1036
+ engine = DenseLayoutEngine(canvas)
1037
+ engine.autoLayout("compact")
1038
+
1039
+ # Fit to viewport with padding
1040
+ fitter = ViewportFitter(canvas)
1041
+ fitter.fitViewport(margin=50)
1042
+
1043
+ # Get scene bounding rect after layout
1044
+ scene_rect = canvas._scene.itemsBoundingRect()
1045
+ scene_rect = scene_rect.adjusted(-40, -40, 40, 40) # Add padding
1046
+
1047
+ # Calculate scale to fit scene into output image while maintaining aspect ratio
1048
+ scale_x = width / scene_rect.width()
1049
+ scale_y = height / scene_rect.height()
1050
+ scale = min(scale_x, scale_y) * 0.9 # 0.9 factor for some margin
1051
+
1052
+ # Calculate the rendered scene size
1053
+ render_width = scene_rect.width() * scale
1054
+ render_height = scene_rect.height() * scale
1055
+
1056
+ # Center the rendered scene in the output image
1057
+ offset_x = (width - render_width) / 2
1058
+ offset_y = (height - render_height) / 2
1059
+
1060
+ # Create target rect (centered in output image)
1061
+ target_rect = QRectF(offset_x, offset_y, render_width, render_height)
1062
+
1063
+ # Create pixmap for rendering
1064
+ pixmap = QPixmap(width, height)
1065
+ pixmap.fill(QColor(248, 250, 252)) # Light background
1066
+
1067
+ # Render scene to pixmap
1068
+ painter = QPainter(pixmap)
1069
+ painter.setRenderHints(
1070
+ QPainter.RenderHint.Antialiasing |
1071
+ QPainter.RenderHint.SmoothPixmapTransform |
1072
+ QPainter.RenderHint.TextAntialiasing
1073
+ )
1074
+
1075
+ # Render scene centered in the output image
1076
+ canvas._scene.render(
1077
+ painter,
1078
+ target_rect,
1079
+ scene_rect,
1080
+ Qt.AspectRatioMode.KeepAspectRatio
1081
+ )
1082
+ painter.end()
1083
+
1084
+ # Convert QPixmap to PIL Image
1085
+ # Save to temp file (QPixmap doesn't support BytesIO directly in PyQt6)
1086
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
1087
+ tmp_path = tmp.name
1088
+
1089
+ pixmap.save(tmp_path, "PNG")
1090
+
1091
+ # Open with PIL and copy to memory (to allow temp file deletion on Windows)
1092
+ with Image.open(tmp_path) as img_temp:
1093
+ img = img_temp.copy()
1094
+
1095
+ # Clean up temp file
1096
+ try:
1097
+ os.unlink(tmp_path)
1098
+ except PermissionError:
1099
+ pass # File may still be locked on Windows
1100
+
1101
+ return img