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,117 @@
1
+ """Runtime error types for smartcomment tracing."""
2
+
3
+ from .variable import RuntimeVariable
4
+ from typing import Any
5
+
6
+
7
+ def _diff_context(old: str, new: str, max_chars: int = 120) -> tuple[str, str]:
8
+ """Return context snippets around the first character where
9
+ old value and new value diverge.
10
+
11
+ It locates the first differing character index, then extracts a
12
+ window of at most `max_chars` characters centred on that position
13
+ from each string. Ellipsis markers are prepended and appended when
14
+ the window does not cover the full string.
15
+
16
+ Args:
17
+ old (`str`):
18
+ The previously recorded encoded value.
19
+ new (`str`):
20
+ The newly provided encoded value.
21
+ max_chars (`int`, defaults to `120`):
22
+ Maximum number of characters to show in each snippet.
23
+
24
+ Returns:
25
+ `tuple[str, str]`:
26
+ A tuple of two strings with context around the
27
+ first difference.
28
+ """
29
+ diff_idx = 0
30
+ for i, (oc, nc) in enumerate(zip(old, new)):
31
+ if oc != nc:
32
+ diff_idx = i
33
+ break
34
+ else:
35
+ diff_idx = min(len(old), len(new))
36
+
37
+ half = max_chars // 2
38
+ start = max(0, diff_idx - half)
39
+
40
+ def _snippet(s: str) -> str:
41
+ end = min(len(s), start + max_chars)
42
+ prefix = "..." if start > 0 else ""
43
+ suffix = "..." if end < len(s) else ""
44
+ return f"{prefix}{s[start:end]}{suffix}"
45
+
46
+ return _snippet(old), _snippet(new)
47
+
48
+
49
+ class TraceConsistencyError(RuntimeError):
50
+ """It is raised in strict mode when a tracked value diverges from
51
+ its recorded state.
52
+
53
+ This typically means a value was mutated in-place without going
54
+ through `comment_mutation`. The error message includes both the
55
+ user-provided value and the existing variable node so that the
56
+ developer can quickly identify the inconsistency.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ user_value: Any,
63
+ user_value_encoded: str,
64
+ existing_variable: RuntimeVariable[Any],
65
+ identity_name: str,
66
+ ) -> None:
67
+ """Initialize the error.
68
+
69
+ Args:
70
+ user_value (`Any`):
71
+ The raw Python value that the caller tried to register.
72
+ user_value_encoded (`str`):
73
+ The encoded string of the user-provided value.
74
+ existing_variable (`RuntimeVariable[Any]`):
75
+ The existing variable node stored in the graph.
76
+ identity_name (`str`):
77
+ The identity name that both values resolve to.
78
+ """
79
+ self.user_value = user_value
80
+ self.user_value_encoded = user_value_encoded
81
+ self.existing_variable = existing_variable
82
+ self.identity_name = identity_name
83
+
84
+ message = self._build_message()
85
+ super().__init__(message)
86
+
87
+ def _build_message(self) -> str:
88
+ """Build a human-readable diagnostic message."""
89
+ ev = self.existing_variable
90
+ old_snippet, new_snippet = _diff_context(ev.raw_value, self.user_value_encoded)
91
+
92
+ lines = [
93
+ "Untraced mutation detected in strict mode.",
94
+ "",
95
+ f" Variable name (identity) : {self.identity_name!r}",
96
+ f" Existing full node identifier : {ev.full_node_id}",
97
+ f" Existing version number : v{ev.version}",
98
+ "",
99
+ " Diff (first changed character with context):",
100
+ f" Recorded : {old_snippet}",
101
+ f" Provided : {new_snippet}",
102
+ "",
103
+ "The value bound to this identity has changed since it was last "
104
+ "recorded, but no `comment_mutation` call was made. In strict "
105
+ "mode this is treated as an error.",
106
+ "",
107
+ "To fix this, either:",
108
+ " 1. Use `comment_mutation(target=rv, new_value=...)` to record "
109
+ "the change explicitly.",
110
+ " 2. Disable strict mode: `comment_graph(strict=False)` or "
111
+ "`graph.strict = False`.",
112
+ ]
113
+ return "\n".join(lines)
114
+
115
+
116
+ class ExecNetworkKeyError(KeyError):
117
+ """It is raised when a key error occurs in the execution network."""
@@ -0,0 +1,520 @@
1
+ """Read-only subgraph view with query and display methods."""
2
+
3
+ from ..analysis.visualization import _VISUAL_BACKENDS
4
+ from .operation import RuntimeEdge, RuntimeOp
5
+ from .variable import RuntimeVariable
6
+ from typing import (
7
+ Any,
8
+ Self,
9
+ Iterable,
10
+ )
11
+
12
+
13
+ class RuntimeGraph:
14
+ """Read-only view over a subgraph."""
15
+
16
+ __slots__ = (
17
+ "nodes", "edges", "ops",
18
+ "_node_ids", "_op_ids", "_edge_ids"
19
+ )
20
+
21
+ def __init__(
22
+ self,
23
+ nodes: list[RuntimeVariable[Any]],
24
+ edges: list[RuntimeEdge],
25
+ ops: list[RuntimeOp],
26
+ ) -> None:
27
+ """Initialize the runtime graph handle.
28
+
29
+ Args:
30
+ nodes (`list[RuntimeVariable]`):
31
+ The list of nodes in the subgraph.
32
+ edges (`list[RuntimeEdge]`):
33
+ The list of edges in the subgraph.
34
+ ops (`list[RuntimeOp]`):
35
+ The list of operations in the subgraph.
36
+ """
37
+ self.nodes = nodes
38
+ self.edges = edges
39
+ self.ops = ops
40
+ self._node_ids = frozenset(n.full_node_id for n in nodes)
41
+ self._op_ids = frozenset(o.op_id for o in ops)
42
+ self._edge_ids = frozenset(e.edge_id for e in edges)
43
+
44
+ def __contains__(self, full_node_id: str) -> bool:
45
+ """Check whether a node with the given full node identifier exists.
46
+
47
+ Args:
48
+ full_node_id (`str`):
49
+ The full node identifier to check.
50
+
51
+ Returns:
52
+ `bool`:
53
+ ``True`` if the node exists in the subgraph, ``False``
54
+ otherwise.
55
+ """
56
+ return full_node_id in self._node_ids
57
+
58
+ def filter_by_category(self, category: str | Iterable[str]) -> Self:
59
+ """Return a sub-view containing only nodes of the given category or categories.
60
+
61
+ Args:
62
+ category (`str | Iterable[str]`):
63
+ A single category string or an iterable of category strings.
64
+
65
+ Returns:
66
+ `Self`:
67
+ Filtered subgraph.
68
+ """
69
+ cats = {category} if isinstance(category, str) else set(category)
70
+ matched = [n for n in self.nodes if n.category in cats]
71
+ matched_ids = {n.full_node_id for n in matched}
72
+ kept_edges = [
73
+ e for e in self.edges
74
+ if e.source_full_node_id in matched_ids and e.target_full_node_id in matched_ids
75
+ ]
76
+ kept_op_ids = {e.op_id for e in kept_edges}
77
+ kept_ops = [o for o in self.ops if o.op_id in kept_op_ids]
78
+ return type(self)(nodes=matched, edges=kept_edges, ops=kept_ops)
79
+
80
+ def filter_by_time(
81
+ self,
82
+ start: str | None = None,
83
+ end: str | None = None,
84
+ ) -> Self:
85
+ """Return a sub-view of nodes within a time range (inclusive).
86
+
87
+ Args:
88
+ start (`str | None`, optional):
89
+ Inclusive lower bound timestamp. If not provided,
90
+ it means no lower bound.
91
+ end (`str | None`, optional):
92
+ Inclusive upper bound timestamp. If not provided,
93
+ it means no upper bound.
94
+
95
+ Returns:
96
+ `Self`:
97
+ Filtered subgraph.
98
+ """
99
+ matched = [
100
+ n for n in self.nodes
101
+ if (start is None or n.created_at >= start)
102
+ and (end is None or n.created_at <= end)
103
+ ]
104
+ matched_ids = {n.full_node_id for n in matched}
105
+ kept_edges = [
106
+ e for e in self.edges
107
+ if e.source_full_node_id in matched_ids and e.target_full_node_id in matched_ids
108
+ ]
109
+ kept_op_ids = {e.op_id for e in kept_edges}
110
+ kept_ops = [o for o in self.ops if o.op_id in kept_op_ids]
111
+ return type(self)(nodes=matched, edges=kept_edges, ops=kept_ops)
112
+
113
+ def time_range(self) -> tuple[str, str] | None:
114
+ """Return the earliest and latest creation time among all runtime variables.
115
+
116
+ Returns:
117
+ `tuple[str, str] | None`:
118
+ The earliest and latest creation time of variables in the
119
+ runtime graph if the runtime graph is not empty.
120
+ """
121
+ if not self.nodes:
122
+ return None
123
+ times = [n.created_at for n in self.nodes]
124
+ return (min(times), max(times))
125
+
126
+ def get_root_nodes(self) -> list[RuntimeVariable[Any]]:
127
+ """Return nodes with zero in-degree (no incoming edges).
128
+
129
+ Returns:
130
+ `list[RuntimeVariable]`:
131
+ Root nodes.
132
+ """
133
+ targets = {e.target_full_node_id for e in self.edges}
134
+ return [n for n in self.nodes if n.full_node_id not in targets]
135
+
136
+ def get_leaf_nodes(self) -> list[RuntimeVariable[Any]]:
137
+ """Return nodes with zero out-degree (no outgoing edges).
138
+
139
+ Returns:
140
+ `list[RuntimeVariable]`:
141
+ Leaf nodes.
142
+ """
143
+ sources = {e.source_full_node_id for e in self.edges}
144
+ return [n for n in self.nodes if n.full_node_id not in sources]
145
+
146
+ def induced_subgraph(self, full_node_ids: Iterable[str]) -> Self:
147
+ """Return the induced subgraph over the given node identifiers.
148
+
149
+ Nodes not present in this graph are silently skipped.
150
+
151
+ Args:
152
+ full_node_ids (`Iterable[str]`):
153
+ The full node identifiers to include.
154
+
155
+ Returns:
156
+ `Self`:
157
+ A sub-view containing only the specified nodes and the
158
+ edges whose both endpoints are in the set.
159
+ """
160
+ id_set = set(full_node_ids)
161
+ matched = [n for n in self.nodes if n.full_node_id in id_set]
162
+ matched_ids = {n.full_node_id for n in matched}
163
+ kept_edges = [
164
+ e for e in self.edges
165
+ if e.source_full_node_id in matched_ids
166
+ and e.target_full_node_id in matched_ids
167
+ ]
168
+ kept_op_ids = {e.op_id for e in kept_edges}
169
+ kept_ops = [o for o in self.ops if o.op_id in kept_op_ids]
170
+ return type(self)(nodes=matched, edges=kept_edges, ops=kept_ops)
171
+
172
+ def __and__(self, other: Self) -> Self:
173
+ """Return the intersection of two graphs.
174
+
175
+ Args:
176
+ other (`Self`):
177
+ The other graph.
178
+
179
+ Returns:
180
+ `Self`:
181
+ The intersection graph.
182
+ """
183
+ if not isinstance(other, RuntimeGraph):
184
+ return NotImplemented
185
+
186
+ common_node_ids = self._node_ids & other._node_ids
187
+ common_edge_ids = self._edge_ids & other._edge_ids
188
+
189
+ nodes = [n for n in self.nodes if n.full_node_id in common_node_ids]
190
+ edges = [
191
+ e for e in self.edges
192
+ if e.edge_id in common_edge_ids
193
+ and e.source_full_node_id in common_node_ids
194
+ and e.target_full_node_id in common_node_ids
195
+ ]
196
+ kept_op_ids = {e.op_id for e in edges}
197
+ ops = [o for o in self.ops if o.op_id in kept_op_ids]
198
+
199
+ return type(self)(nodes=nodes, edges=edges, ops=ops)
200
+
201
+ def __or__(self, other: Self) -> Self:
202
+ """Return the union of two graphs.
203
+
204
+ Args:
205
+ other (`Self`):
206
+ The other graph.
207
+
208
+ Returns:
209
+ `Self`:
210
+ The union graph.
211
+ """
212
+ if not isinstance(other, RuntimeGraph):
213
+ return NotImplemented
214
+
215
+ node_map= {
216
+ n.full_node_id: n for n in self.nodes
217
+ }
218
+ for n in other.nodes:
219
+ node_map.setdefault(n.full_node_id, n)
220
+
221
+ op_map = {o.op_id: o for o in self.ops}
222
+ for o in other.ops:
223
+ op_map.setdefault(o.op_id, o)
224
+
225
+ edge_map = {e.edge_id: e for e in self.edges}
226
+ for e in other.edges:
227
+ edge_map.setdefault(e.edge_id, e)
228
+
229
+ return type(self)(
230
+ nodes=list(node_map.values()),
231
+ edges=list(edge_map.values()),
232
+ ops=list(op_map.values()),
233
+ )
234
+
235
+ def __sub__(self, other: Self) -> Self:
236
+ """Return the difference.
237
+
238
+ Args:
239
+ other (`Self`):
240
+ The other graph.
241
+
242
+ Returns:
243
+ `Self`:
244
+ The difference graph.
245
+ """
246
+ if not isinstance(other, RuntimeGraph):
247
+ return NotImplemented
248
+
249
+ diff_node_ids = self._node_ids - other._node_ids
250
+ diff_op_ids = self._op_ids - other._op_ids
251
+
252
+ nodes = [n for n in self.nodes if n.full_node_id in diff_node_ids]
253
+ edges = [
254
+ e for e in self.edges
255
+ if e.op_id in diff_op_ids
256
+ and e.source_full_node_id in diff_node_ids
257
+ and e.target_full_node_id in diff_node_ids
258
+ ]
259
+ kept_op_ids = {e.op_id for e in edges}
260
+ ops = [o for o in self.ops if o.op_id in kept_op_ids]
261
+
262
+ return type(self)(nodes=nodes, edges=edges, ops=ops)
263
+
264
+ def __xor__(self, other: Self) -> Self:
265
+ """Return the symmetric difference of two graphs.
266
+
267
+ Args:
268
+ other (`Self`):
269
+ The other graph.
270
+
271
+ Returns:
272
+ `Self`:
273
+ The symmetric-difference graph.
274
+ """
275
+ if not isinstance(other, RuntimeGraph):
276
+ return NotImplemented
277
+
278
+ xor_node_ids = self._node_ids ^ other._node_ids
279
+ xor_op_ids = self._op_ids ^ other._op_ids
280
+ xor_edge_ids = self._edge_ids ^ other._edge_ids
281
+
282
+ node_map = {}
283
+ for n in (*self.nodes, *other.nodes):
284
+ if n.full_node_id in xor_node_ids:
285
+ node_map.setdefault(n.full_node_id, n)
286
+
287
+ edges = [
288
+ e for e in (*self.edges, *other.edges)
289
+ if e.edge_id in xor_edge_ids
290
+ and e.op_id in xor_op_ids
291
+ and e.source_full_node_id in xor_node_ids
292
+ and e.target_full_node_id in xor_node_ids
293
+ ]
294
+
295
+ kept_op_ids = {e.op_id for e in edges}
296
+ op_map = {}
297
+ for o in (*self.ops, *other.ops):
298
+ if o.op_id in kept_op_ids:
299
+ op_map.setdefault(o.op_id, o)
300
+
301
+ return type(self)(
302
+ nodes=list(node_map.values()),
303
+ edges=edges,
304
+ ops=list(op_map.values()),
305
+ )
306
+
307
+ def __eq__(self, other: object) -> bool:
308
+ """Check structural equality by node, operation, and edge identifiers."""
309
+ if not isinstance(other, RuntimeGraph):
310
+ return NotImplemented
311
+ return (
312
+ self._node_ids == other._node_ids
313
+ and self._op_ids == other._op_ids
314
+ and self._edge_ids == other._edge_ids
315
+ )
316
+
317
+ def issubset(self, other: Self) -> bool:
318
+ """Check whether every node, op, and edge in this graph also
319
+ exists in another graph.
320
+
321
+ Args:
322
+ other (`Self`):
323
+ The candidate supergraph.
324
+
325
+ Returns:
326
+ `bool`:
327
+ ``True`` if this graph is a subgraph of another graph.
328
+ """
329
+ return (
330
+ self._node_ids <= other._node_ids
331
+ and self._op_ids <= other._op_ids
332
+ and self._edge_ids <= other._edge_ids
333
+ )
334
+
335
+ def issuperset(self, other: Self) -> bool:
336
+ """Check whether every node, operation, and edge in another graph also
337
+ exists in this graph.
338
+
339
+ Args:
340
+ other (`Self`):
341
+ The candidate subgraph.
342
+
343
+ Returns:
344
+ `bool`:
345
+ ``True`` if this graph is a supergraph of another graph.
346
+ """
347
+ return (
348
+ self._node_ids >= other._node_ids
349
+ and self._op_ids >= other._op_ids
350
+ and self._edge_ids >= other._edge_ids
351
+ )
352
+
353
+ def __le__(self, other: Self) -> bool:
354
+ if not isinstance(other, RuntimeGraph):
355
+ return NotImplemented
356
+ return self.issubset(other)
357
+
358
+ def __ge__(self, other: Self) -> bool:
359
+ if not isinstance(other, RuntimeGraph):
360
+ return NotImplemented
361
+ return self.issuperset(other)
362
+
363
+ def __lt__(self, other: Self) -> bool:
364
+ if not isinstance(other, RuntimeGraph):
365
+ return NotImplemented
366
+ return self != other and self.issubset(other)
367
+
368
+ def __gt__(self, other: Self) -> bool:
369
+ if not isinstance(other, RuntimeGraph):
370
+ return NotImplemented
371
+ return self != other and self.issuperset(other)
372
+
373
+ @property
374
+ def edge_count(self) -> int:
375
+ """Return the number of edges in the graph."""
376
+ return len(self.edges)
377
+
378
+ @property
379
+ def op_count(self) -> int:
380
+ """Return the number of operations in the graph."""
381
+ return len(self.ops)
382
+
383
+ @property
384
+ def is_empty(self) -> bool:
385
+ """Check whether the graph contains no nodes."""
386
+ return len(self.nodes) == 0
387
+
388
+ def visualize(self, backend: str = "graphviz", **kwargs: Any) -> Any:
389
+ """Render this subgraph using a visualization backend.
390
+
391
+ Args:
392
+ backend (`str`, defaults to `"graphviz"`):
393
+ Visualization backend name.
394
+ **kwargs (`Any`):
395
+ Forwarded to the backend.
396
+
397
+ Returns:
398
+ `Any`:
399
+ The return value of the visualization backend.
400
+ """
401
+ if backend not in _VISUAL_BACKENDS:
402
+ raise ValueError(
403
+ f"'{backend}' is not a supported visualization backend. "
404
+ "Available visualization backends "
405
+ f"are {', '.join(list(_VISUAL_BACKENDS.keys()))}."
406
+ )
407
+ return _VISUAL_BACKENDS[backend](
408
+ self.nodes,
409
+ self.edges,
410
+ **kwargs
411
+ )
412
+
413
+ def __len__(self) -> int:
414
+ """Get the number of nodes in the graph."""
415
+ return len(self.nodes)
416
+
417
+ def __repr__(self) -> str:
418
+ """Get the string representation of the graph."""
419
+ return (
420
+ f"{self.__class__.__name__}(nodes={len(self.nodes)}, "
421
+ f"edges={len(self.edges)}, "
422
+ f"ops={len(self.ops)})"
423
+ )
424
+
425
+ def to_xml(
426
+ self,
427
+ *,
428
+ include_metadata: bool = False,
429
+ include_variable_value: bool = True,
430
+ ) -> str:
431
+ """Serialize the subgraph to an XML-like string for large language
432
+ model consumption.
433
+
434
+ Args:
435
+ include_metadata (`bool`, defaults to `False`):
436
+ When it is enabled, include metadata as a JSON string element.
437
+ include_variable_value (`bool`, defaults to `True`):
438
+ When it is enabled, include stored values for variable nodes.
439
+
440
+ Returns:
441
+ `str`:
442
+ XML representation.
443
+ """
444
+ if not self.nodes:
445
+ return "<graph />"
446
+
447
+ parts = ["<graph>"]
448
+ parts.append(" <nodes>")
449
+ for n in self.nodes:
450
+ for line in n.to_xml(
451
+ include_metadata=include_metadata,
452
+ include_variable_value=include_variable_value,
453
+ ).splitlines():
454
+ parts.append(f" {line}")
455
+ parts.append(" </nodes>")
456
+
457
+ if self.edges:
458
+ parts.append(" <edges>")
459
+ for e in self.edges:
460
+ for line in e.to_xml(include_metadata=include_metadata).splitlines():
461
+ parts.append(f" {line}")
462
+ parts.append(" </edges>")
463
+
464
+ if self.ops:
465
+ parts.append(" <operations>")
466
+ for o in self.ops:
467
+ for line in o.to_xml(include_metadata=include_metadata).splitlines():
468
+ parts.append(f" {line}")
469
+ parts.append(" </operations>")
470
+
471
+ parts.append("</graph>")
472
+ return "\n".join(parts)
473
+
474
+ def to_markdown(
475
+ self,
476
+ *,
477
+ include_metadata: bool = False,
478
+ include_variable_value: bool = True,
479
+ ) -> str:
480
+ """Serialize the subgraph to Markdown for large language model consumption.
481
+
482
+ Args:
483
+ include_metadata (`bool`, defaults to `False`):
484
+ When it is enabled, include metadata as a JSON string.
485
+ include_variable_value (`bool`, defaults to `True`):
486
+ When it is enabled, include stored values for variable nodes.
487
+
488
+ Returns:
489
+ `str`:
490
+ Markdown representation.
491
+ """
492
+ lines = ["## Graph"]
493
+
494
+ if not self.nodes:
495
+ lines.append("\n*Empty graph.*")
496
+ return "\n".join(lines)
497
+
498
+ lines.append(f"\n### Nodes ({len(self.nodes)})\n")
499
+ for n in self.nodes:
500
+ lines.append(
501
+ n.to_markdown(
502
+ include_metadata=include_metadata,
503
+ include_variable_value=include_variable_value,
504
+ )
505
+ )
506
+ lines.append("")
507
+
508
+ if self.edges:
509
+ lines.append(f"### Edges ({len(self.edges)})\n")
510
+ for e in self.edges:
511
+ lines.append(e.to_markdown(include_metadata=include_metadata))
512
+ lines.append("")
513
+
514
+ if self.ops:
515
+ lines.append(f"### Operations ({len(self.ops)})\n")
516
+ for o in self.ops:
517
+ lines.append(o.to_markdown(include_metadata=include_metadata))
518
+ lines.append("")
519
+
520
+ return "\n".join(lines)