frameplot 0.4.0__tar.gz → 0.5.1__tar.gz
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.
- {frameplot-0.4.0/src/frameplot.egg-info → frameplot-0.5.1}/PKG-INFO +36 -8
- {frameplot-0.4.0 → frameplot-0.5.1}/README.md +35 -7
- {frameplot-0.4.0 → frameplot-0.5.1}/pyproject.toml +1 -1
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/__init__.py +81 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/order.py +11 -8
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/place.py +6 -4
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/rank.py +1 -1
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/route.py +667 -28
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/scc.py +1 -1
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/types.py +18 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/validate.py +42 -5
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/model.py +10 -3
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/svg.py +92 -2
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/theme.py +35 -4
- {frameplot-0.4.0 → frameplot-0.5.1/src/frameplot.egg-info}/PKG-INFO +36 -8
- {frameplot-0.4.0 → frameplot-0.5.1}/tests/test_rendering.py +278 -12
- {frameplot-0.4.0 → frameplot-0.5.1}/LICENSE +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/setup.cfg +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/__init__.py +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/api.py +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/text.py +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/__init__.py +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/png.py +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/SOURCES.txt +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/dependency_links.txt +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/requires.txt +0 -0
- {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: frameplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
|
|
5
5
|
Author: Small Turtle 2
|
|
6
6
|
License-Expression: MIT
|
|
@@ -38,7 +38,7 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
38
38
|
|
|
39
39
|
[한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
|
|
40
40
|
|
|
41
|
-

|
|
41
|
+

|
|
42
42
|
|
|
43
43
|
`frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or high-resolution PNG for slides and papers.
|
|
44
44
|
|
|
@@ -46,9 +46,13 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
46
46
|
|
|
47
47
|
All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
|
|
48
48
|
|
|
49
|
-
| Retro |
|
|
50
|
-
| --- | --- |
|
|
51
|
-
|  |  |  |
|
|
52
|
+
|
|
53
|
+
| Pastel | Dark |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
|  |  |
|
|
52
56
|
|
|
53
57
|
| Cyberpunk | Monochrome |
|
|
54
58
|
| --- | --- |
|
|
@@ -101,17 +105,41 @@ pipeline.save_png("pipeline.png")
|
|
|
101
105
|
|
|
102
106
|

|
|
103
107
|
|
|
108
|
+
## Edge-to-Edge Joins
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from frameplot import Edge, Node, Pipeline
|
|
112
|
+
|
|
113
|
+
pipeline = Pipeline(
|
|
114
|
+
nodes=[
|
|
115
|
+
Node("source", "Source", "Primary request"),
|
|
116
|
+
Node("worker", "Worker", "Prepare response"),
|
|
117
|
+
Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
|
|
118
|
+
Node("done", "Done", "Return result", fill="#D9EAD3"),
|
|
119
|
+
],
|
|
120
|
+
edges=[
|
|
121
|
+
Edge("e1", "source", "worker"),
|
|
122
|
+
Edge("e2", "worker", "done"),
|
|
123
|
+
Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
104
130
|
## Public API
|
|
105
131
|
|
|
106
132
|
Top-level imports are the supported public API:
|
|
107
133
|
|
|
108
134
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
109
|
-
- `Edge(id, source, target, color=None, dashed=False, metadata=None)`
|
|
135
|
+
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
110
136
|
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
111
137
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
112
138
|
- `Theme(...)`
|
|
113
139
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
114
140
|
|
|
141
|
+
`Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
|
|
142
|
+
|
|
115
143
|
`Pipeline` exposes:
|
|
116
144
|
|
|
117
145
|
- `to_svg() -> str`
|
|
@@ -125,12 +153,12 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
125
153
|
|
|
126
154
|
- **Complex Routing**: Seamlessly connecting AWS (S3/Lambda) to GCP (Pub/Sub/Dataflow) services.
|
|
127
155
|
- **Contextual Details**: Using a `DetailPanel` to explain the internal Spark Job Pipeline of the "Dataflow" node.
|
|
128
|
-
- **Retro
|
|
156
|
+
- **Soft Retro Styling**: Applying the built-in `Theme.soft_retro()` preset on a white canvas.
|
|
129
157
|
|
|
130
158
|
## Design Notes
|
|
131
159
|
|
|
132
160
|
- Layout is intentionally left-to-right in v0.x.
|
|
133
|
-
- Edge labels are not supported yet.
|
|
161
|
+
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
134
162
|
- Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
|
|
135
163
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
136
164
|
|
|
@@ -9,7 +9,7 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
9
9
|
|
|
10
10
|
[한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
|
|
11
11
|
|
|
12
|
-

|
|
12
|
+

|
|
13
13
|
|
|
14
14
|
`frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or high-resolution PNG for slides and papers.
|
|
15
15
|
|
|
@@ -17,9 +17,13 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
17
17
|
|
|
18
18
|
All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
|
|
19
19
|
|
|
20
|
-
| Retro |
|
|
21
|
-
| --- | --- |
|
|
22
|
-
|  |  |  |
|
|
23
|
+
|
|
24
|
+
| Pastel | Dark |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
|  |  |
|
|
23
27
|
|
|
24
28
|
| Cyberpunk | Monochrome |
|
|
25
29
|
| --- | --- |
|
|
@@ -72,17 +76,41 @@ pipeline.save_png("pipeline.png")
|
|
|
72
76
|
|
|
73
77
|

|
|
74
78
|
|
|
79
|
+
## Edge-to-Edge Joins
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from frameplot import Edge, Node, Pipeline
|
|
83
|
+
|
|
84
|
+
pipeline = Pipeline(
|
|
85
|
+
nodes=[
|
|
86
|
+
Node("source", "Source", "Primary request"),
|
|
87
|
+
Node("worker", "Worker", "Prepare response"),
|
|
88
|
+
Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
|
|
89
|
+
Node("done", "Done", "Return result", fill="#D9EAD3"),
|
|
90
|
+
],
|
|
91
|
+
edges=[
|
|
92
|
+
Edge("e1", "source", "worker"),
|
|
93
|
+
Edge("e2", "worker", "done"),
|
|
94
|
+
Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
75
101
|
## Public API
|
|
76
102
|
|
|
77
103
|
Top-level imports are the supported public API:
|
|
78
104
|
|
|
79
105
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
80
|
-
- `Edge(id, source, target, color=None, dashed=False, metadata=None)`
|
|
106
|
+
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
81
107
|
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
82
108
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
83
109
|
- `Theme(...)`
|
|
84
110
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
85
111
|
|
|
112
|
+
`Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
|
|
113
|
+
|
|
86
114
|
`Pipeline` exposes:
|
|
87
115
|
|
|
88
116
|
- `to_svg() -> str`
|
|
@@ -96,12 +124,12 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
96
124
|
|
|
97
125
|
- **Complex Routing**: Seamlessly connecting AWS (S3/Lambda) to GCP (Pub/Sub/Dataflow) services.
|
|
98
126
|
- **Contextual Details**: Using a `DetailPanel` to explain the internal Spark Job Pipeline of the "Dataflow" node.
|
|
99
|
-
- **Retro
|
|
127
|
+
- **Soft Retro Styling**: Applying the built-in `Theme.soft_retro()` preset on a white canvas.
|
|
100
128
|
|
|
101
129
|
## Design Notes
|
|
102
130
|
|
|
103
131
|
- Layout is intentionally left-to-right in v0.x.
|
|
104
|
-
- Edge labels are not supported yet.
|
|
132
|
+
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
105
133
|
- Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
|
|
106
134
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
107
135
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "frameplot"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.1"
|
|
8
8
|
description = "Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -139,6 +139,13 @@ def _rank_gap_overrides(
|
|
|
139
139
|
).items():
|
|
140
140
|
required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
|
|
141
141
|
|
|
142
|
+
for key, required_gap in _join_target_gap_requirements(
|
|
143
|
+
validated,
|
|
144
|
+
placed_nodes,
|
|
145
|
+
routed_edges,
|
|
146
|
+
).items():
|
|
147
|
+
required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
|
|
148
|
+
|
|
142
149
|
for key, required_gap in required_gaps.items():
|
|
143
150
|
if required_gap > base_gap + EPSILON:
|
|
144
151
|
overrides[key] = round(required_gap, 2)
|
|
@@ -146,6 +153,64 @@ def _rank_gap_overrides(
|
|
|
146
153
|
return overrides
|
|
147
154
|
|
|
148
155
|
|
|
156
|
+
def _join_target_gap_requirements(
|
|
157
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
158
|
+
placed_nodes: dict[str, LayoutNode],
|
|
159
|
+
routed_edges: tuple[RoutedEdge, ...],
|
|
160
|
+
) -> dict[tuple[int, int], float]:
|
|
161
|
+
route_by_id = {route.edge.id: route for route in routed_edges}
|
|
162
|
+
joins_by_target_segment: dict[tuple[str, int], list[RoutedEdge]] = {}
|
|
163
|
+
|
|
164
|
+
for routed_edge in routed_edges:
|
|
165
|
+
if routed_edge.target_kind != "edge" or routed_edge.target_edge_id is None:
|
|
166
|
+
continue
|
|
167
|
+
if routed_edge.join_segment_index is None:
|
|
168
|
+
continue
|
|
169
|
+
joins_by_target_segment.setdefault(
|
|
170
|
+
(routed_edge.target_edge_id, routed_edge.join_segment_index),
|
|
171
|
+
[],
|
|
172
|
+
).append(routed_edge)
|
|
173
|
+
|
|
174
|
+
required_gaps: dict[tuple[int, int], float] = {}
|
|
175
|
+
badge_diameter = max(validated.theme.arrow_size * 1.8, validated.theme.stroke_width * 6.0)
|
|
176
|
+
join_spacing = max(validated.theme.arrow_size * 1.5, validated.theme.stroke_width * 6.0)
|
|
177
|
+
|
|
178
|
+
for (target_edge_id, join_segment_index), joins in joins_by_target_segment.items():
|
|
179
|
+
target_edge = validated.edge_lookup[target_edge_id]
|
|
180
|
+
target_target = validated.edge_targets[target_edge_id]
|
|
181
|
+
source_node = placed_nodes[target_edge.source]
|
|
182
|
+
target_node = placed_nodes[target_target.node_id]
|
|
183
|
+
target_route = route_by_id.get(target_edge_id)
|
|
184
|
+
if target_route is None:
|
|
185
|
+
continue
|
|
186
|
+
if source_node.component_id != target_node.component_id:
|
|
187
|
+
continue
|
|
188
|
+
if source_node.rank >= target_node.rank or source_node.order != target_node.order:
|
|
189
|
+
continue
|
|
190
|
+
if join_segment_index >= len(target_route.points) - 1:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
start = target_route.points[join_segment_index]
|
|
194
|
+
end = target_route.points[join_segment_index + 1]
|
|
195
|
+
if start.y != end.y:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
current_segment_length = abs(end.x - start.x)
|
|
199
|
+
required_segment_length = (
|
|
200
|
+
validated.theme.route_track_gap
|
|
201
|
+
+ badge_diameter * len(joins)
|
|
202
|
+
+ join_spacing * max(0, len(joins) - 1)
|
|
203
|
+
)
|
|
204
|
+
if current_segment_length >= required_segment_length - EPSILON:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
required_gap = validated.theme.route_track_gap + (required_segment_length - current_segment_length)
|
|
208
|
+
key = (source_node.component_id, source_node.rank)
|
|
209
|
+
required_gaps[key] = max(required_gaps.get(key, 0.0), round(required_gap, 2))
|
|
210
|
+
|
|
211
|
+
return required_gaps
|
|
212
|
+
|
|
213
|
+
|
|
149
214
|
def _group_boundary_gap_requirements(
|
|
150
215
|
placed_nodes: dict[str, LayoutNode],
|
|
151
216
|
overlays: tuple[GroupOverlay, ...],
|
|
@@ -506,6 +571,22 @@ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge
|
|
|
506
571
|
height=route.bounds.height,
|
|
507
572
|
),
|
|
508
573
|
stroke=route.stroke,
|
|
574
|
+
target_kind=route.target_kind,
|
|
575
|
+
target_node_id=route.target_node_id,
|
|
576
|
+
target_edge_id=route.target_edge_id,
|
|
577
|
+
join_point=(
|
|
578
|
+
Point(route.join_point.x + shift_x, route.join_point.y + shift_y)
|
|
579
|
+
if route.join_point is not None
|
|
580
|
+
else None
|
|
581
|
+
),
|
|
582
|
+
badge_center=(
|
|
583
|
+
Point(route.badge_center.x + shift_x, route.badge_center.y + shift_y)
|
|
584
|
+
if route.badge_center is not None
|
|
585
|
+
else None
|
|
586
|
+
),
|
|
587
|
+
join_segment_index=route.join_segment_index,
|
|
588
|
+
show_arrowhead=route.show_arrowhead,
|
|
589
|
+
join_badge_radius=route.join_badge_radius,
|
|
509
590
|
)
|
|
510
591
|
|
|
511
592
|
|
|
@@ -47,9 +47,10 @@ def _forward_neighbors(
|
|
|
47
47
|
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
48
48
|
|
|
49
49
|
for edge in validated.edges:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
51
|
+
if ranks[edge.source] < ranks[target_node_id]:
|
|
52
|
+
incoming[target_node_id].append(edge.source)
|
|
53
|
+
outgoing[edge.source].append(target_node_id)
|
|
53
54
|
|
|
54
55
|
return incoming, outgoing
|
|
55
56
|
|
|
@@ -120,10 +121,11 @@ def _detail_focus_path_nodes(
|
|
|
120
121
|
forward_incoming: dict[str, list[str]] = defaultdict(list)
|
|
121
122
|
|
|
122
123
|
for edge in validated.edges:
|
|
123
|
-
|
|
124
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
125
|
+
if edge.dashed or ranks[edge.source] >= ranks[target_node_id]:
|
|
124
126
|
continue
|
|
125
|
-
forward_outgoing[edge.source].append(
|
|
126
|
-
forward_incoming[
|
|
127
|
+
forward_outgoing[edge.source].append(target_node_id)
|
|
128
|
+
forward_incoming[target_node_id].append(edge.source)
|
|
127
129
|
|
|
128
130
|
ancestors = _reachable(focus_node_id, forward_incoming)
|
|
129
131
|
descendants = _reachable(focus_node_id, forward_outgoing)
|
|
@@ -152,8 +154,9 @@ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
|
|
|
152
154
|
def _weak_component_nodes(validated: ValidatedPipeline, start_node_id: str) -> set[str]:
|
|
153
155
|
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
154
156
|
for edge in validated.edges:
|
|
155
|
-
|
|
156
|
-
adjacency[edge.
|
|
157
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
158
|
+
adjacency[edge.source].add(target_node_id)
|
|
159
|
+
adjacency[target_node_id].add(edge.source)
|
|
157
160
|
|
|
158
161
|
seen = {start_node_id}
|
|
159
162
|
queue = deque([start_node_id])
|
|
@@ -101,8 +101,9 @@ def place_nodes(
|
|
|
101
101
|
def _weak_components(validated: "ValidatedPipeline | ValidatedDetailPanel") -> list[tuple[str, ...]]:
|
|
102
102
|
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
103
103
|
for edge in validated.edges:
|
|
104
|
-
|
|
105
|
-
adjacency[edge.
|
|
104
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
105
|
+
adjacency[edge.source].add(target_node_id)
|
|
106
|
+
adjacency[target_node_id].add(edge.source)
|
|
106
107
|
|
|
107
108
|
components: list[tuple[str, ...]] = []
|
|
108
109
|
seen: set[str] = set()
|
|
@@ -148,10 +149,11 @@ def _row_gap_after(
|
|
|
148
149
|
row_padding[row] = max((node_padding.get(n, 0.0) for n in nodes_in_row), default=0.0)
|
|
149
150
|
|
|
150
151
|
for edge in validated.edges:
|
|
151
|
-
|
|
152
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
153
|
+
if edge.source not in component_set or target_node_id not in component_set:
|
|
152
154
|
continue
|
|
153
155
|
source_row = rows[edge.source]
|
|
154
|
-
target_row = rows[
|
|
156
|
+
target_row = rows[target_node_id]
|
|
155
157
|
if source_row == target_row:
|
|
156
158
|
continue
|
|
157
159
|
for row in range(min(source_row, target_row), max(source_row, target_row)):
|
|
@@ -14,7 +14,7 @@ def assign_ranks(validated: ValidatedPipeline, scc_result: SccResult) -> dict[st
|
|
|
14
14
|
|
|
15
15
|
for edge in validated.edges:
|
|
16
16
|
source_component = scc_result.node_to_component[edge.source]
|
|
17
|
-
target_component = scc_result.node_to_component[edge.
|
|
17
|
+
target_component = scc_result.node_to_component[validated.edge_targets[edge.id].node_id]
|
|
18
18
|
if source_component == target_component:
|
|
19
19
|
continue
|
|
20
20
|
predecessors[target_component].add(source_component)
|