smartcomment 0.1.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.
@@ -0,0 +1,49 @@
1
+ """smartcomment: A General-Purpose Execution Graph Tracing Package."""
2
+
3
+ from .runtime import (
4
+ current_context,
5
+ current_graph,
6
+ current_op,
7
+ current_session,
8
+ comment_graph,
9
+ comment_op_scope,
10
+ comment_session,
11
+ disable_tracing,
12
+ enable_tracing,
13
+ is_tracing_enabled,
14
+ propagate_attributes,
15
+ )
16
+ from .api import (
17
+ comment_fn,
18
+ comment_link,
19
+ comment_mutation,
20
+ comment_op,
21
+ comment_variable,
22
+ )
23
+ from .identity import IdentityRegistry
24
+ from .logging import logger, setup_logger
25
+ from .debugging import draw_graph
26
+
27
+
28
+ __all__ = [
29
+ "IdentityRegistry",
30
+ "logger",
31
+ "setup_logger",
32
+ "comment_graph",
33
+ "comment_op_scope",
34
+ "comment_session",
35
+ "current_context",
36
+ "current_graph",
37
+ "current_op",
38
+ "current_session",
39
+ "disable_tracing",
40
+ "draw_graph",
41
+ "enable_tracing",
42
+ "is_tracing_enabled",
43
+ "propagate_attributes",
44
+ "comment_fn",
45
+ "comment_link",
46
+ "comment_mutation",
47
+ "comment_op",
48
+ "comment_variable",
49
+ ]
@@ -0,0 +1 @@
1
+ """Analysis and visualization utilities."""
@@ -0,0 +1,43 @@
1
+ """Visualization backends for execution graphs.
2
+
3
+ Standalone functions accept user-facing runtime types so callers
4
+ can pass graph data without unwrapping internals.
5
+
6
+ Optional dependencies: ``graphviz``, ``pyvis``, ``matplotlib``.
7
+ Install with ``pip install smartcomment[viz]``.
8
+ """
9
+
10
+ from collections import OrderedDict
11
+ from ...runtime.operation import RuntimeEdge
12
+ from ...runtime.variable import RuntimeVariable
13
+ from .graphviz import render_static, to_dot
14
+ from .pyvis import render_interactive
15
+ from typing import Any, Protocol
16
+
17
+
18
+ class GraphVisualizationBackend(Protocol):
19
+ """Graph visualization backend protocol."""
20
+
21
+ def __call__(
22
+ self,
23
+ nodes: list[RuntimeVariable[Any]],
24
+ edges: list[RuntimeEdge],
25
+ **kwargs: Any,
26
+ ) -> Any:
27
+ ...
28
+
29
+
30
+ _VISUAL_BACKENDS: OrderedDict[str, GraphVisualizationBackend] = OrderedDict(
31
+ (
32
+ ("graphviz", render_static),
33
+ ("dot", to_dot),
34
+ ("pyvis", render_interactive),
35
+ )
36
+ )
37
+
38
+
39
+ __all__ = [
40
+ "render_interactive",
41
+ "render_static",
42
+ "to_dot",
43
+ ]
@@ -0,0 +1,103 @@
1
+ """Utility functions for visualization."""
2
+
3
+ from __future__ import annotations
4
+ import warnings
5
+ from typing import TYPE_CHECKING, Iterable
6
+
7
+ if TYPE_CHECKING:
8
+ from matplotlib.colors import Colormap
9
+
10
+
11
+ def _truncate(val: str, max_len: int = 30) -> str:
12
+ """Truncate a string for graph labels (newlines collapsed to spaces).
13
+
14
+ Args:
15
+ val (`str`):
16
+ Text to truncate.
17
+ max_len (`int`, defaults to `30`):
18
+ Maximum length of the string.
19
+
20
+ Returns:
21
+ `str`:
22
+ Truncated string without newlines.
23
+ """
24
+ s = val.replace("\n", " ").replace("\r", "")
25
+ if len(s) > max_len:
26
+ return s[: max_len - 3] + "..."
27
+ return s
28
+
29
+
30
+ def _escape_html(s: str) -> str:
31
+ """Escape string for HTML labels."""
32
+ return s.replace(
33
+ "&",
34
+ "&"
35
+ ).replace(
36
+ "<",
37
+ "&lt;"
38
+ ).replace(
39
+ ">",
40
+ "&gt;"
41
+ ).replace(
42
+ '"',
43
+ "&quot;"
44
+ )
45
+
46
+
47
+ def _get_color_map(
48
+ categories: Iterable[str],
49
+ cmap: str | Colormap | None = None,
50
+ max_auto: int = 20
51
+ ) -> dict[str, str]:
52
+ """Generate a hex color mapping for a set of categories using Matplotlib.
53
+
54
+ Args:
55
+ categories (`Iterable[str]`):
56
+ Unique categories to colorize.
57
+ cmap (`str | Colormap | None`, optional):
58
+ Matplotlib colormap name or a colormap object.
59
+ If not provided, the default colormap will be used.
60
+ max_auto (`int`):
61
+ If no color map is provided and categories exceed this, return no colors.
62
+
63
+ Returns:
64
+ `dict[str, str]`:
65
+ Mapping from category string to hex color string.
66
+ """
67
+ if not categories:
68
+ raise ValueError("No categories are provided.")
69
+
70
+ if cmap is None and len(categories) > max_auto:
71
+ return {}
72
+
73
+ try:
74
+ import matplotlib.pyplot as plt
75
+ import matplotlib.colors as mcolors
76
+ except ImportError as e:
77
+ if cmap is not None:
78
+ raise ImportError(
79
+ "`matplotlib` is required for custom colormaps. "
80
+ "Install it via `pip install matplotlib`."
81
+ ) from e
82
+
83
+ # Graceful fallback to no colors if default behavior and no matplotlib.
84
+ warnings.warn(
85
+ "`matplotlib` is required for custom colormaps, but it is not installed. "
86
+ "No colors will be assigned to categories."
87
+ )
88
+ return {}
89
+
90
+ if cmap is None:
91
+ cmap_obj = plt.get_cmap("tab20")
92
+ elif isinstance(cmap, str):
93
+ cmap_obj = plt.get_cmap(cmap)
94
+ else:
95
+ cmap_obj = cmap
96
+
97
+ n = len(categories)
98
+ color_dict = {}
99
+ for i, cat in enumerate(sorted(categories)):
100
+ rgba = cmap_obj(i / max(1, n - 1))
101
+ color_dict[cat] = mcolors.to_hex(rgba)
102
+
103
+ return color_dict
@@ -0,0 +1,201 @@
1
+ """Graphviz visualization backend."""
2
+
3
+ from __future__ import annotations
4
+ from ...runtime.operation import RuntimeEdge
5
+ from ...runtime.variable import RuntimeVariable
6
+ from ._utils import (
7
+ _get_color_map,
8
+ _escape_html,
9
+ _truncate,
10
+ )
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from matplotlib.colors import Colormap
16
+ from graphviz import Source
17
+
18
+
19
+ def to_dot(
20
+ nodes: list[RuntimeVariable[Any]],
21
+ edges: list[RuntimeEdge],
22
+ node_cmap: str | Colormap | None = None,
23
+ edge_cmap: str | Colormap | None = None,
24
+ max_str_len: int = 30,
25
+ **kwargs: Any,
26
+ ) -> str:
27
+ """Convert a node list and an edge list to a `graphviz` DOT string.
28
+
29
+ Args:
30
+ nodes (`list[RuntimeVariable]`):
31
+ Variable nodes to render.
32
+ edges (`list[RuntimeEdge]`):
33
+ Edges to render.
34
+ node_cmap (`str | Colormap | None`, optional):
35
+ Matplotlib colormap (name or object) for coloring nodes by category.
36
+ edge_cmap (`str | Colormap | None`, optional):
37
+ Matplotlib colormap (name or object) for coloring edges by category.
38
+ max_str_len (`int`, defaults to `30`):
39
+ Maximum string length for displayed fields before truncation.
40
+ **kwargs (`Any`):
41
+ These keyword arguments will be ignored.
42
+
43
+ Returns:
44
+ `str`:
45
+ DOT-format string.
46
+ """
47
+ lines = [
48
+ 'digraph exec_graph {',
49
+ ' rankdir=LR;',
50
+ ' node [shape=none, fontname="Helvetica"];',
51
+ ' edge [fontname="Helvetica"];'
52
+ ]
53
+
54
+ # Get unique categories for nodes and edges.
55
+ node_cats = {n.category for n in nodes}
56
+ edge_cats = {e.category for e in edges}
57
+
58
+
59
+ if node_cats:
60
+ node_colors = _get_color_map(node_cats, cmap=node_cmap)
61
+ else:
62
+ node_colors = {}
63
+ if edge_cats:
64
+ edge_colors = _get_color_map(edge_cats, cmap=edge_cmap)
65
+ else:
66
+ edge_colors = {}
67
+
68
+ for node in nodes:
69
+ cat = node.category
70
+ # Default white background.
71
+ color_hex = node_colors.get(cat, "#FFFFFF")
72
+
73
+ node_id_str = _escape_html(
74
+ _truncate(
75
+ node.full_node_id,
76
+ max_len=max_str_len
77
+ )
78
+ )
79
+ raw_val_str = _escape_html(
80
+ _truncate(
81
+ node.raw_value,
82
+ max_len=max_str_len
83
+ )
84
+ )
85
+ cat_str = _escape_html(
86
+ _truncate(
87
+ node.category,
88
+ max_len=max_str_len
89
+ )
90
+ )
91
+ tp_str = _escape_html(
92
+ _truncate(
93
+ node.trigger_point,
94
+ max_len=max_str_len
95
+ )
96
+ )
97
+ comment_str = _escape_html(
98
+ _truncate(node.comment, max_len=max_str_len) if node.comment else ""
99
+ )
100
+
101
+ # Pad empty comments with a space to prevent the cell from collapsing.
102
+ comment_display = comment_str if comment_str else " "
103
+
104
+ # Build HTML-like record with strict BALIGN="LEFT" and <BR ALIGN="LEFT"/>.
105
+ label = (
106
+ f'<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" BGCOLOR="{color_hex}">\n'
107
+ f' <TR><TD ALIGN="CENTER"><B>{node_id_str}</B></TD></TR>\n'
108
+ f' <TR><TD ALIGN="LEFT" BALIGN="LEFT">'
109
+ f'{raw_val_str}<BR ALIGN="LEFT"/>'
110
+ f'category: {cat_str}<BR ALIGN="LEFT"/>'
111
+ f'trigger point: {tp_str}<BR ALIGN="LEFT"/>'
112
+ f'</TD></TR>\n'
113
+ f' <TR><TD ALIGN="LEFT" BALIGN="LEFT">{comment_display}<BR ALIGN="LEFT"/></TD></TR>\n'
114
+ f'</TABLE>>'
115
+ )
116
+
117
+ lines.append(f' "{node.full_node_id}" [label={label}];')
118
+
119
+ for edge in edges:
120
+ cat = edge.category
121
+ # Default black for edges.
122
+ color_hex = edge_colors.get(cat, "#000000")
123
+
124
+ label = _escape_html(
125
+ _truncate(
126
+ edge.category,
127
+ max_len=max_str_len
128
+ )
129
+ )
130
+ if edge.comment:
131
+ label = _escape_html(
132
+ _truncate(
133
+ edge.comment,
134
+ max_len=max_str_len
135
+ )
136
+ )
137
+
138
+ lines.append(
139
+ f' "{edge.source_full_node_id}" -> "{edge.target_full_node_id}" '
140
+ f'[label="{label}", color="{color_hex}", fontcolor="{color_hex}"];'
141
+ )
142
+
143
+ lines.append("}")
144
+ return "\n".join(lines)
145
+
146
+
147
+ def render_static(
148
+ nodes: list[RuntimeVariable[Any]],
149
+ edges: list[RuntimeEdge],
150
+ filename: str = "exec_graph",
151
+ format: str = "png",
152
+ node_cmap: str | Colormap | None = None,
153
+ edge_cmap: str | Colormap | None = None,
154
+ max_str_len: int = 30,
155
+ **kwargs: Any,
156
+ ) -> Source:
157
+ """Render a graph to a static image file via `graphviz`.
158
+
159
+ Args:
160
+ nodes (`list[RuntimeVariable]`):
161
+ Variable nodes to render.
162
+ edges (`list[RuntimeEdge]`):
163
+ Edges to render.
164
+ filename (`str`, defaults to `"exec_graph"`):
165
+ Output filename (without extension).
166
+ format (`str`, defaults to `"png"`):
167
+ Image format (``"png"``, ``"svg"``, ``"pdf"``).
168
+ node_cmap (`str | Colormap | None`, optional):
169
+ Matplotlib colormap (name or object) for coloring nodes by category.
170
+ edge_cmap (`str | Colormap | None`, optional):
171
+ Matplotlib colormap (name or object) for coloring edges by category.
172
+ max_str_len (`int`, defaults to `30`):
173
+ Maximum characters for text display.
174
+ **kwargs (`Any`):
175
+ Additional keyword arguments to be passed to ``graphviz.Source.render``.
176
+
177
+ Returns:
178
+ `Source`:
179
+ A `graphviz.Source` object.
180
+ """
181
+ try:
182
+ import graphviz as gv
183
+ except ImportError as e:
184
+ raise ImportError(
185
+ "`graphviz` Python package is required. "
186
+ "Install it via `pip install graphviz`."
187
+ ) from e
188
+
189
+ dot_str = to_dot(
190
+ nodes=nodes,
191
+ edges=edges,
192
+ node_cmap=node_cmap,
193
+ edge_cmap=edge_cmap,
194
+ max_str_len=max_str_len,
195
+ )
196
+ src = gv.Source(dot_str, format=format)
197
+ kwargs.setdefault("cleanup", True)
198
+ kwargs.setdefault("view", False)
199
+ src.render(filename=filename, **kwargs)
200
+
201
+ return src
@@ -0,0 +1,155 @@
1
+ """Pyvis visualization backend."""
2
+
3
+ from __future__ import annotations
4
+ from ...runtime.operation import RuntimeEdge
5
+ from ...runtime.variable import RuntimeVariable
6
+ from ._utils import _get_color_map, _truncate
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from pyvis.network import Network
11
+ from matplotlib.colors import Colormap
12
+
13
+
14
+ _DEFAULT_BARNES_HUT = {
15
+ "gravity": -12000,
16
+ "central_gravity": 0.12,
17
+ "spring_length": 400,
18
+ "spring_strength": 0.001,
19
+ "damping": 0.08,
20
+ "overlap": 1,
21
+ }
22
+
23
+
24
+ def render_interactive(
25
+ nodes: list[RuntimeVariable[Any]],
26
+ edges: list[RuntimeEdge],
27
+ filename: str = "exec_graph.html",
28
+ node_cmap: str | Colormap | None = None,
29
+ edge_cmap: str | Colormap | None = None,
30
+ max_str_len: int = 30,
31
+ smooth_type: str = "continuous",
32
+ barnes_hut_config: dict[str, float | int] | None = None,
33
+ **kwargs: Any,
34
+ ) -> Network:
35
+ """Render an interactive HTML graph via `pyvis`.
36
+
37
+ Args:
38
+ nodes (`list[RuntimeVariable]`):
39
+ Variable nodes to render.
40
+ edges (`list[RuntimeEdge]`):
41
+ Edges to render.
42
+ filename (`str`, defaults to `"exec_graph.html"`):
43
+ Output HTML file path.
44
+ node_cmap (`str | Colormap | None`, optional):
45
+ Matplotlib colormap (name or object) for coloring nodes by
46
+ category.
47
+ edge_cmap (`str | Colormap | None`, optional):
48
+ Matplotlib colormap (name or object) for coloring edges by
49
+ category.
50
+ max_str_len (`int`, defaults to `30`):
51
+ Maximum string length for node/edge labels before truncation.
52
+ smooth_type (`str`, defaults to `"continuous"`):
53
+ Edge smoothing algorithm.
54
+ barnes_hut_config (`dict[str, float | int] | None`, optional):
55
+ Override the default BarnesHut physics parameters.
56
+ **kwargs (`Any`):
57
+ Additional keyword arguments to be passed to ``pyvis.network.Network``.
58
+
59
+ Returns:
60
+ `Network`:
61
+ A `pyvis.network.Network` object.
62
+ """
63
+ try:
64
+ from pyvis.network import Network as PyvisNetwork
65
+ except ImportError as e:
66
+ raise ImportError(
67
+ "`pyvis` Python package is required. "
68
+ "Install it via `pip install pyvis`."
69
+ ) from e
70
+
71
+ net = PyvisNetwork(**kwargs)
72
+
73
+ node_cats = {n.category for n in nodes}
74
+ edge_cats = {e.category for e in edges}
75
+
76
+ if node_cats:
77
+ node_colors = _get_color_map(node_cats, cmap=node_cmap)
78
+ else:
79
+ node_colors = {}
80
+ if edge_cats:
81
+ edge_colors = _get_color_map(edge_cats, cmap=edge_cmap)
82
+ else:
83
+ edge_colors = {}
84
+
85
+ for node in nodes:
86
+ cat = node.category
87
+ # Pyvis default blueish.
88
+ color_hex = node_colors.get(cat, "#97C2FC")
89
+
90
+ node_id_str = _truncate(node.full_node_id, max_len=max_str_len)
91
+ raw_val_str = _truncate(node.raw_value, max_len=max_str_len)
92
+ cat_str = _truncate(cat, max_len=max_str_len)
93
+ tp_str = _truncate(node.trigger_point, max_len=max_str_len)
94
+ comment_str = (
95
+ _truncate(node.comment, max_len=max_str_len) if node.comment else ""
96
+ )
97
+
98
+ separator = "-" * max(15, min(30, max_str_len))
99
+ label = (
100
+ f"{node_id_str}\n{separator}\n"
101
+ f"{raw_val_str}\ncategory: {cat_str}\n"
102
+ f"trigger point: {tp_str}"
103
+ )
104
+ if comment_str:
105
+ label += f"\n{separator}\n{comment_str}"
106
+
107
+ title = (
108
+ f"ID: {node.full_node_id}\nRaw Value: {node.raw_value}\n"
109
+ f"Category: {cat}\nTrigger Point: {node.trigger_point}"
110
+ )
111
+ if node.comment:
112
+ title += f"\nComment: {node.comment}"
113
+
114
+ # All fields are left aligned.
115
+ net.add_node(
116
+ node.full_node_id,
117
+ label=label,
118
+ title=title,
119
+ shape="box",
120
+ color=color_hex,
121
+ font={"align": "left"},
122
+ )
123
+
124
+ for edge in edges:
125
+ cat = edge.category
126
+ color_hex = edge_colors.get(cat, "#848484")
127
+ tp_str = _truncate(edge.trigger_point, max_len=max_str_len)
128
+
129
+ label_str = _truncate(edge.category, max_str_len)
130
+ if edge.comment:
131
+ label_str = _truncate(edge.comment, max_str_len)
132
+ label_str += f"\n({tp_str})"
133
+
134
+ title_str = f"Category: {cat}\nTrigger Point: {edge.trigger_point}"
135
+ if edge.comment:
136
+ title_str = (
137
+ f"Category: {cat}\nComment: {edge.comment}\n"
138
+ f"Trigger Point: {edge.trigger_point}"
139
+ )
140
+
141
+ net.add_edge(
142
+ edge.source_full_node_id,
143
+ edge.target_full_node_id,
144
+ label=label_str,
145
+ title=title_str,
146
+ color=color_hex,
147
+ )
148
+
149
+ net.set_edge_smooth(smooth_type)
150
+
151
+ bh = {**_DEFAULT_BARNES_HUT, **(barnes_hut_config or {})}
152
+ net.barnes_hut(**bh)
153
+ net.save_graph(filename)
154
+
155
+ return net
@@ -0,0 +1,18 @@
1
+ """Public APIs."""
2
+
3
+ from .public import (
4
+ comment_fn,
5
+ comment_link,
6
+ comment_mutation,
7
+ comment_op,
8
+ comment_variable,
9
+ )
10
+
11
+
12
+ __all__ = [
13
+ "comment_fn",
14
+ "comment_link",
15
+ "comment_mutation",
16
+ "comment_op",
17
+ "comment_variable",
18
+ ]