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/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