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 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
+ [![CI](https://github.com/TheTrueSCU/graphable/actions/workflows/ci.yml/badge.svg)](https://github.com/TheTrueSCU/graphable/actions/workflows/ci.yml)
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.30
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any