retroflow 0.8.2__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.
- retroflow/__init__.py +48 -0
- retroflow/generator.py +1803 -0
- retroflow/layout.py +239 -0
- retroflow/parser.py +99 -0
- retroflow/py.typed +0 -0
- retroflow/renderer.py +486 -0
- retroflow/router.py +343 -0
- retroflow-0.8.2.dist-info/METADATA +445 -0
- retroflow-0.8.2.dist-info/RECORD +11 -0
- retroflow-0.8.2.dist-info/WHEEL +4 -0
- retroflow-0.8.2.dist-info/licenses/LICENSE +21 -0
retroflow/layout.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layout module using networkx for hierarchical graph layout.
|
|
3
|
+
|
|
4
|
+
Uses networkx for:
|
|
5
|
+
- Graph representation
|
|
6
|
+
- Cycle detection
|
|
7
|
+
- Topological sorting / layer assignment
|
|
8
|
+
- Node ordering within layers
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List, Set, Tuple
|
|
13
|
+
|
|
14
|
+
import networkx as nx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class NodeLayout:
|
|
19
|
+
"""Represents a node's layout information."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
layer: int = 0
|
|
23
|
+
position: int = 0 # Position within layer
|
|
24
|
+
x: int = 0 # Character x coordinate
|
|
25
|
+
y: int = 0 # Character y coordinate
|
|
26
|
+
width: int = 0
|
|
27
|
+
height: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LayoutResult:
|
|
32
|
+
"""Result of the layout algorithm."""
|
|
33
|
+
|
|
34
|
+
nodes: Dict[str, NodeLayout] = field(default_factory=dict)
|
|
35
|
+
layers: List[List[str]] = field(default_factory=list)
|
|
36
|
+
edges: List[Tuple[str, str]] = field(default_factory=list)
|
|
37
|
+
back_edges: Set[Tuple[str, str]] = field(default_factory=set)
|
|
38
|
+
has_cycles: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NetworkXLayout:
|
|
42
|
+
"""
|
|
43
|
+
Graph layout using networkx.
|
|
44
|
+
|
|
45
|
+
For DAGs: uses topological_generations for layer assignment
|
|
46
|
+
For cyclic graphs: identifies back edges, breaks cycles, then layouts
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.graph: nx.DiGraph = None
|
|
51
|
+
self.back_edges: Set[Tuple[str, str]] = set()
|
|
52
|
+
|
|
53
|
+
def layout(self, connections: List[Tuple[str, str]]) -> LayoutResult:
|
|
54
|
+
"""
|
|
55
|
+
Compute layout for the given connections.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
connections: List of (source, target) tuples
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
LayoutResult with node positions and layer assignments
|
|
62
|
+
"""
|
|
63
|
+
# Build networkx graph
|
|
64
|
+
self.graph = nx.DiGraph()
|
|
65
|
+
self.graph.add_edges_from(connections)
|
|
66
|
+
|
|
67
|
+
# Check for cycles
|
|
68
|
+
has_cycles = not nx.is_directed_acyclic_graph(self.graph)
|
|
69
|
+
self.back_edges = set()
|
|
70
|
+
|
|
71
|
+
if has_cycles:
|
|
72
|
+
# Find and remove back edges to create a DAG
|
|
73
|
+
self._break_cycles()
|
|
74
|
+
|
|
75
|
+
# Assign layers using topological generations
|
|
76
|
+
layers = self._assign_layers()
|
|
77
|
+
|
|
78
|
+
# Order nodes within each layer to minimize crossings
|
|
79
|
+
layers = self._order_layers(layers)
|
|
80
|
+
|
|
81
|
+
# Build result
|
|
82
|
+
result = LayoutResult()
|
|
83
|
+
result.has_cycles = has_cycles
|
|
84
|
+
result.back_edges = self.back_edges
|
|
85
|
+
result.layers = layers
|
|
86
|
+
result.edges = list(connections)
|
|
87
|
+
|
|
88
|
+
# Create node layouts
|
|
89
|
+
for layer_idx, layer in enumerate(layers):
|
|
90
|
+
for pos_idx, node_name in enumerate(layer):
|
|
91
|
+
result.nodes[node_name] = NodeLayout(
|
|
92
|
+
name=node_name, layer=layer_idx, position=pos_idx
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
def _break_cycles(self) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Identify back edges and create a DAG by conceptually removing them.
|
|
100
|
+
Uses DFS to find back edges.
|
|
101
|
+
"""
|
|
102
|
+
# Find all cycles
|
|
103
|
+
try:
|
|
104
|
+
cycles = list(nx.simple_cycles(self.graph))
|
|
105
|
+
except Exception:
|
|
106
|
+
cycles = []
|
|
107
|
+
|
|
108
|
+
if not cycles:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# For each cycle, we need to identify one edge to "break"
|
|
112
|
+
# We'll use a DFS-based approach to find back edges
|
|
113
|
+
visited = set()
|
|
114
|
+
rec_stack = set()
|
|
115
|
+
|
|
116
|
+
def dfs(node):
|
|
117
|
+
visited.add(node)
|
|
118
|
+
rec_stack.add(node)
|
|
119
|
+
|
|
120
|
+
for successor in list(self.graph.successors(node)):
|
|
121
|
+
if successor not in visited:
|
|
122
|
+
dfs(successor)
|
|
123
|
+
elif successor in rec_stack:
|
|
124
|
+
# This is a back edge
|
|
125
|
+
self.back_edges.add((node, successor))
|
|
126
|
+
|
|
127
|
+
rec_stack.remove(node)
|
|
128
|
+
|
|
129
|
+
# Start DFS from nodes with no predecessors, or any node if all have them
|
|
130
|
+
roots = [n for n in self.graph.nodes() if self.graph.in_degree(n) == 0]
|
|
131
|
+
if not roots:
|
|
132
|
+
roots = [list(self.graph.nodes())[0]]
|
|
133
|
+
|
|
134
|
+
for root in roots:
|
|
135
|
+
if root not in visited:
|
|
136
|
+
dfs(root)
|
|
137
|
+
|
|
138
|
+
# Visit any remaining unvisited nodes
|
|
139
|
+
for node in self.graph.nodes():
|
|
140
|
+
if node not in visited:
|
|
141
|
+
dfs(node)
|
|
142
|
+
|
|
143
|
+
def _assign_layers(self) -> List[List[str]]:
|
|
144
|
+
"""
|
|
145
|
+
Assign nodes to layers using longest path method.
|
|
146
|
+
"""
|
|
147
|
+
# Create a working graph without back edges
|
|
148
|
+
working_graph = self.graph.copy()
|
|
149
|
+
working_graph.remove_edges_from(self.back_edges)
|
|
150
|
+
|
|
151
|
+
# Use longest path for layer assignment
|
|
152
|
+
# This places nodes as far down as possible
|
|
153
|
+
node_layer: Dict[str, int] = {}
|
|
154
|
+
|
|
155
|
+
# Process in topological order
|
|
156
|
+
try:
|
|
157
|
+
topo_order = list(nx.topological_sort(working_graph))
|
|
158
|
+
except nx.NetworkXUnfeasible:
|
|
159
|
+
# Still has cycles somehow, fall back to simple ordering
|
|
160
|
+
topo_order = list(working_graph.nodes())
|
|
161
|
+
|
|
162
|
+
for node in topo_order:
|
|
163
|
+
predecessors = list(working_graph.predecessors(node))
|
|
164
|
+
if not predecessors:
|
|
165
|
+
node_layer[node] = 0
|
|
166
|
+
else:
|
|
167
|
+
node_layer[node] = max(node_layer.get(p, 0) for p in predecessors) + 1
|
|
168
|
+
|
|
169
|
+
# Group by layer
|
|
170
|
+
if not node_layer:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
max_layer = max(node_layer.values())
|
|
174
|
+
layers: List[List[str]] = [[] for _ in range(max_layer + 1)]
|
|
175
|
+
|
|
176
|
+
for node, layer in node_layer.items():
|
|
177
|
+
layers[layer].append(node)
|
|
178
|
+
|
|
179
|
+
return layers
|
|
180
|
+
|
|
181
|
+
def _order_layers(self, layers: List[List[str]]) -> List[List[str]]:
|
|
182
|
+
"""
|
|
183
|
+
Order nodes within each layer to minimize edge crossings.
|
|
184
|
+
Uses barycenter heuristic.
|
|
185
|
+
"""
|
|
186
|
+
if len(layers) <= 1:
|
|
187
|
+
return layers
|
|
188
|
+
|
|
189
|
+
# Create working graph without back edges for ordering
|
|
190
|
+
working_graph = self.graph.copy()
|
|
191
|
+
working_graph.remove_edges_from(self.back_edges)
|
|
192
|
+
|
|
193
|
+
# Multiple passes of barycenter ordering
|
|
194
|
+
for _ in range(4):
|
|
195
|
+
# Forward pass
|
|
196
|
+
for i in range(1, len(layers)):
|
|
197
|
+
layers[i] = self._order_layer_by_barycenter(
|
|
198
|
+
layers[i], layers[i - 1], working_graph, use_predecessors=True
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Backward pass
|
|
202
|
+
for i in range(len(layers) - 2, -1, -1):
|
|
203
|
+
layers[i] = self._order_layer_by_barycenter(
|
|
204
|
+
layers[i], layers[i + 1], working_graph, use_predecessors=False
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return layers
|
|
208
|
+
|
|
209
|
+
def _order_layer_by_barycenter(
|
|
210
|
+
self,
|
|
211
|
+
layer: List[str],
|
|
212
|
+
ref_layer: List[str],
|
|
213
|
+
graph: nx.DiGraph,
|
|
214
|
+
use_predecessors: bool,
|
|
215
|
+
) -> List[str]:
|
|
216
|
+
"""
|
|
217
|
+
Order nodes by barycenter (average position of connected nodes).
|
|
218
|
+
"""
|
|
219
|
+
ref_positions = {node: i for i, node in enumerate(ref_layer)}
|
|
220
|
+
|
|
221
|
+
def barycenter(node: str) -> float:
|
|
222
|
+
if use_predecessors:
|
|
223
|
+
neighbors = list(graph.predecessors(node))
|
|
224
|
+
else:
|
|
225
|
+
neighbors = list(graph.successors(node))
|
|
226
|
+
|
|
227
|
+
positions = [ref_positions[n] for n in neighbors if n in ref_positions]
|
|
228
|
+
|
|
229
|
+
if not positions:
|
|
230
|
+
# Keep original order for nodes with no connections to ref layer
|
|
231
|
+
return layer.index(node) if node in layer else 0
|
|
232
|
+
|
|
233
|
+
return sum(positions) / len(positions)
|
|
234
|
+
|
|
235
|
+
return sorted(layer, key=barycenter)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Backward compatibility alias
|
|
239
|
+
SugiyamaLayout = NetworkXLayout
|
retroflow/parser.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parser module for flowchart generator.
|
|
3
|
+
|
|
4
|
+
Handles parsing of input text into graph connections.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParseError(Exception):
|
|
11
|
+
"""Raised when input parsing fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Parser:
|
|
17
|
+
"""Parses flowchart input text into connections."""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.connections = []
|
|
21
|
+
|
|
22
|
+
def parse(self, input_text: str) -> List[Tuple[str, str]]:
|
|
23
|
+
"""
|
|
24
|
+
Parse input text and return list of (source, target) connections.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
input_text: Multi-line string with connections in format "A -> B"
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of (source, target) tuples
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ParseError: If input format is invalid
|
|
34
|
+
"""
|
|
35
|
+
connections = []
|
|
36
|
+
|
|
37
|
+
for line_num, line in enumerate(input_text.strip().split("\n"), 1):
|
|
38
|
+
line = line.strip()
|
|
39
|
+
|
|
40
|
+
# Skip empty lines and comments
|
|
41
|
+
if not line or line.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
# Check for arrow
|
|
45
|
+
if "->" not in line:
|
|
46
|
+
raise ParseError(
|
|
47
|
+
f"Line {line_num}: Expected '->' in connection: {line}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Split on arrow
|
|
51
|
+
parts = line.split("->")
|
|
52
|
+
if len(parts) != 2:
|
|
53
|
+
raise ParseError(f"Line {line_num}: Invalid connection format: {line}")
|
|
54
|
+
|
|
55
|
+
source = parts[0].strip()
|
|
56
|
+
target = parts[1].strip()
|
|
57
|
+
|
|
58
|
+
# Validate node names
|
|
59
|
+
if not source:
|
|
60
|
+
raise ParseError(f"Line {line_num}: Empty source node")
|
|
61
|
+
if not target:
|
|
62
|
+
raise ParseError(f"Line {line_num}: Empty target node")
|
|
63
|
+
|
|
64
|
+
connections.append((source, target))
|
|
65
|
+
|
|
66
|
+
if not connections:
|
|
67
|
+
raise ParseError("No valid connections found in input")
|
|
68
|
+
|
|
69
|
+
return connections
|
|
70
|
+
|
|
71
|
+
def get_all_nodes(self, connections: List[Tuple[str, str]]) -> List[str]:
|
|
72
|
+
"""
|
|
73
|
+
Extract all unique nodes from connections.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
connections: List of (source, target) tuples
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Sorted list of unique node names
|
|
80
|
+
"""
|
|
81
|
+
nodes = set()
|
|
82
|
+
for source, target in connections:
|
|
83
|
+
nodes.add(source)
|
|
84
|
+
nodes.add(target)
|
|
85
|
+
return sorted(nodes)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_flowchart(input_text: str) -> List[Tuple[str, str]]:
|
|
89
|
+
"""
|
|
90
|
+
Convenience function to parse flowchart input.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
input_text: Multi-line string with connections
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of (source, target) tuples
|
|
97
|
+
"""
|
|
98
|
+
parser = Parser()
|
|
99
|
+
return parser.parse(input_text)
|
retroflow/py.typed
ADDED
|
File without changes
|