graphable 0.2.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.
- graphable/__init__.py +0 -0
- graphable/graph.py +342 -0
- graphable/graphable.py +125 -0
- graphable/py.typed +0 -0
- graphable/views/__init__.py +0 -0
- graphable/views/graphviz.py +148 -0
- graphable/views/mermaid.py +241 -0
- graphable/views/texttree.py +121 -0
- graphable-0.2.0.dist-info/METADATA +104 -0
- graphable-0.2.0.dist-info/RECORD +11 -0
- graphable-0.2.0.dist-info/WHEEL +4 -0
graphable/__init__.py
ADDED
|
File without changes
|
graphable/graph.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from graphlib import CycleError, TopologicalSorter
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .graphable import Graphable
|
|
9
|
+
|
|
10
|
+
logger = getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GraphCycleError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
Exception raised when a cycle is detected in the graph.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, cycle: list[Any] | None = None):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.cycle = cycle
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GraphConsistencyError(Exception):
|
|
24
|
+
"""
|
|
25
|
+
Exception raised when the bi-directional relationships in the graph are inconsistent.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def graph[T: Graphable[Any, Any]](contains: list[T]) -> Graph[T]:
|
|
32
|
+
"""
|
|
33
|
+
Constructs a Graph containing the given nodes and all their connected dependencies/dependents.
|
|
34
|
+
It traverses the graph both up (dependencies) and down (dependents) from the initial nodes.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
contains (list[T]): A list of initial nodes to start the graph construction from.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Graph[T]: A Graph object containing all reachable nodes.
|
|
41
|
+
"""
|
|
42
|
+
logger.debug(f"Building graph from {len(contains)} initial nodes.")
|
|
43
|
+
|
|
44
|
+
def go_down(node: T, nodes: set[T]) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Recursively traverse down the graph to find all dependent nodes.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
node (T): The current node to traverse from.
|
|
50
|
+
nodes (set[T]): The set of nodes found so far.
|
|
51
|
+
"""
|
|
52
|
+
for down_node in node.dependents:
|
|
53
|
+
if down_node in nodes:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
nodes.add(down_node)
|
|
57
|
+
go_down(down_node, nodes)
|
|
58
|
+
|
|
59
|
+
def go_up(node: T, nodes: set[T]) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Recursively traverse up the graph to find all dependency nodes.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
node (T): The current node to traverse from.
|
|
65
|
+
nodes (set[T]): The set of nodes found so far.
|
|
66
|
+
"""
|
|
67
|
+
for up_node in node.depends_on:
|
|
68
|
+
if up_node in nodes:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
nodes.add(up_node)
|
|
72
|
+
go_up(up_node, nodes)
|
|
73
|
+
|
|
74
|
+
nodes: set[T] = set(contains)
|
|
75
|
+
for node in contains:
|
|
76
|
+
go_up(node, nodes)
|
|
77
|
+
go_down(node, nodes)
|
|
78
|
+
|
|
79
|
+
logger.info(f"Graph built with {len(nodes)} total nodes.")
|
|
80
|
+
return Graph(nodes)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Graph[T: Graphable[Any, Any]]:
|
|
84
|
+
"""
|
|
85
|
+
Represents a graph of Graphable nodes.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, initial: set[T] | None = None):
|
|
89
|
+
"""
|
|
90
|
+
Initialize a Graph.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
initial (set[T] | None): An optional set of initial nodes.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
GraphCycleError: If the initial set of nodes contains a cycle.
|
|
97
|
+
"""
|
|
98
|
+
self._nodes: set[T] = initial if initial else set[T]()
|
|
99
|
+
self._topological_order: list[T] | None = None
|
|
100
|
+
|
|
101
|
+
if self._nodes:
|
|
102
|
+
self.check_consistency()
|
|
103
|
+
self.check_cycles()
|
|
104
|
+
|
|
105
|
+
def _find_path(self, start: T, target: T) -> list[T] | None:
|
|
106
|
+
"""
|
|
107
|
+
Find a path from start to target using BFS.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
start (T): The starting node.
|
|
111
|
+
target (T): The target node.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
list[T] | None: The shortest path as a list of nodes, or None if no path exists.
|
|
115
|
+
"""
|
|
116
|
+
# BFS find shortest path
|
|
117
|
+
queue: deque[tuple[T, list[T]]] = deque()
|
|
118
|
+
for neighbor in start.dependents:
|
|
119
|
+
queue.append((neighbor, [start, neighbor]))
|
|
120
|
+
|
|
121
|
+
# We don't mark 'start' as visited in the set immediately if we want to find a cycle
|
|
122
|
+
# that returns to 'start'. However, if start == target, the above loop already handles it.
|
|
123
|
+
# Actually, the most robust way is to mark everything we pop as visited.
|
|
124
|
+
visited: set[T] = set()
|
|
125
|
+
while queue:
|
|
126
|
+
current, path = queue.popleft()
|
|
127
|
+
if current == target:
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
if current in visited:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
visited.add(current)
|
|
134
|
+
for neighbor in current.dependents:
|
|
135
|
+
if neighbor not in visited:
|
|
136
|
+
queue.append((neighbor, path + [neighbor]))
|
|
137
|
+
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def check_cycles(self) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Check for cycles in the graph.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
GraphCycleError: If a cycle is detected.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
sorter = TopologicalSorter({node: node.depends_on for node in self._nodes})
|
|
149
|
+
sorter.prepare()
|
|
150
|
+
except CycleError as e:
|
|
151
|
+
# graphlib.CycleError args: (message, cycle_tuple)
|
|
152
|
+
cycle = list(e.args[1]) if len(e.args) > 1 else None
|
|
153
|
+
raise GraphCycleError(f"Cycle detected in graph: {e}", cycle=cycle) from e
|
|
154
|
+
|
|
155
|
+
def check_consistency(self) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Check for consistency between depends_on and dependents for all nodes in the graph.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
GraphConsistencyError: If an inconsistency is detected.
|
|
161
|
+
"""
|
|
162
|
+
for node in self._nodes:
|
|
163
|
+
self._check_node_consistency(node)
|
|
164
|
+
|
|
165
|
+
def _check_node_consistency(self, node: T) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Check for consistency between depends_on and dependents for a single node.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
node (T): The node to check.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
GraphConsistencyError: If an inconsistency is detected.
|
|
174
|
+
"""
|
|
175
|
+
# Check dependencies: if node depends on X, X must have node as dependent
|
|
176
|
+
for dep in node.depends_on:
|
|
177
|
+
if node not in dep.dependents:
|
|
178
|
+
raise GraphConsistencyError(
|
|
179
|
+
f"Inconsistency: Node '{node.reference}' depends on '{dep.reference}', "
|
|
180
|
+
f"but '{dep.reference}' does not list '{node.reference}' as a dependent."
|
|
181
|
+
)
|
|
182
|
+
# Check dependents: if node has dependent Y, Y must depend on node
|
|
183
|
+
for sub in node.dependents:
|
|
184
|
+
if node not in sub.depends_on:
|
|
185
|
+
raise GraphConsistencyError(
|
|
186
|
+
f"Inconsistency: Node '{node.reference}' has dependent '{sub.reference}', "
|
|
187
|
+
f"but '{sub.reference}' does not depend on '{node.reference}'."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def add_edge(self, node: T, dependent: T) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Add a directed edge from node to dependent.
|
|
193
|
+
Also adds the nodes to the graph if they are not already present.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
node (T): The source node (dependency).
|
|
197
|
+
dependent (T): The target node (dependent).
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
GraphCycleError: If adding the edge would create a cycle.
|
|
201
|
+
"""
|
|
202
|
+
if node == dependent:
|
|
203
|
+
raise GraphCycleError(
|
|
204
|
+
f"Self-loop detected: node '{node.reference}' cannot depend on itself.",
|
|
205
|
+
cycle=[node, node],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Check if adding this edge creates a cycle.
|
|
209
|
+
# A cycle is created if there is already a path from 'dependent' to 'node'.
|
|
210
|
+
if path := self._find_path(dependent, node):
|
|
211
|
+
cycle = path + [dependent]
|
|
212
|
+
raise GraphCycleError(
|
|
213
|
+
f"Adding edge '{node.reference}' -> '{dependent.reference}' would create a cycle.",
|
|
214
|
+
cycle=cycle,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self.add_node(node)
|
|
218
|
+
self.add_node(dependent)
|
|
219
|
+
|
|
220
|
+
node._add_dependent(dependent)
|
|
221
|
+
dependent._add_depends_on(node)
|
|
222
|
+
logger.debug(f"Added edge: {node.reference} -> {dependent.reference}")
|
|
223
|
+
|
|
224
|
+
# Invalidate cache
|
|
225
|
+
if self._topological_order is not None:
|
|
226
|
+
self._topological_order = None
|
|
227
|
+
|
|
228
|
+
def add_node(self, node: T) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Add a node to the graph.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
node (T): The node to add.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
bool: True if the node was added (was not already present), False otherwise.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
GraphCycleError: If the node is part of an existing cycle.
|
|
240
|
+
"""
|
|
241
|
+
if node in self._nodes:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
# If the node is already part of a cycle (linked externally), adding it might be invalid
|
|
245
|
+
# if we want to enforce DAG.
|
|
246
|
+
if cycle := self._find_path(node, node):
|
|
247
|
+
raise GraphCycleError(
|
|
248
|
+
f"Node '{node.reference}' is part of an existing cycle.", cycle=cycle
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
self._check_node_consistency(node)
|
|
252
|
+
self._nodes.add(node)
|
|
253
|
+
logger.debug(f"Added node: {node.reference}")
|
|
254
|
+
|
|
255
|
+
if self._topological_order is not None:
|
|
256
|
+
self._topological_order = None
|
|
257
|
+
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def sinks(self) -> list[T]:
|
|
262
|
+
"""
|
|
263
|
+
Get all sink nodes (nodes with no dependents).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
list[T]: A list of sink nodes.
|
|
267
|
+
"""
|
|
268
|
+
return [node for node in self._nodes if 0 == len(node.dependents)]
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def sources(self) -> list[T]:
|
|
272
|
+
"""
|
|
273
|
+
Get all source nodes (nodes with no dependencies).
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
list[T]: A list of source nodes.
|
|
277
|
+
"""
|
|
278
|
+
return [node for node in self._nodes if 0 == len(node.depends_on)]
|
|
279
|
+
|
|
280
|
+
def subgraph_filtered(self, fn: Callable[[T], bool]) -> Graph[T]:
|
|
281
|
+
"""
|
|
282
|
+
Create a new subgraph containing only nodes that satisfy the predicate.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
fn (Callable[[T], bool]): The predicate function.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Graph[T]: A new Graph containing the filtered nodes.
|
|
289
|
+
"""
|
|
290
|
+
logger.debug("Creating filtered subgraph.")
|
|
291
|
+
return graph([node for node in self._nodes if fn(node)])
|
|
292
|
+
|
|
293
|
+
def subgraph_tagged(self, tag: str) -> Graph[T]:
|
|
294
|
+
"""
|
|
295
|
+
Create a new subgraph containing only nodes with the specified tag.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
tag (str): The tag to filter by.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Graph[T]: A new Graph containing the tagged nodes.
|
|
302
|
+
"""
|
|
303
|
+
logger.debug(f"Creating subgraph for tag: {tag}")
|
|
304
|
+
return graph([node for node in self._nodes if node.is_tagged(tag)])
|
|
305
|
+
|
|
306
|
+
def topological_order(self) -> list[T]:
|
|
307
|
+
"""
|
|
308
|
+
Get the nodes in topological order.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
list[T]: A list of nodes sorted topologically.
|
|
312
|
+
"""
|
|
313
|
+
if self._topological_order is None:
|
|
314
|
+
logger.debug("Calculating topological order.")
|
|
315
|
+
sorter = TopologicalSorter({node: node.depends_on for node in self._nodes})
|
|
316
|
+
self._topological_order = list(sorter.static_order())
|
|
317
|
+
|
|
318
|
+
return self._topological_order
|
|
319
|
+
|
|
320
|
+
def topological_order_filtered(self, fn: Callable[[T], bool]) -> list[T]:
|
|
321
|
+
"""
|
|
322
|
+
Get a filtered list of nodes in topological order.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
fn (Callable[[T], bool]): The predicate function.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
list[T]: Filtered topologically sorted nodes.
|
|
329
|
+
"""
|
|
330
|
+
return [node for node in self.topological_order() if fn(node)]
|
|
331
|
+
|
|
332
|
+
def topological_order_tagged(self, tag: str) -> list[T]:
|
|
333
|
+
"""
|
|
334
|
+
Get a list of nodes with a specific tag in topological order.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
tag (str): The tag to filter by.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
list[T]: Tagged topologically sorted nodes.
|
|
341
|
+
"""
|
|
342
|
+
return [node for node in self.topological_order() if node.is_tagged(tag)]
|
graphable/graphable.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
logger = getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Graphable[T, S: Graphable[T, S]]:
|
|
8
|
+
"""
|
|
9
|
+
A generic class representing a node in a graph that can track dependencies and dependents.
|
|
10
|
+
|
|
11
|
+
Type Parameters:
|
|
12
|
+
T: The type of the reference object this node holds.
|
|
13
|
+
S: The type of the Graphable subclass (recursive bound).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, reference: T):
|
|
17
|
+
"""
|
|
18
|
+
Initialize a Graphable node.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
reference (T): The underlying object this node represents.
|
|
22
|
+
"""
|
|
23
|
+
self._dependents: set[S] = set()
|
|
24
|
+
self._depends_on: set[S] = set()
|
|
25
|
+
self._reference: T = reference
|
|
26
|
+
self._tags: set[str] = set()
|
|
27
|
+
logger.debug(f"Created Graphable node for reference: {reference}")
|
|
28
|
+
|
|
29
|
+
def _add_dependent(self, dependent: S) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Internal method to add a dependent node (incoming edge in dependency graph).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
dependent (S): The node that depends on this node.
|
|
35
|
+
"""
|
|
36
|
+
if dependent not in self._dependents:
|
|
37
|
+
self._dependents.add(dependent)
|
|
38
|
+
logger.debug(
|
|
39
|
+
f"Node '{self.reference}': added dependent '{cast(Graphable, dependent).reference}'"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _add_depends_on(self, depends_on: S) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Internal method to add a dependency (outgoing edge in dependency graph).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
depends_on (S): The node that this node depends on.
|
|
48
|
+
"""
|
|
49
|
+
if depends_on not in self._depends_on:
|
|
50
|
+
self._depends_on.add(depends_on)
|
|
51
|
+
logger.debug(
|
|
52
|
+
f"Node '{self.reference}': added dependency '{cast(Graphable, depends_on).reference}'"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def add_tag(self, tag: str) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Add a tag to this node.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
tag (str): The tag to add.
|
|
61
|
+
"""
|
|
62
|
+
self._tags.add(tag)
|
|
63
|
+
logger.debug(f"Added tag '{tag}' to {self.reference}")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def dependents(self) -> set[S]:
|
|
67
|
+
"""
|
|
68
|
+
Get the set of nodes that depend on this node.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
set[S]: A copy of the dependents set.
|
|
72
|
+
"""
|
|
73
|
+
return set(self._dependents)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def depends_on(self) -> set[S]:
|
|
77
|
+
"""
|
|
78
|
+
Get the set of nodes that this node depends on.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
set[S]: A copy of the dependencies set.
|
|
82
|
+
"""
|
|
83
|
+
return set(self._depends_on)
|
|
84
|
+
|
|
85
|
+
def is_tagged(self, tag: str) -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Check if the node has a specific tag.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
tag (str): The tag to check.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if the tag exists, False otherwise.
|
|
94
|
+
"""
|
|
95
|
+
return tag in self._tags
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def reference(self) -> T:
|
|
99
|
+
"""
|
|
100
|
+
Get the underlying reference object.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
T: The reference object.
|
|
104
|
+
"""
|
|
105
|
+
return self._reference
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def tags(self) -> set[str]:
|
|
109
|
+
"""
|
|
110
|
+
Get the set of tags for this node.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
set[str]: A copy of the tags set.
|
|
114
|
+
"""
|
|
115
|
+
return set(self._tags)
|
|
116
|
+
|
|
117
|
+
def remove_tag(self, tag: str) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Remove a tag from this node.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
tag (str): The tag to remove.
|
|
123
|
+
"""
|
|
124
|
+
self._tags.discard(tag)
|
|
125
|
+
logger.debug(f"Removed tag '{tag}' from {self.reference}")
|
graphable/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import which
|
|
5
|
+
from subprocess import PIPE, CalledProcessError, run
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ..graph import Graph
|
|
9
|
+
from ..graphable import Graphable
|
|
10
|
+
|
|
11
|
+
logger = getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class GraphvizStylingConfig:
|
|
16
|
+
"""
|
|
17
|
+
Configuration for customizing Graphviz DOT generation.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
node_ref_fnc: Function to generate the node identifier (reference).
|
|
21
|
+
node_label_fnc: Function to generate the node label.
|
|
22
|
+
node_attr_fnc: Function to generate a dictionary of attributes for a node.
|
|
23
|
+
edge_attr_fnc: Function to generate a dictionary of attributes for an edge.
|
|
24
|
+
graph_attr: Dictionary of global graph attributes.
|
|
25
|
+
node_attr_default: Dictionary of default node attributes.
|
|
26
|
+
edge_attr_default: Dictionary of default edge attributes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
node_ref_fnc: Callable[[Graphable], str] = lambda n: str(n.reference)
|
|
30
|
+
node_label_fnc: Callable[[Graphable], str] = lambda n: str(n.reference)
|
|
31
|
+
node_attr_fnc: Callable[[Graphable], dict[str, str]] | None = None
|
|
32
|
+
edge_attr_fnc: Callable[[Graphable, Graphable], dict[str, str]] | None = None
|
|
33
|
+
graph_attr: dict[str, str] | None = None
|
|
34
|
+
node_attr_default: dict[str, str] | None = None
|
|
35
|
+
edge_attr_default: dict[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_dot_on_path() -> None:
|
|
39
|
+
"""Check if 'dot' executable is available in the system path."""
|
|
40
|
+
if which("dot") is None:
|
|
41
|
+
logger.error("dot not found on PATH.")
|
|
42
|
+
raise FileNotFoundError("dot (Graphviz) is required but not available on $PATH")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_attrs(attrs: dict[str, str] | None) -> str:
|
|
46
|
+
"""Format a dictionary of attributes into a DOT attribute string."""
|
|
47
|
+
if not attrs:
|
|
48
|
+
return ""
|
|
49
|
+
parts = [f'{k}="{v}"' for k, v in attrs.items()]
|
|
50
|
+
return f" [{', '.join(parts)}]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_topology_graphviz_dot(
|
|
54
|
+
graph: Graph, config: GraphvizStylingConfig | None = None
|
|
55
|
+
) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Generate Graphviz DOT definition from a Graph.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
graph (Graph): The graph to convert.
|
|
61
|
+
config (GraphvizStylingConfig | None): Styling configuration.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
str: The Graphviz DOT definition string.
|
|
65
|
+
"""
|
|
66
|
+
config = config or GraphvizStylingConfig()
|
|
67
|
+
dot: list[str] = ["digraph G {"]
|
|
68
|
+
|
|
69
|
+
# Global attributes
|
|
70
|
+
if config.graph_attr:
|
|
71
|
+
for k, v in config.graph_attr.items():
|
|
72
|
+
dot.append(f' {k}="{v}";')
|
|
73
|
+
|
|
74
|
+
if config.node_attr_default:
|
|
75
|
+
dot.append(f" node{_format_attrs(config.node_attr_default)};")
|
|
76
|
+
|
|
77
|
+
if config.edge_attr_default:
|
|
78
|
+
dot.append(f" edge{_format_attrs(config.edge_attr_default)};")
|
|
79
|
+
|
|
80
|
+
# Nodes and Edges
|
|
81
|
+
for node in graph.topological_order():
|
|
82
|
+
node_ref = config.node_ref_fnc(node)
|
|
83
|
+
node_attrs = {"label": config.node_label_fnc(node)}
|
|
84
|
+
if config.node_attr_fnc:
|
|
85
|
+
node_attrs.update(config.node_attr_fnc(node))
|
|
86
|
+
|
|
87
|
+
dot.append(f' "{node_ref}"{_format_attrs(node_attrs)};')
|
|
88
|
+
|
|
89
|
+
for dependent in node.dependents:
|
|
90
|
+
dep_ref = config.node_ref_fnc(dependent)
|
|
91
|
+
edge_attrs = {}
|
|
92
|
+
if config.edge_attr_fnc:
|
|
93
|
+
edge_attrs.update(config.edge_attr_fnc(node, dependent))
|
|
94
|
+
|
|
95
|
+
dot.append(f' "{node_ref}" -> "{dep_ref}"{_format_attrs(edge_attrs)};')
|
|
96
|
+
|
|
97
|
+
dot.append("}")
|
|
98
|
+
return "\n".join(dot)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def export_topology_graphviz_dot(
|
|
102
|
+
graph: Graph, output: Path, config: GraphvizStylingConfig | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Export the graph to a Graphviz .dot file.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
graph (Graph): The graph to export.
|
|
109
|
+
output (Path): The output file path.
|
|
110
|
+
config (GraphvizStylingConfig | None): Styling configuration.
|
|
111
|
+
"""
|
|
112
|
+
logger.info(f"Exporting graphviz dot to: {output}")
|
|
113
|
+
with open(output, "w+") as f:
|
|
114
|
+
f.write(create_topology_graphviz_dot(graph, config))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def export_topology_graphviz_svg(
|
|
118
|
+
graph: Graph, output: Path, config: GraphvizStylingConfig | None = None
|
|
119
|
+
) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Export the graph to an SVG file using the 'dot' command.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
graph (Graph): The graph to export.
|
|
125
|
+
output (Path): The output file path.
|
|
126
|
+
config (GraphvizStylingConfig | None): Styling configuration.
|
|
127
|
+
"""
|
|
128
|
+
logger.info(f"Exporting graphviz svg to: {output}")
|
|
129
|
+
_check_dot_on_path()
|
|
130
|
+
|
|
131
|
+
dot_content: str = create_topology_graphviz_dot(graph, config)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
run(
|
|
135
|
+
["dot", "-Tsvg", "-o", str(output)],
|
|
136
|
+
input=dot_content,
|
|
137
|
+
check=True,
|
|
138
|
+
stderr=PIPE,
|
|
139
|
+
stdout=PIPE,
|
|
140
|
+
text=True,
|
|
141
|
+
)
|
|
142
|
+
logger.info(f"Successfully exported SVG to {output}")
|
|
143
|
+
except CalledProcessError as e:
|
|
144
|
+
logger.error(f"Error executing dot: {e.stderr}")
|
|
145
|
+
raise
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Failed to export SVG to {output}: {e}")
|
|
148
|
+
raise
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from atexit import register as on_script_exit
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import cache
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from shutil import which
|
|
7
|
+
from string import Template
|
|
8
|
+
from subprocess import PIPE, CalledProcessError, run
|
|
9
|
+
from tempfile import NamedTemporaryFile
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from ..graph import Graph
|
|
13
|
+
from ..graphable import Graphable
|
|
14
|
+
|
|
15
|
+
logger = getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_MERMAID_CONFIG_JSON: str = '{ "htmlLabels": false }'
|
|
18
|
+
_MMDC_SCRIPT_TEMPLATE: Template = Template("""
|
|
19
|
+
#!/bin/env bash
|
|
20
|
+
/bin/env mmdc -c $mermaid_config -i $source -o $output -p $puppeteer_config
|
|
21
|
+
""")
|
|
22
|
+
_PUPPETEER_CONFIG_JSON: str = '{ "args": [ "--no-sandbox" ] }'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MermaidStylingConfig:
|
|
27
|
+
"""
|
|
28
|
+
Configuration for customizing Mermaid diagram generation.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
node_ref_fnc: Function to generate the node identifier (reference).
|
|
32
|
+
node_text_fnc: Function to generate the node label text.
|
|
33
|
+
node_style_fnc: Function to generate specific style for a node (or None).
|
|
34
|
+
node_style_default: Default style string for nodes (or None).
|
|
35
|
+
link_text_fnc: Function to generate label for links between nodes.
|
|
36
|
+
link_style_fnc: Function to generate style for links (or None).
|
|
37
|
+
link_style_default: Default style string for links (or None).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
node_ref_fnc: Callable[[Graphable], str] = lambda n: n.reference
|
|
41
|
+
node_text_fnc: Callable[[Graphable], str] = lambda n: n.reference
|
|
42
|
+
node_style_fnc: Callable[[Graphable], str] | None = None
|
|
43
|
+
node_style_default: str | None = None
|
|
44
|
+
link_text_fnc: Callable[[Graphable, Graphable], str] = lambda n, sn: "-->"
|
|
45
|
+
link_style_fnc: Callable[[Graphable, Graphable], str] | None = None
|
|
46
|
+
link_style_default: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_mmdc_on_path() -> None:
|
|
50
|
+
"""Check if 'mmdc' executable is available in the system path."""
|
|
51
|
+
if which("mmdc") is None:
|
|
52
|
+
logger.error("mmdc not found on PATH.")
|
|
53
|
+
raise FileNotFoundError("mmdc is required but not available on $PATH")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cleanup_on_exit(path: Path) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Remove a temporary file if it still exists at script exit.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path (Path): The path to the file to remove.
|
|
62
|
+
"""
|
|
63
|
+
if path.exists():
|
|
64
|
+
logger.debug(f"Cleaning up temporary file: {path}")
|
|
65
|
+
path.unlink()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _create_mmdc_script(mmdc_script_content: str) -> Path:
|
|
69
|
+
"""Create a temporary shell script for executing mmdc."""
|
|
70
|
+
with NamedTemporaryFile(delete=False, mode="w+", suffix=".sh") as f:
|
|
71
|
+
f.write(mmdc_script_content)
|
|
72
|
+
mmdc_script: Path = Path(f.name)
|
|
73
|
+
logger.debug(f"Created temporary mmdc script: {mmdc_script}")
|
|
74
|
+
return mmdc_script
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_mmdc_script_content(source: Path, output: Path) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Generate the bash script content to run mmdc.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
source (Path): Path to the source mermaid file.
|
|
83
|
+
output (Path): Path to the output file.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
str: The script content.
|
|
87
|
+
"""
|
|
88
|
+
mmdc_script_content: str = _MMDC_SCRIPT_TEMPLATE.substitute(
|
|
89
|
+
mermaid_config=_write_mermaid_config(),
|
|
90
|
+
output=output,
|
|
91
|
+
puppeteer_config=_write_puppeteer_config(),
|
|
92
|
+
source=source,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return mmdc_script_content
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_topology_mermaid_mmd(
|
|
99
|
+
graph: Graph, config: MermaidStylingConfig | None = None
|
|
100
|
+
) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Generate Mermaid flowchart definition from a Graph.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
graph (Graph): The graph to convert.
|
|
106
|
+
config (MermaidStylingConfig | None): Styling configuration.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
str: The mermaid graph definition string.
|
|
110
|
+
"""
|
|
111
|
+
config = config or MermaidStylingConfig()
|
|
112
|
+
|
|
113
|
+
def link_style(node: Graphable, subnode: Graphable) -> str | None:
|
|
114
|
+
if config.link_style_fnc:
|
|
115
|
+
return config.link_style_fnc(node, subnode)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def node_style(node: Graphable) -> str | None:
|
|
119
|
+
if config.node_style_fnc and (style := config.node_style_fnc(node)):
|
|
120
|
+
return style
|
|
121
|
+
return config.node_style_default
|
|
122
|
+
|
|
123
|
+
link_num: int = 0
|
|
124
|
+
mermaid: list[str] = ["flowchart TD"]
|
|
125
|
+
for node in graph.topological_order():
|
|
126
|
+
if subnodes := node.dependents:
|
|
127
|
+
for subnode in subnodes:
|
|
128
|
+
mermaid.append(
|
|
129
|
+
f"{config.node_text_fnc(node)} {config.link_text_fnc(node, subnode)} {config.node_text_fnc(subnode)}"
|
|
130
|
+
)
|
|
131
|
+
if style := link_style(node, subnode):
|
|
132
|
+
mermaid.append(f"linkStyle {link_num} {style}")
|
|
133
|
+
link_num += 1
|
|
134
|
+
else:
|
|
135
|
+
mermaid.append(f"{config.node_text_fnc(node)}")
|
|
136
|
+
|
|
137
|
+
if style := node_style(node):
|
|
138
|
+
mermaid.append(f"style {config.node_ref_fnc(node)} {style}")
|
|
139
|
+
|
|
140
|
+
if config.link_style_default:
|
|
141
|
+
mermaid.append(f"linkStyle default {config.link_style_default}")
|
|
142
|
+
|
|
143
|
+
return "\n".join(mermaid)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _execute_build_script(build_script: Path) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Execute the build script.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
build_script (Path): Path to the script.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
bool: True if execution succeeded, False otherwise.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
run(
|
|
158
|
+
["/bin/env", "bash", build_script],
|
|
159
|
+
check=True,
|
|
160
|
+
stderr=PIPE,
|
|
161
|
+
stdout=PIPE,
|
|
162
|
+
text=True,
|
|
163
|
+
)
|
|
164
|
+
return True
|
|
165
|
+
except CalledProcessError as e:
|
|
166
|
+
logger.error(f"Error executing {build_script}: {e.stderr}")
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
logger.error("Could not execute script: file not found.")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def export_topology_mermaid_mmd(
|
|
173
|
+
graph: Graph, output: Path, config: MermaidStylingConfig | None = None
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Export the graph to a Mermaid .mmd file.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
graph (Graph): The graph to export.
|
|
180
|
+
output (Path): The output file path.
|
|
181
|
+
config (MermaidStylingConfig | None): Styling configuration.
|
|
182
|
+
"""
|
|
183
|
+
logger.info(f"Exporting mermaid mmd to: {output}")
|
|
184
|
+
with open(output, "w+") as f:
|
|
185
|
+
f.write(create_topology_mermaid_mmd(graph, config))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def export_topology_mermaid_svg(
|
|
189
|
+
graph: Graph, output: Path, config: MermaidStylingConfig | None = None
|
|
190
|
+
) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Export the graph to an SVG file using mmdc.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
graph (Graph): The graph to export.
|
|
196
|
+
output (Path): The output file path.
|
|
197
|
+
config (MermaidStylingConfig | None): Styling configuration.
|
|
198
|
+
"""
|
|
199
|
+
logger.info(f"Exporting mermaid svg to: {output}")
|
|
200
|
+
_check_mmdc_on_path()
|
|
201
|
+
|
|
202
|
+
mermaid: str = create_topology_mermaid_mmd(graph, config)
|
|
203
|
+
|
|
204
|
+
with NamedTemporaryFile(delete=False, mode="w+", suffix=".mmd") as f:
|
|
205
|
+
f.write(mermaid)
|
|
206
|
+
source: Path = Path(f.name)
|
|
207
|
+
|
|
208
|
+
logger.debug(f"Created temporary mermaid source file: {source}")
|
|
209
|
+
|
|
210
|
+
build_script: Path = _create_mmdc_script(
|
|
211
|
+
create_mmdc_script_content(source=source, output=output)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if _execute_build_script(build_script):
|
|
215
|
+
build_script.unlink()
|
|
216
|
+
source.unlink()
|
|
217
|
+
logger.info(f"Successfully exported SVG to {output}")
|
|
218
|
+
else:
|
|
219
|
+
logger.error(f"Failed to export SVG to {output}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@cache
|
|
223
|
+
def _write_mermaid_config() -> Path:
|
|
224
|
+
"""Write temporary mermaid config file."""
|
|
225
|
+
with NamedTemporaryFile(delete=False, mode="w+", suffix=".json") as f:
|
|
226
|
+
f.write(_MERMAID_CONFIG_JSON)
|
|
227
|
+
|
|
228
|
+
path: Path = Path(f.name)
|
|
229
|
+
on_script_exit(lambda: _cleanup_on_exit(path))
|
|
230
|
+
return path
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@cache
|
|
234
|
+
def _write_puppeteer_config() -> Path:
|
|
235
|
+
"""Write temporary puppeteer config file."""
|
|
236
|
+
with NamedTemporaryFile(delete=False, mode="w+", suffix=".json") as f:
|
|
237
|
+
f.write(_PUPPETEER_CONFIG_JSON)
|
|
238
|
+
|
|
239
|
+
path: Path = Path(f.name)
|
|
240
|
+
on_script_exit(lambda: _cleanup_on_exit(path))
|
|
241
|
+
return path
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ..graph import Graph
|
|
7
|
+
from ..graphable import Graphable
|
|
8
|
+
|
|
9
|
+
logger = getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TextTreeStylingConfig:
|
|
14
|
+
"""
|
|
15
|
+
Configuration for text tree representation of the graph.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
initial_indent: String to use for initial indentation.
|
|
19
|
+
node_text_fnc: Function to generate the text representation of a node.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
initial_indent: str = ""
|
|
23
|
+
node_text_fnc: Callable[[Graphable], str] = lambda n: n.reference
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_topology_tree_txt(
|
|
27
|
+
graph: Graph, config: TextTreeStylingConfig | None = None
|
|
28
|
+
) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Create a text-based tree representation of the graph topology.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
graph (Graph): The graph to convert.
|
|
34
|
+
config (TextTreeStylingConfig | None): Styling configuration.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: The text tree representation.
|
|
38
|
+
"""
|
|
39
|
+
if config is None:
|
|
40
|
+
config = TextTreeStylingConfig()
|
|
41
|
+
|
|
42
|
+
logger.debug("Creating topology tree text.")
|
|
43
|
+
|
|
44
|
+
def create_topology_subtree_txt(
|
|
45
|
+
node: Graphable,
|
|
46
|
+
indent: str = "",
|
|
47
|
+
is_last: bool = True,
|
|
48
|
+
is_root: bool = True,
|
|
49
|
+
visited: set[Graphable] | None = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Recursively generate the text representation for a subtree.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
node (Graphable): The current node being processed.
|
|
56
|
+
indent (str): The current indentation string.
|
|
57
|
+
is_last (bool): Whether this node is the last sibling.
|
|
58
|
+
is_root (bool): Whether this is the root of the (sub)tree.
|
|
59
|
+
visited (set[Graphable] | None): Set of already visited nodes to detect cycles/redundancy.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: The text representation of the subtree.
|
|
63
|
+
"""
|
|
64
|
+
if visited is None:
|
|
65
|
+
visited = set[Graphable]()
|
|
66
|
+
already_seen: bool = node in visited
|
|
67
|
+
|
|
68
|
+
subtree: list[str] = []
|
|
69
|
+
if is_root:
|
|
70
|
+
subtree.append(f"{indent}{config.node_text_fnc(node)}")
|
|
71
|
+
|
|
72
|
+
next_indent: str = indent
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
marker: str = "└─ " if is_last else "├─ "
|
|
76
|
+
suffix: str = " (see above)" if already_seen and node.depends_on else ""
|
|
77
|
+
subtree.append(f"{indent}{marker}{config.node_text_fnc(node)}{suffix}")
|
|
78
|
+
|
|
79
|
+
next_indent: str = indent + (" " if is_last else "│ ")
|
|
80
|
+
|
|
81
|
+
if already_seen:
|
|
82
|
+
return "\n".join(subtree)
|
|
83
|
+
visited.add(node)
|
|
84
|
+
|
|
85
|
+
for i, subnode in enumerate(node.depends_on, start=1):
|
|
86
|
+
subtree.append(
|
|
87
|
+
create_topology_subtree_txt(
|
|
88
|
+
node=subnode,
|
|
89
|
+
indent=next_indent,
|
|
90
|
+
is_last=(i == len(node.depends_on)),
|
|
91
|
+
is_root=False,
|
|
92
|
+
visited=visited,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return "\n".join(subtree)
|
|
97
|
+
|
|
98
|
+
tree: list[str] = []
|
|
99
|
+
for node in graph.sinks:
|
|
100
|
+
tree.append(
|
|
101
|
+
create_topology_subtree_txt(
|
|
102
|
+
node=node, indent=config.initial_indent, is_root=True
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return "\n".join(tree)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def export_topology_tree_txt(
|
|
109
|
+
graph: Graph, output: Path, config: TextTreeStylingConfig | None = None
|
|
110
|
+
) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Export the graph to a text tree file.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
graph (Graph): The graph to export.
|
|
116
|
+
output (Path): The output file path.
|
|
117
|
+
config (TextTreeStylingConfig | None): Styling configuration.
|
|
118
|
+
"""
|
|
119
|
+
logger.info(f"Exporting topology tree text to: {output}")
|
|
120
|
+
with open(output, "w+") as f:
|
|
121
|
+
f.write(create_topology_tree_txt(graph, config))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: graphable
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight, type-safe library for building, managing, and visualizing dependency graphs.
|
|
5
|
+
Keywords: graph,dependency-graph,topological-sort,mermaid,visualization
|
|
6
|
+
Author: Richard West
|
|
7
|
+
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# graphable
|
|
17
|
+
|
|
18
|
+
[](https://github.com/TheTrueSCU/graphable/actions/workflows/ci.yml)
|
|
19
|
+
[](https://opensource.org/licenses/MIT)
|
|
20
|
+
|
|
21
|
+
`graphable` is a lightweight, type-safe Python library for building, managing, and visualizing dependency graphs. It provides a simple API for defining nodes and their relationships, performing topological sorts, and exporting graphs to various formats like Mermaid and ASCII text trees.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Type-Safe:** Built with modern Python generics and type hints.
|
|
26
|
+
- **Topological Sorting:** Easily get nodes in dependency order.
|
|
27
|
+
- **Filtering & Tagging:** Create subgraphs based on custom predicates or tags.
|
|
28
|
+
- **Visualizations:**
|
|
29
|
+
- **Mermaid:** Generate flowchart definitions or export directly to SVG.
|
|
30
|
+
- **Text Tree:** Generate beautiful ASCII tree representations.
|
|
31
|
+
- **Modern Tooling:** Managed with `uv` and `just`.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add graphable
|
|
37
|
+
# or
|
|
38
|
+
pip install graphable
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from graphable.graph import Graph
|
|
45
|
+
from graphable.graphable import Graphable
|
|
46
|
+
from graphable.views.texttree import create_topology_tree_txt
|
|
47
|
+
|
|
48
|
+
# 1. Define your nodes
|
|
49
|
+
a = Graphable("Database")
|
|
50
|
+
b = Graphable("API Service")
|
|
51
|
+
c = Graphable("Web Frontend")
|
|
52
|
+
|
|
53
|
+
# 2. Build the graph
|
|
54
|
+
g = Graph()
|
|
55
|
+
g.add_edge(a, b) # API Service depends on Database
|
|
56
|
+
g.add_edge(b, c) # Web Frontend depends on API Service
|
|
57
|
+
|
|
58
|
+
# 3. Get topological order
|
|
59
|
+
for node in g.topological_order():
|
|
60
|
+
print(node.reference)
|
|
61
|
+
# Output: Database, API Service, Web Frontend
|
|
62
|
+
|
|
63
|
+
# 4. Visualize as a text tree
|
|
64
|
+
print(create_topology_tree_txt(g))
|
|
65
|
+
# Output:
|
|
66
|
+
# Web Frontend
|
|
67
|
+
# └─ API Service
|
|
68
|
+
# └─ Database
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Visualizing with Mermaid
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from graphable.views.mermaid import create_topology_mermaid_mmd
|
|
75
|
+
|
|
76
|
+
mmd = create_topology_mermaid_mmd(g)
|
|
77
|
+
print(mmd)
|
|
78
|
+
# Output:
|
|
79
|
+
# flowchart TD
|
|
80
|
+
# Database --> API Service
|
|
81
|
+
# API Service --> Web Frontend
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
85
|
+
|
|
86
|
+
Full documentation is available in the `docs/` directory. You can build it locally:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
just docs-view
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
This project uses `uv` for dependency management and `just` as a command runner.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
just install # Install dependencies
|
|
98
|
+
just check # Run linting, type checking, and tests
|
|
99
|
+
just coverage # Run tests with coverage report
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
graphable/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
graphable/graph.py,sha256=lTjVep8I6i4szrgyrMMLbsCPD2MhfhJ131woMgZCedo,11080
|
|
3
|
+
graphable/graphable.py,sha256=I4niraMNhaBMRdV-P3HhFukMeohr5Y60--2-5X3XwkI,3416
|
|
4
|
+
graphable/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
graphable/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
graphable/views/graphviz.py,sha256=BEpORB-1hY9wsmmiEwN5Yuf-FOXcpj0F4bVkpg0wli8,4906
|
|
7
|
+
graphable/views/mermaid.py,sha256=lxI3ZWjnbQAuCYgEQ75b79Rqc1RMKU5bIVeQf59d6ak,7701
|
|
8
|
+
graphable/views/texttree.py,sha256=ctUF7oG2noeGoED7IP-He0VDCJvbz3j1wNViC2vaJ1s,3651
|
|
9
|
+
graphable-0.2.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
10
|
+
graphable-0.2.0.dist-info/METADATA,sha256=bHZXsJ-DFigAbUNu0rqFJh4CAkXMe3duHXy5REss0U8,3130
|
|
11
|
+
graphable-0.2.0.dist-info/RECORD,,
|