graphrefly 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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2249 @@
|
|
|
1
|
+
"""Graph container — GRAPHREFLY-SPEC §3.1–3.8 (Phase 1.1–1.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
from collections import deque
|
|
11
|
+
from contextlib import contextmanager, suppress
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
14
|
+
|
|
15
|
+
from graphrefly.core.clock import monotonic_ns
|
|
16
|
+
from graphrefly.core.guard import GuardDenied, normalize_actor
|
|
17
|
+
from graphrefly.core.meta import describe_node
|
|
18
|
+
from graphrefly.core.node import NodeImpl
|
|
19
|
+
from graphrefly.core.protocol import Messages, MessageType, is_batching, message_tier
|
|
20
|
+
from graphrefly.core.sugar import state
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Iterator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class TraceEntry:
|
|
28
|
+
"""Single entry in the :meth:`Graph.trace_log` ring buffer."""
|
|
29
|
+
|
|
30
|
+
timestamp_ns: int
|
|
31
|
+
path: str
|
|
32
|
+
reason: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ObserveResult:
|
|
37
|
+
"""Structured observation result from :meth:`Graph.observe` with ``structured=True``.
|
|
38
|
+
|
|
39
|
+
Accumulates events from the observation and provides a ``dispose()`` method
|
|
40
|
+
to unsubscribe.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
dirty_count: int = 0
|
|
45
|
+
resolved_count: int = 0
|
|
46
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
completed_cleanly: bool = False
|
|
48
|
+
errored: bool = False
|
|
49
|
+
_dispose_fn: Callable[[], None] | None = field(default=None, repr=False)
|
|
50
|
+
|
|
51
|
+
def dispose(self) -> None:
|
|
52
|
+
"""Unsubscribe from the observation."""
|
|
53
|
+
if self._dispose_fn is not None:
|
|
54
|
+
self._dispose_fn()
|
|
55
|
+
self._dispose_fn = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class SpyHandle:
|
|
60
|
+
"""Handle returned by :meth:`Graph.spy` (TS parity).
|
|
61
|
+
|
|
62
|
+
Exposes the structured accumulator as ``result`` and a top-level ``dispose()``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
result: ObserveResult
|
|
66
|
+
|
|
67
|
+
def dispose(self) -> None:
|
|
68
|
+
self.result.dispose()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class GraphDiffResult:
|
|
73
|
+
"""Result of :meth:`Graph.diff` comparing two describe outputs."""
|
|
74
|
+
|
|
75
|
+
nodesAdded: list[str] = field(default_factory=list)
|
|
76
|
+
nodesRemoved: list[str] = field(default_factory=list)
|
|
77
|
+
nodesChanged: list[dict[str, Any]] = field(default_factory=list)
|
|
78
|
+
edgesAdded: list[dict[str, str]] = field(default_factory=list)
|
|
79
|
+
edgesRemoved: list[dict[str, str]] = field(default_factory=list)
|
|
80
|
+
subgraphsAdded: list[str] = field(default_factory=list)
|
|
81
|
+
subgraphsRemoved: list[str] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, frozen=True)
|
|
85
|
+
class GraphAutoCheckpointHandle:
|
|
86
|
+
dispose: Callable[[], None]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
#: Separator for qualified paths (e.g. ``"parent::child::node"``).
|
|
90
|
+
PATH_SEP = "::"
|
|
91
|
+
|
|
92
|
+
#: Reserved path segment for companion meta nodes (``parent::__meta__::field``).
|
|
93
|
+
#: Local node and mount names must not equal this string.
|
|
94
|
+
GRAPH_META_SEGMENT = "__meta__"
|
|
95
|
+
|
|
96
|
+
#: Backward-compat alias — prefer :data:`GRAPH_META_SEGMENT`.
|
|
97
|
+
META_PATH_SEG = GRAPH_META_SEGMENT
|
|
98
|
+
|
|
99
|
+
#: Snapshot envelope version for :meth:`Graph.snapshot` / :meth:`Graph.restore` /
|
|
100
|
+
#: :meth:`Graph.from_snapshot` (GRAPHREFLY-SPEC §3.8).
|
|
101
|
+
GRAPH_SNAPSHOT_VERSION = 1
|
|
102
|
+
|
|
103
|
+
_GRAPH_DIAGRAM_DIRECTIONS = frozenset({"TD", "LR", "BT", "RL"})
|
|
104
|
+
|
|
105
|
+
#: Sentinel: :meth:`Graph.describe` without ``actor`` returns the full graph (backward compat).
|
|
106
|
+
_DESCRIBE_UNSCOPED = object()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _node_allows_observe(n: NodeImpl[Any], actor: dict[str, Any]) -> bool:
|
|
110
|
+
return n.allows_observe(actor)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_snapshot_envelope(data: dict[str, Any]) -> dict[str, Any]:
|
|
114
|
+
"""Validate flat snapshot envelope (``version`` field) and return ``data``."""
|
|
115
|
+
ver = data.get("version")
|
|
116
|
+
if ver != GRAPH_SNAPSHOT_VERSION:
|
|
117
|
+
msg = f"unsupported snapshot version {ver!r} (expected {GRAPH_SNAPSHOT_VERSION})"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
for key in ("name", "nodes", "edges", "subgraphs"):
|
|
120
|
+
if key not in data:
|
|
121
|
+
msg = f"snapshot missing required key {key!r}"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
if not isinstance(data["name"], str):
|
|
124
|
+
msg = f"snapshot 'name' must be a str, got {type(data['name']).__name__}"
|
|
125
|
+
raise TypeError(msg)
|
|
126
|
+
if not isinstance(data["nodes"], dict):
|
|
127
|
+
msg = f"snapshot 'nodes' must be a dict, got {type(data['nodes']).__name__}"
|
|
128
|
+
raise TypeError(msg)
|
|
129
|
+
if not isinstance(data["edges"], list):
|
|
130
|
+
msg = f"snapshot 'edges' must be a list, got {type(data['edges']).__name__}"
|
|
131
|
+
raise TypeError(msg)
|
|
132
|
+
if not isinstance(data["subgraphs"], list):
|
|
133
|
+
msg = f"snapshot 'subgraphs' must be a list, got {type(data['subgraphs']).__name__}"
|
|
134
|
+
raise TypeError(msg)
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _path_has_meta_segment(path: str) -> bool:
|
|
139
|
+
return GRAPH_META_SEGMENT in path.split(PATH_SEP)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _normalize_diagram_direction(direction: str | None) -> str:
|
|
143
|
+
if direction is None:
|
|
144
|
+
return "LR"
|
|
145
|
+
if direction not in _GRAPH_DIAGRAM_DIRECTIONS:
|
|
146
|
+
valid = ", ".join(sorted(_GRAPH_DIAGRAM_DIRECTIONS))
|
|
147
|
+
raise ValueError(f"invalid diagram direction {direction!r}; expected one of: {valid}")
|
|
148
|
+
return direction
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _d2_direction_from_graph_direction(direction: str) -> str:
|
|
152
|
+
if direction == "TD":
|
|
153
|
+
return "down"
|
|
154
|
+
if direction == "BT":
|
|
155
|
+
return "up"
|
|
156
|
+
if direction == "RL":
|
|
157
|
+
return "left"
|
|
158
|
+
return "right"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _escape_mermaid_label(value: str) -> str:
|
|
162
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _escape_d2_label(value: str) -> str:
|
|
166
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_SPY_ANSI_THEME: dict[str, str] = {
|
|
170
|
+
"data": "\u001b[32m",
|
|
171
|
+
"dirty": "\u001b[33m",
|
|
172
|
+
"resolved": "\u001b[36m",
|
|
173
|
+
"complete": "\u001b[34m",
|
|
174
|
+
"error": "\u001b[31m",
|
|
175
|
+
"derived": "\u001b[35m",
|
|
176
|
+
"path": "\u001b[90m",
|
|
177
|
+
"reset": "\u001b[0m",
|
|
178
|
+
}
|
|
179
|
+
_SPY_NO_COLOR_THEME: dict[str, str] = {
|
|
180
|
+
"data": "",
|
|
181
|
+
"dirty": "",
|
|
182
|
+
"resolved": "",
|
|
183
|
+
"complete": "",
|
|
184
|
+
"error": "",
|
|
185
|
+
"derived": "",
|
|
186
|
+
"path": "",
|
|
187
|
+
"reset": "",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _resolve_spy_theme(theme: str | dict[str, str] | None) -> dict[str, str]:
|
|
192
|
+
if theme in (None, "ansi"):
|
|
193
|
+
return dict(_SPY_ANSI_THEME)
|
|
194
|
+
if theme == "none":
|
|
195
|
+
return dict(_SPY_NO_COLOR_THEME)
|
|
196
|
+
if isinstance(theme, dict):
|
|
197
|
+
out = dict(_SPY_NO_COLOR_THEME)
|
|
198
|
+
for key, value in theme.items():
|
|
199
|
+
if key in out and isinstance(value, str):
|
|
200
|
+
out[key] = value
|
|
201
|
+
return out
|
|
202
|
+
return dict(_SPY_NO_COLOR_THEME)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _message_type_label(msg_type: Any) -> str | None:
|
|
206
|
+
if msg_type is MessageType.DATA:
|
|
207
|
+
return "data"
|
|
208
|
+
if msg_type is MessageType.DIRTY:
|
|
209
|
+
return "dirty"
|
|
210
|
+
if msg_type is MessageType.RESOLVED:
|
|
211
|
+
return "resolved"
|
|
212
|
+
if msg_type is MessageType.COMPLETE:
|
|
213
|
+
return "complete"
|
|
214
|
+
if msg_type is MessageType.ERROR:
|
|
215
|
+
return "error"
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _describe_data(value: Any) -> str:
|
|
220
|
+
if isinstance(value, str):
|
|
221
|
+
return json.dumps(value)
|
|
222
|
+
if isinstance(value, bool | int | float) or value is None:
|
|
223
|
+
return str(value)
|
|
224
|
+
try:
|
|
225
|
+
return json.dumps(value)
|
|
226
|
+
except Exception:
|
|
227
|
+
return "[unserializable]"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _clear_graph_registry(root: Graph) -> None:
|
|
231
|
+
"""Remove all mounts, nodes, and edges without sending messages (after TEARDOWN)."""
|
|
232
|
+
with root._locked():
|
|
233
|
+
mounts = list(root._mounts.items())
|
|
234
|
+
root._mounts.clear()
|
|
235
|
+
root._nodes.clear()
|
|
236
|
+
root._edges.clear()
|
|
237
|
+
for _mn, ch in mounts:
|
|
238
|
+
_clear_graph_registry(ch)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ensure_qualified_mount(root: Graph, qualified: str) -> None:
|
|
242
|
+
"""Create ``mount::...`` chain on ``root`` if missing (``from_snapshot`` helper)."""
|
|
243
|
+
parts = qualified.split(PATH_SEP)
|
|
244
|
+
g = root
|
|
245
|
+
for seg in parts:
|
|
246
|
+
with g._locked():
|
|
247
|
+
in_mounts = seg in g._mounts
|
|
248
|
+
in_nodes = seg in g._nodes
|
|
249
|
+
if in_nodes:
|
|
250
|
+
msg = f"snapshot mount path {qualified!r} collides with existing node {seg!r}"
|
|
251
|
+
raise ValueError(msg)
|
|
252
|
+
if not in_mounts:
|
|
253
|
+
g.mount(seg, Graph(seg))
|
|
254
|
+
with g._locked():
|
|
255
|
+
g = g._mounts[seg]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _owner_graph_and_local(root: Graph, path: str) -> tuple[Graph, str]:
|
|
259
|
+
parts = path.split(PATH_SEP)
|
|
260
|
+
if not parts or any(not p for p in parts):
|
|
261
|
+
raise ValueError(f"invalid path {path!r}")
|
|
262
|
+
if _path_has_meta_segment(path):
|
|
263
|
+
msg = f"expected primary node path without {GRAPH_META_SEGMENT!r} segment, got {path!r}"
|
|
264
|
+
raise ValueError(msg)
|
|
265
|
+
*mounts, local = parts
|
|
266
|
+
g = root
|
|
267
|
+
for seg in mounts:
|
|
268
|
+
with g._locked():
|
|
269
|
+
try:
|
|
270
|
+
g = g._mounts[seg]
|
|
271
|
+
except KeyError as e:
|
|
272
|
+
raise KeyError(f"unknown mount {seg!r} in path {path!r}") from e
|
|
273
|
+
return g, local
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Graph:
|
|
277
|
+
"""Named registry of nodes with explicit edges (pure wires, no transforms).
|
|
278
|
+
|
|
279
|
+
Qualified paths use ``::`` as the segment separator
|
|
280
|
+
(e.g. ``"parent::child::node"``). Registry mutations are serialized on an
|
|
281
|
+
:class:`threading.RLock` when ``thread_safe`` is ``True`` (default).
|
|
282
|
+
|
|
283
|
+
:meth:`connect` is idempotent; :meth:`disconnect` raises :exc:`ValueError`
|
|
284
|
+
if the edge was never registered. Neither method mutates ``NodeImpl``
|
|
285
|
+
dependency lists.
|
|
286
|
+
|
|
287
|
+
:meth:`mount` embeds a child graph (GRAPHREFLY-SPEC §3.4–3.5).
|
|
288
|
+
:meth:`signal` broadcasts to every registered node, its meta companions, and
|
|
289
|
+
all mounted subgraphs. :meth:`describe` returns Appendix-B-shaped JSON;
|
|
290
|
+
:meth:`observe` exposes a live message stream.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
name: Registry name used in ``describe()`` output and diagnostics.
|
|
294
|
+
opts: Optional dict with keys ``thread_safe`` (bool, default ``True``)
|
|
295
|
+
and ``trace_size`` (int ring-buffer size for :meth:`annotate`).
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
```python
|
|
299
|
+
from graphrefly import Graph, state
|
|
300
|
+
g = Graph("demo")
|
|
301
|
+
x = state(0, name="x")
|
|
302
|
+
y = state(0, name="y")
|
|
303
|
+
g.add("x", x)
|
|
304
|
+
g.add("y", y)
|
|
305
|
+
g.connect("x", "y")
|
|
306
|
+
g.set("x", 1)
|
|
307
|
+
assert g.get("y") == 1
|
|
308
|
+
```
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
inspector_enabled: ClassVar[bool] = os.environ.get("NODE_ENV") != "production"
|
|
312
|
+
_factories: ClassVar[list[tuple[str, Callable[[str, dict[str, Any]], NodeImpl[Any]]]]] = []
|
|
313
|
+
|
|
314
|
+
__slots__ = (
|
|
315
|
+
"_annotations",
|
|
316
|
+
"_auto_checkpoint_disposers",
|
|
317
|
+
"_default_versioning_level",
|
|
318
|
+
"_edges",
|
|
319
|
+
"_lock",
|
|
320
|
+
"_mounts",
|
|
321
|
+
"_name",
|
|
322
|
+
"_nodes",
|
|
323
|
+
"_thread_safe",
|
|
324
|
+
"_trace_ring",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def __init__(self, name: str, opts: dict[str, Any] | None = None) -> None:
|
|
328
|
+
"""Create a graph. ``opts`` may include ``thread_safe`` (default ``True``)."""
|
|
329
|
+
if not name:
|
|
330
|
+
msg = "Graph name must be non-empty"
|
|
331
|
+
raise ValueError(msg)
|
|
332
|
+
if PATH_SEP in name:
|
|
333
|
+
msg = f"Graph name must not contain {PATH_SEP!r} (got {name!r})"
|
|
334
|
+
raise ValueError(msg)
|
|
335
|
+
o = dict(opts or {})
|
|
336
|
+
self._name = name
|
|
337
|
+
self._thread_safe = bool(o.get("thread_safe", True))
|
|
338
|
+
self._lock: threading.RLock | None = threading.RLock() if self._thread_safe else None
|
|
339
|
+
self._nodes: dict[str, NodeImpl[Any]] = {}
|
|
340
|
+
self._mounts: dict[str, Graph] = {}
|
|
341
|
+
self._edges: set[tuple[str, str]] = set()
|
|
342
|
+
self._annotations: dict[str, str] = {}
|
|
343
|
+
self._trace_ring: deque[TraceEntry] = deque(maxlen=1000)
|
|
344
|
+
self._auto_checkpoint_disposers: set[Callable[[], None]] = set()
|
|
345
|
+
self._default_versioning_level: int | None = None
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def name(self) -> str:
|
|
349
|
+
return self._name
|
|
350
|
+
|
|
351
|
+
@classmethod
|
|
352
|
+
def register_factory(
|
|
353
|
+
cls, pattern: str, factory: Callable[[str, dict[str, Any]], NodeImpl[Any]]
|
|
354
|
+
) -> None:
|
|
355
|
+
if not pattern:
|
|
356
|
+
raise ValueError("Graph.register_factory requires a non-empty pattern")
|
|
357
|
+
cls.unregister_factory(pattern)
|
|
358
|
+
cls._factories.append((pattern, factory))
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def unregister_factory(cls, pattern: str) -> None:
|
|
362
|
+
cls._factories = [entry for entry in cls._factories if entry[0] != pattern]
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def _factory_for_path(cls, path: str) -> Callable[[str, dict[str, Any]], NodeImpl[Any]] | None:
|
|
366
|
+
for pattern, factory in reversed(cls._factories):
|
|
367
|
+
if fnmatch.fnmatchcase(path, pattern):
|
|
368
|
+
return factory
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
@contextmanager
|
|
372
|
+
def _locked(self) -> Iterator[None]:
|
|
373
|
+
lock = self._lock
|
|
374
|
+
if lock is not None:
|
|
375
|
+
with lock:
|
|
376
|
+
yield
|
|
377
|
+
else:
|
|
378
|
+
yield
|
|
379
|
+
|
|
380
|
+
def add(self, node_name: str, n: NodeImpl[Any]) -> None:
|
|
381
|
+
"""Register a node under the given name in this graph.
|
|
382
|
+
|
|
383
|
+
Sets ``n``'s internal name to ``node_name`` when the node has no name set.
|
|
384
|
+
Raises :exc:`ValueError` if ``node_name`` is already registered.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
node_name: Local name (no ``::`` separators).
|
|
388
|
+
n: The :class:`~graphrefly.core.node.NodeImpl` to register.
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
```python
|
|
392
|
+
from graphrefly import Graph, state
|
|
393
|
+
g = Graph("g")
|
|
394
|
+
g.add("x", state(0))
|
|
395
|
+
```
|
|
396
|
+
"""
|
|
397
|
+
if not node_name:
|
|
398
|
+
raise ValueError("node name must be non-empty")
|
|
399
|
+
if node_name == GRAPH_META_SEGMENT:
|
|
400
|
+
msg = f"reserved name {GRAPH_META_SEGMENT!r} cannot be used as a local node name"
|
|
401
|
+
raise ValueError(msg)
|
|
402
|
+
if PATH_SEP in node_name:
|
|
403
|
+
msg = f"local node name must not contain {PATH_SEP!r} (path separator)"
|
|
404
|
+
raise ValueError(msg)
|
|
405
|
+
with self._locked():
|
|
406
|
+
if node_name in self._mounts:
|
|
407
|
+
raise KeyError(f"name {node_name!r} is already a mounted subgraph")
|
|
408
|
+
if node_name in self._nodes:
|
|
409
|
+
raise KeyError(f"duplicate node name: {node_name!r}")
|
|
410
|
+
for existing_name, existing in self._nodes.items():
|
|
411
|
+
if existing is n:
|
|
412
|
+
raise ValueError(f"node instance already registered as {existing_name!r}")
|
|
413
|
+
if n._name is None:
|
|
414
|
+
object.__setattr__(n, "_name", node_name)
|
|
415
|
+
if self._default_versioning_level is not None:
|
|
416
|
+
n._apply_versioning(self._default_versioning_level)
|
|
417
|
+
self._nodes[node_name] = n
|
|
418
|
+
|
|
419
|
+
def set_versioning(self, level: int | None) -> None:
|
|
420
|
+
"""Set default versioning level for all nodes in this graph (roadmap §6.0).
|
|
421
|
+
|
|
422
|
+
Retroactively upgrades already-registered nodes. Nodes added later via
|
|
423
|
+
:meth:`add` inherit this level unless they already have versioning.
|
|
424
|
+
|
|
425
|
+
**Scope:** Does not propagate to mounted subgraphs. Call
|
|
426
|
+
``set_versioning`` on each child graph separately if needed.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
level: ``0`` for V0, ``1`` for V1, or ``None`` to clear.
|
|
430
|
+
"""
|
|
431
|
+
self._default_versioning_level = level
|
|
432
|
+
if level is None:
|
|
433
|
+
return
|
|
434
|
+
with self._locked():
|
|
435
|
+
for n in self._nodes.values():
|
|
436
|
+
n._apply_versioning(level)
|
|
437
|
+
|
|
438
|
+
def mount(self, mount_name: str, child: Graph) -> None:
|
|
439
|
+
"""Embed a child graph under ``mount_name`` (GRAPHREFLY-SPEC §3.4).
|
|
440
|
+
|
|
441
|
+
Child nodes become addressable as ``"mount_name::local_name"`` from this
|
|
442
|
+
graph. Mount and top-level node names must not collide.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
mount_name: Local name for the mount point (no ``::`` separators).
|
|
446
|
+
child: The :class:`Graph` to embed.
|
|
447
|
+
|
|
448
|
+
Example:
|
|
449
|
+
```python
|
|
450
|
+
from graphrefly import Graph, state
|
|
451
|
+
parent = Graph("parent")
|
|
452
|
+
child = Graph("child")
|
|
453
|
+
child.add("v", state(1))
|
|
454
|
+
parent.mount("sub", child)
|
|
455
|
+
assert parent.get("sub::v") == 1
|
|
456
|
+
```
|
|
457
|
+
"""
|
|
458
|
+
if not mount_name:
|
|
459
|
+
raise ValueError("mount name must be non-empty")
|
|
460
|
+
if PATH_SEP in mount_name:
|
|
461
|
+
msg = f"mount name must not contain {PATH_SEP!r}"
|
|
462
|
+
raise ValueError(msg)
|
|
463
|
+
if child is self:
|
|
464
|
+
raise ValueError("cannot mount a graph into itself")
|
|
465
|
+
if mount_name == GRAPH_META_SEGMENT:
|
|
466
|
+
msg = f"reserved name {GRAPH_META_SEGMENT!r} cannot be used as a mount name"
|
|
467
|
+
raise ValueError(msg)
|
|
468
|
+
with self._locked():
|
|
469
|
+
if mount_name in self._nodes:
|
|
470
|
+
raise KeyError(f"name {mount_name!r} is already a registered node")
|
|
471
|
+
if mount_name in self._mounts:
|
|
472
|
+
raise KeyError(f"duplicate mount name: {mount_name!r}")
|
|
473
|
+
for _m, g in self._mounts.items():
|
|
474
|
+
if g is child:
|
|
475
|
+
raise ValueError("this child graph is already mounted here")
|
|
476
|
+
if child._graph_reachable(self):
|
|
477
|
+
raise ValueError("mount would create a cycle in the graph hierarchy")
|
|
478
|
+
self._mounts[mount_name] = child
|
|
479
|
+
|
|
480
|
+
def remove(self, node_name: str) -> None:
|
|
481
|
+
"""Unregister a node or unmount a subgraph and send ``[[TEARDOWN]]`` to it.
|
|
482
|
+
|
|
483
|
+
Removes the name from the internal registry and sends teardown outside the
|
|
484
|
+
lock to avoid deadlocking against per-node subgraph write locks.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
node_name: Local name of a registered node or mount point.
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
```python
|
|
491
|
+
from graphrefly import Graph, state
|
|
492
|
+
g = Graph("g")
|
|
493
|
+
g.add("x", state(0))
|
|
494
|
+
g.remove("x")
|
|
495
|
+
```
|
|
496
|
+
"""
|
|
497
|
+
if PATH_SEP in node_name:
|
|
498
|
+
msg = "remove() expects a single segment (local node or mount name on this graph)"
|
|
499
|
+
raise ValueError(msg)
|
|
500
|
+
with self._locked():
|
|
501
|
+
if node_name in self._mounts:
|
|
502
|
+
child = self._mounts.pop(node_name)
|
|
503
|
+
self._edges = {
|
|
504
|
+
(f, t) for f, t in self._edges if not self._edge_touches_mount(f, t, node_name)
|
|
505
|
+
}
|
|
506
|
+
elif node_name in self._nodes:
|
|
507
|
+
child = None
|
|
508
|
+
n = self._nodes.pop(node_name)
|
|
509
|
+
self._edges = {(f, t) for f, t in self._edges if f != node_name and t != node_name}
|
|
510
|
+
else:
|
|
511
|
+
raise KeyError(node_name)
|
|
512
|
+
if child is not None:
|
|
513
|
+
_teardown_mounted_graph(child)
|
|
514
|
+
return
|
|
515
|
+
# Registry already dropped this name — teardown must not fail on guards (orphan risk).
|
|
516
|
+
n.down([(MessageType.TEARDOWN,)], internal=True)
|
|
517
|
+
|
|
518
|
+
def _edge_touches_mount(self, f: str, t: str, mount_name: str) -> bool:
|
|
519
|
+
prefix = f"{mount_name}{PATH_SEP}"
|
|
520
|
+
return f == mount_name or t == mount_name or f.startswith(prefix) or t.startswith(prefix)
|
|
521
|
+
|
|
522
|
+
def resolve(self, path: str) -> NodeImpl[Any]:
|
|
523
|
+
"""Return the node at a ``::``-qualified path (GRAPHREFLY-SPEC §3.5).
|
|
524
|
+
|
|
525
|
+
Traverses mounts as needed; handles ``__meta__`` segments for companion
|
|
526
|
+
nodes. If the first segment matches this graph's name it is stripped.
|
|
527
|
+
Raises :exc:`KeyError` for unknown path segments.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
path: Fully-qualified path (e.g. ``"parent::child::node"``).
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
The :class:`~graphrefly.core.node.NodeImpl` at that path.
|
|
534
|
+
|
|
535
|
+
Example:
|
|
536
|
+
```python
|
|
537
|
+
from graphrefly import Graph, state
|
|
538
|
+
g = Graph("g")
|
|
539
|
+
g.add("x", state(42))
|
|
540
|
+
assert g.resolve("x").get() == 42
|
|
541
|
+
```
|
|
542
|
+
"""
|
|
543
|
+
parts = path.split(PATH_SEP)
|
|
544
|
+
if not parts or any(not p for p in parts):
|
|
545
|
+
raise ValueError(f"path must be one or more non-empty {PATH_SEP!r}-separated segments")
|
|
546
|
+
if parts[0] == self._name:
|
|
547
|
+
parts = parts[1:]
|
|
548
|
+
if not parts:
|
|
549
|
+
raise ValueError(f"resolve path ends at graph name only: {path!r}")
|
|
550
|
+
return self._resolve_parts_unlocked(parts, path)
|
|
551
|
+
|
|
552
|
+
def _resolve_parts_unlocked(self, parts: list[str], path: str) -> NodeImpl[Any]:
|
|
553
|
+
head, *tail = parts
|
|
554
|
+
with self._locked():
|
|
555
|
+
if head in self._nodes:
|
|
556
|
+
base = self._nodes[head]
|
|
557
|
+
if not tail:
|
|
558
|
+
return base
|
|
559
|
+
return _finish_resolve_from_node(base, tail, path)
|
|
560
|
+
if head in self._mounts:
|
|
561
|
+
if not tail:
|
|
562
|
+
raise KeyError(f"{path!r} names a subgraph, not a node")
|
|
563
|
+
child = self._mounts[head]
|
|
564
|
+
else:
|
|
565
|
+
raise KeyError(path)
|
|
566
|
+
return child._resolve_parts_unlocked(tail, path)
|
|
567
|
+
|
|
568
|
+
def _resolve_endpoint(self, path: str) -> tuple[Graph, str, NodeImpl[Any]]:
|
|
569
|
+
"""Graph that owns the endpoint, local name in that graph, and node."""
|
|
570
|
+
parts = path.split(PATH_SEP)
|
|
571
|
+
if not parts or any(not p for p in parts):
|
|
572
|
+
raise ValueError(f"path must be one or more non-empty {PATH_SEP!r}-separated segments")
|
|
573
|
+
if GRAPH_META_SEGMENT in parts:
|
|
574
|
+
msg = (
|
|
575
|
+
"connect/disconnect endpoints must be registered graph nodes, "
|
|
576
|
+
f"not meta paths (got {path!r})"
|
|
577
|
+
)
|
|
578
|
+
raise ValueError(msg)
|
|
579
|
+
try:
|
|
580
|
+
return self._resolve_endpoint_parts_unlocked(parts, path)
|
|
581
|
+
except KeyError as e:
|
|
582
|
+
raise KeyError(f"unknown node: {path!r}") from e
|
|
583
|
+
|
|
584
|
+
def _resolve_endpoint_parts_unlocked(
|
|
585
|
+
self, parts: list[str], path: str
|
|
586
|
+
) -> tuple[Graph, str, NodeImpl[Any]]:
|
|
587
|
+
head, *tail = parts
|
|
588
|
+
with self._locked():
|
|
589
|
+
if head in self._nodes:
|
|
590
|
+
if tail:
|
|
591
|
+
raise KeyError(f"{path!r}: {head!r} is a node, not a subgraph")
|
|
592
|
+
return (self, head, self._nodes[head])
|
|
593
|
+
if head in self._mounts:
|
|
594
|
+
if not tail:
|
|
595
|
+
raise KeyError(f"{path!r} names a subgraph, not a node")
|
|
596
|
+
child = self._mounts[head]
|
|
597
|
+
else:
|
|
598
|
+
raise KeyError(path)
|
|
599
|
+
return child._resolve_endpoint_parts_unlocked(tail, path)
|
|
600
|
+
|
|
601
|
+
def _graph_reachable(self, target: Graph) -> bool:
|
|
602
|
+
if self is target:
|
|
603
|
+
return True
|
|
604
|
+
with self._locked():
|
|
605
|
+
children = list(self._mounts.values())
|
|
606
|
+
return any(c._graph_reachable(target) for c in children)
|
|
607
|
+
|
|
608
|
+
def signal(
|
|
609
|
+
self,
|
|
610
|
+
messages: Messages,
|
|
611
|
+
*,
|
|
612
|
+
actor: Any | None = None,
|
|
613
|
+
internal: bool = False,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Deliver messages to every registered node, meta companion, and mounted subgraph.
|
|
616
|
+
|
|
617
|
+
When a node has a guard, it is checked with action ``"signal"``; nodes that
|
|
618
|
+
reject it are silently skipped.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
messages: The :class:`~graphrefly.core.protocol.Messages` to broadcast.
|
|
622
|
+
actor: Optional actor context checked against each node's guard.
|
|
623
|
+
|
|
624
|
+
Example:
|
|
625
|
+
```python
|
|
626
|
+
from graphrefly import Graph, state
|
|
627
|
+
from graphrefly.core.protocol import MessageType
|
|
628
|
+
g = Graph("g")
|
|
629
|
+
g.add("x", state(0))
|
|
630
|
+
g.signal([(MessageType.INVALIDATE,)])
|
|
631
|
+
```
|
|
632
|
+
"""
|
|
633
|
+
_signal_graph(self, messages, set(), normalize_actor(actor), "", internal=internal)
|
|
634
|
+
|
|
635
|
+
def destroy(self) -> None:
|
|
636
|
+
"""Teardown all nodes and clear every registry in this graph and its mounts.
|
|
637
|
+
|
|
638
|
+
Sends ``[[TEARDOWN]]`` to every node in the same visitation order as
|
|
639
|
+
:meth:`signal` (GRAPHREFLY-SPEC §3.7), then recursively clears mounted subgraphs.
|
|
640
|
+
|
|
641
|
+
Example:
|
|
642
|
+
```python
|
|
643
|
+
from graphrefly import Graph, state
|
|
644
|
+
g = Graph("g")
|
|
645
|
+
g.add("x", state(0))
|
|
646
|
+
g.destroy()
|
|
647
|
+
```
|
|
648
|
+
"""
|
|
649
|
+
_signal_graph(
|
|
650
|
+
self,
|
|
651
|
+
[(MessageType.TEARDOWN,)],
|
|
652
|
+
set(),
|
|
653
|
+
normalize_actor(None),
|
|
654
|
+
"",
|
|
655
|
+
internal=True,
|
|
656
|
+
)
|
|
657
|
+
for dispose in list(self._auto_checkpoint_disposers):
|
|
658
|
+
with suppress(Exception):
|
|
659
|
+
dispose()
|
|
660
|
+
self._auto_checkpoint_disposers.clear()
|
|
661
|
+
_clear_graph_registry(self)
|
|
662
|
+
|
|
663
|
+
def snapshot(self) -> dict[str, Any]:
|
|
664
|
+
"""Serialize graph structure and current node values to a JSON-friendly dict (§3.8).
|
|
665
|
+
|
|
666
|
+
Wraps :meth:`describe` output in a versioned envelope required by
|
|
667
|
+
:meth:`restore` / :meth:`from_snapshot`. The format is stable for
|
|
668
|
+
:meth:`to_json` determinism.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
A ``dict`` with keys ``version``, ``name``, ``nodes``, ``edges``,
|
|
672
|
+
and ``subgraphs``.
|
|
673
|
+
|
|
674
|
+
Example:
|
|
675
|
+
```python
|
|
676
|
+
from graphrefly import Graph, state
|
|
677
|
+
g = Graph("g")
|
|
678
|
+
g.add("x", state(42))
|
|
679
|
+
snap = g.snapshot()
|
|
680
|
+
assert snap["version"] == 1
|
|
681
|
+
```
|
|
682
|
+
"""
|
|
683
|
+
body = self.describe()
|
|
684
|
+
nodes_sorted = dict(sorted(body["nodes"].items()))
|
|
685
|
+
subgraphs_sorted = sorted(body["subgraphs"])
|
|
686
|
+
return {
|
|
687
|
+
"version": GRAPH_SNAPSHOT_VERSION,
|
|
688
|
+
"name": body["name"],
|
|
689
|
+
"nodes": nodes_sorted,
|
|
690
|
+
"edges": body["edges"],
|
|
691
|
+
"subgraphs": subgraphs_sorted,
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
def to_json(self) -> str:
|
|
695
|
+
"""Return deterministic JSON text for the current :meth:`snapshot` (§3.8).
|
|
696
|
+
|
|
697
|
+
Produces compact, sorted-key JSON with a trailing newline (suitable for
|
|
698
|
+
version control). Raises :exc:`TypeError` when a node value is not
|
|
699
|
+
JSON-serializable.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
A compact JSON ``str`` ending with a newline.
|
|
703
|
+
|
|
704
|
+
Example:
|
|
705
|
+
```python
|
|
706
|
+
from graphrefly import Graph, state
|
|
707
|
+
g = Graph("g")
|
|
708
|
+
g.add("x", state(0))
|
|
709
|
+
assert g.to_json().startswith("{")
|
|
710
|
+
```
|
|
711
|
+
"""
|
|
712
|
+
return (
|
|
713
|
+
json.dumps(
|
|
714
|
+
self.snapshot(),
|
|
715
|
+
ensure_ascii=False,
|
|
716
|
+
sort_keys=True,
|
|
717
|
+
separators=(",", ":"),
|
|
718
|
+
)
|
|
719
|
+
+ "\n"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def auto_checkpoint(
|
|
723
|
+
self,
|
|
724
|
+
adapter: Any,
|
|
725
|
+
*,
|
|
726
|
+
debounce_ms: int = 500,
|
|
727
|
+
compact_every: int = 10,
|
|
728
|
+
filter: Callable[[str, dict[str, Any]], bool] | None = None,
|
|
729
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
730
|
+
) -> GraphAutoCheckpointHandle:
|
|
731
|
+
"""Arm debounced reactive persistence via graph-wide observe stream.
|
|
732
|
+
|
|
733
|
+
Trigger gate uses :func:`message_tier`: only message batches containing
|
|
734
|
+
tier >= 2 tuples schedule a checkpoint.
|
|
735
|
+
"""
|
|
736
|
+
|
|
737
|
+
lock = threading.Lock()
|
|
738
|
+
timer: threading.Timer | None = None
|
|
739
|
+
seq = 0
|
|
740
|
+
pending = False
|
|
741
|
+
last_describe: dict[str, Any] | None = None
|
|
742
|
+
debounce_s = max(0.0, float(debounce_ms) / 1000.0)
|
|
743
|
+
compact_every = max(1, int(compact_every))
|
|
744
|
+
|
|
745
|
+
def flush() -> None:
|
|
746
|
+
nonlocal timer, seq, pending, last_describe
|
|
747
|
+
with lock:
|
|
748
|
+
timer = None
|
|
749
|
+
if not pending:
|
|
750
|
+
return
|
|
751
|
+
pending = False
|
|
752
|
+
try:
|
|
753
|
+
described = self.describe()
|
|
754
|
+
snapshot = {**described, "version": GRAPH_SNAPSHOT_VERSION}
|
|
755
|
+
seq += 1
|
|
756
|
+
if last_describe is None or seq % compact_every == 0:
|
|
757
|
+
adapter.save({"mode": "full", "snapshot": snapshot, "seq": seq})
|
|
758
|
+
else:
|
|
759
|
+
diff = Graph.diff(last_describe, described)
|
|
760
|
+
adapter.save(
|
|
761
|
+
{"mode": "diff", "diff": diff.__dict__, "snapshot": snapshot, "seq": seq}
|
|
762
|
+
)
|
|
763
|
+
last_describe = described
|
|
764
|
+
except Exception as exc:
|
|
765
|
+
if on_error is not None:
|
|
766
|
+
on_error(exc)
|
|
767
|
+
|
|
768
|
+
def schedule() -> None:
|
|
769
|
+
nonlocal timer, pending
|
|
770
|
+
with lock:
|
|
771
|
+
pending = True
|
|
772
|
+
if timer is not None:
|
|
773
|
+
timer.cancel()
|
|
774
|
+
timer = threading.Timer(debounce_s, flush)
|
|
775
|
+
timer.daemon = True
|
|
776
|
+
timer.start()
|
|
777
|
+
|
|
778
|
+
def on_msgs(path: str, msgs: Messages) -> None:
|
|
779
|
+
if not any(message_tier(m[0]) >= 2 for m in msgs):
|
|
780
|
+
return
|
|
781
|
+
if filter is not None:
|
|
782
|
+
node_desc = self.describe()["nodes"].get(path)
|
|
783
|
+
if not isinstance(node_desc, dict) or not filter(path, node_desc):
|
|
784
|
+
return
|
|
785
|
+
schedule()
|
|
786
|
+
|
|
787
|
+
observe_stream = self.observe()
|
|
788
|
+
if not isinstance(observe_stream, GraphObserveSource):
|
|
789
|
+
raise TypeError("auto_checkpoint expected GraphObserveSource from observe()")
|
|
790
|
+
unsub = observe_stream.subscribe(on_msgs)
|
|
791
|
+
|
|
792
|
+
def dispose() -> None:
|
|
793
|
+
nonlocal timer
|
|
794
|
+
unsub()
|
|
795
|
+
with lock:
|
|
796
|
+
if timer is not None:
|
|
797
|
+
timer.cancel()
|
|
798
|
+
timer = None
|
|
799
|
+
self._auto_checkpoint_disposers.discard(dispose)
|
|
800
|
+
|
|
801
|
+
self._auto_checkpoint_disposers.add(dispose)
|
|
802
|
+
return GraphAutoCheckpointHandle(dispose=dispose)
|
|
803
|
+
|
|
804
|
+
def restore(self, data: dict[str, Any], *, only: str | list[str] | None = None) -> None:
|
|
805
|
+
"""Apply ``value`` fields from a prior :meth:`snapshot` onto this graph (§3.8).
|
|
806
|
+
|
|
807
|
+
Only ``state`` and ``producer`` entries with a ``value`` key are written;
|
|
808
|
+
derived/operator nodes recompute from the restored sources.
|
|
809
|
+
Raises :exc:`ValueError` on snapshot version mismatch.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
data: A snapshot dict previously produced by :meth:`snapshot`.
|
|
813
|
+
|
|
814
|
+
Example:
|
|
815
|
+
```python
|
|
816
|
+
from graphrefly import Graph, state
|
|
817
|
+
g = Graph("g")
|
|
818
|
+
x = state(0)
|
|
819
|
+
g.add("x", x)
|
|
820
|
+
snap = g.snapshot()
|
|
821
|
+
x.down([("DATA", 99)])
|
|
822
|
+
g.restore(snap)
|
|
823
|
+
assert g.get("x") == 0
|
|
824
|
+
```
|
|
825
|
+
"""
|
|
826
|
+
_parse_snapshot_envelope(data)
|
|
827
|
+
if data["name"] != self._name:
|
|
828
|
+
msg = (
|
|
829
|
+
f'Graph "{self._name}": restore snapshot name '
|
|
830
|
+
f'"{data["name"]}" does not match this graph'
|
|
831
|
+
)
|
|
832
|
+
raise ValueError(msg)
|
|
833
|
+
only_patterns = None if only is None else ([only] if isinstance(only, str) else list(only))
|
|
834
|
+
for path in sorted(data["nodes"]):
|
|
835
|
+
if only_patterns is not None and not any(
|
|
836
|
+
fnmatch.fnmatchcase(path, p) for p in only_patterns
|
|
837
|
+
):
|
|
838
|
+
continue
|
|
839
|
+
spec = data["nodes"][path]
|
|
840
|
+
if not isinstance(spec, dict) or "value" not in spec:
|
|
841
|
+
continue
|
|
842
|
+
ntype = spec.get("type")
|
|
843
|
+
if ntype in ("derived", "operator", "effect"):
|
|
844
|
+
continue
|
|
845
|
+
with contextlib.suppress(KeyError, ValueError):
|
|
846
|
+
self.set(path, spec["value"])
|
|
847
|
+
|
|
848
|
+
@classmethod
|
|
849
|
+
def from_snapshot(
|
|
850
|
+
cls,
|
|
851
|
+
data: dict[str, Any],
|
|
852
|
+
build: Any | None = None,
|
|
853
|
+
) -> Graph:
|
|
854
|
+
"""Build a new graph from :meth:`snapshot` data (§3.8).
|
|
855
|
+
|
|
856
|
+
When *build* is provided it is called with the empty graph first, letting
|
|
857
|
+
callers register derived nodes before :meth:`restore` is applied. State
|
|
858
|
+
nodes not pre-registered are created automatically.
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
data: A snapshot dict produced by :meth:`snapshot`.
|
|
862
|
+
build: Optional callable ``(graph) -> None`` to pre-register derived nodes.
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
A new :class:`Graph` with state nodes restored from ``data``.
|
|
866
|
+
|
|
867
|
+
Example:
|
|
868
|
+
```python
|
|
869
|
+
from graphrefly import Graph, state
|
|
870
|
+
g = Graph("g")
|
|
871
|
+
g.add("x", state(42))
|
|
872
|
+
snap = g.snapshot()
|
|
873
|
+
g2 = Graph.from_snapshot(snap)
|
|
874
|
+
assert g2.get("x") == 42
|
|
875
|
+
```
|
|
876
|
+
"""
|
|
877
|
+
_parse_snapshot_envelope(data)
|
|
878
|
+
root = cls(data["name"])
|
|
879
|
+
if build is not None:
|
|
880
|
+
build(root)
|
|
881
|
+
root.restore(data)
|
|
882
|
+
return root
|
|
883
|
+
for q in sorted(data["subgraphs"], key=lambda p: (p.count(PATH_SEP), p)):
|
|
884
|
+
_ensure_qualified_mount(root, q)
|
|
885
|
+
primary_paths = [
|
|
886
|
+
p
|
|
887
|
+
for p in sorted(data["nodes"])
|
|
888
|
+
if not _path_has_meta_segment(p) and isinstance(data["nodes"][p], dict)
|
|
889
|
+
]
|
|
890
|
+
pending: dict[str, dict[str, Any]] = {p: data["nodes"][p] for p in primary_paths}
|
|
891
|
+
created: dict[str, NodeImpl[Any]] = {}
|
|
892
|
+
progressed = True
|
|
893
|
+
while pending and progressed:
|
|
894
|
+
progressed = False
|
|
895
|
+
for path in list(sorted(pending)):
|
|
896
|
+
spec = pending[path]
|
|
897
|
+
deps = spec.get("deps")
|
|
898
|
+
dep_paths = (
|
|
899
|
+
[d for d in deps if isinstance(d, str)] if isinstance(deps, list) else []
|
|
900
|
+
)
|
|
901
|
+
if not all(dep in created for dep in dep_paths):
|
|
902
|
+
continue
|
|
903
|
+
owner, local = _owner_graph_and_local(root, path)
|
|
904
|
+
meta_plain = spec.get("meta")
|
|
905
|
+
meta_kw: dict[str, Any] = dict(meta_plain) if isinstance(meta_plain, dict) else {}
|
|
906
|
+
ntype = spec.get("type", "state")
|
|
907
|
+
if ntype == "state":
|
|
908
|
+
new_node = state(spec.get("value"), meta=meta_kw)
|
|
909
|
+
else:
|
|
910
|
+
factory = cls._factory_for_path(path)
|
|
911
|
+
if factory is None:
|
|
912
|
+
continue
|
|
913
|
+
new_node = factory(
|
|
914
|
+
local,
|
|
915
|
+
{
|
|
916
|
+
"path": path,
|
|
917
|
+
"type": ntype,
|
|
918
|
+
"value": spec.get("value"),
|
|
919
|
+
"meta": meta_kw,
|
|
920
|
+
"deps": dep_paths,
|
|
921
|
+
"resolved_deps": [created[d] for d in dep_paths],
|
|
922
|
+
},
|
|
923
|
+
)
|
|
924
|
+
with owner._locked():
|
|
925
|
+
if local in owner._nodes or local in owner._mounts:
|
|
926
|
+
msg = f"snapshot path {path!r} collides with an existing name"
|
|
927
|
+
raise ValueError(msg)
|
|
928
|
+
owner.add(local, new_node)
|
|
929
|
+
created[path] = new_node
|
|
930
|
+
pending.pop(path, None)
|
|
931
|
+
progressed = True
|
|
932
|
+
if pending:
|
|
933
|
+
unresolved = ", ".join(sorted(pending))
|
|
934
|
+
raise ValueError(
|
|
935
|
+
"Graph.from_snapshot could not reconstruct nodes without build callback: "
|
|
936
|
+
f"{unresolved}. Register factories with Graph.register_factory(pattern, factory)."
|
|
937
|
+
)
|
|
938
|
+
for edge in data["edges"]:
|
|
939
|
+
if not isinstance(edge, dict):
|
|
940
|
+
continue
|
|
941
|
+
ef = edge.get("from")
|
|
942
|
+
et = edge.get("to")
|
|
943
|
+
if not isinstance(ef, str) or not isinstance(et, str):
|
|
944
|
+
continue
|
|
945
|
+
with suppress(Exception):
|
|
946
|
+
root.connect(ef, et)
|
|
947
|
+
root.restore(data)
|
|
948
|
+
return root
|
|
949
|
+
|
|
950
|
+
def describe(
|
|
951
|
+
self,
|
|
952
|
+
*,
|
|
953
|
+
actor: Any = _DESCRIBE_UNSCOPED,
|
|
954
|
+
filter: dict[str, Any] | Callable[..., bool] | None = None,
|
|
955
|
+
) -> dict[str, Any]:
|
|
956
|
+
"""Static structure snapshot (GRAPHREFLY-SPEC §3.6, Appendix B).
|
|
957
|
+
|
|
958
|
+
``nodes`` keys are qualified paths (including ``::__meta__::`` for companions).
|
|
959
|
+
``edges`` use the same qualified naming. ``subgraphs`` lists every mount point
|
|
960
|
+
in the hierarchy with paths from this graph's root.
|
|
961
|
+
|
|
962
|
+
With ``actor=...``, only nodes the actor may observe are included; **edges** whose
|
|
963
|
+
``from`` or ``to`` is hidden are dropped (roadmap 1.5 D). **Subgraphs** are kept
|
|
964
|
+
only if at least one visible node path lies under that mount prefix.
|
|
965
|
+
Omitting ``actor`` preserves the previous unfiltered behavior.
|
|
966
|
+
|
|
967
|
+
With ``filter=...``, further restrict the output:
|
|
968
|
+
|
|
969
|
+
- If ``filter`` is a ``dict``, each key is matched against node description fields
|
|
970
|
+
(e.g. ``{"type": "state"}`` keeps only nodes whose ``type`` is ``"state"``).
|
|
971
|
+
- If ``filter`` is a callable, it receives ``(path, node_desc)`` and must return
|
|
972
|
+
``True`` to include the node.
|
|
973
|
+
|
|
974
|
+
Args:
|
|
975
|
+
actor: Optional actor for guard-based filtering.
|
|
976
|
+
filter: Optional dict or predicate to filter nodes in the output.
|
|
977
|
+
"""
|
|
978
|
+
targets = _collect_observe_targets(self, "")
|
|
979
|
+
paths_by_id = {id(n): p for p, n in targets}
|
|
980
|
+
nodes_out = {p: _node_describe_for_graph(n, paths_by_id) for p, n in targets}
|
|
981
|
+
edges_out = _collect_edges_qualified(self, "")
|
|
982
|
+
edges_out.sort(key=lambda e: (e["from"], e["to"]))
|
|
983
|
+
subgraphs_out = _collect_subgraphs_qualified(self, "")
|
|
984
|
+
nodes_map: dict[str, Any] = nodes_out
|
|
985
|
+
raw: dict[str, Any] = {
|
|
986
|
+
"name": self._name,
|
|
987
|
+
"nodes": nodes_map,
|
|
988
|
+
"edges": edges_out,
|
|
989
|
+
"subgraphs": subgraphs_out,
|
|
990
|
+
}
|
|
991
|
+
if actor is not _DESCRIBE_UNSCOPED:
|
|
992
|
+
a = normalize_actor(actor)
|
|
993
|
+
visible = {p for p, n in targets if _node_allows_observe(n, a)}
|
|
994
|
+
nodes_map = {k: v for k, v in nodes_map.items() if k in visible}
|
|
995
|
+
edges_out = [e for e in edges_out if e["from"] in visible and e["to"] in visible]
|
|
996
|
+
sep = PATH_SEP
|
|
997
|
+
subgraphs_out = [
|
|
998
|
+
s
|
|
999
|
+
for s in subgraphs_out
|
|
1000
|
+
if any(p == s or p.startswith(f"{s}{sep}") for p in visible)
|
|
1001
|
+
]
|
|
1002
|
+
raw = {**raw, "nodes": nodes_map, "edges": edges_out, "subgraphs": subgraphs_out}
|
|
1003
|
+
if filter is not None:
|
|
1004
|
+
if callable(filter):
|
|
1005
|
+
nodes_map = {p: d for p, d in raw["nodes"].items() if filter(p, d)}
|
|
1006
|
+
elif isinstance(filter, dict):
|
|
1007
|
+
|
|
1008
|
+
def _match(desc: dict[str, Any]) -> bool:
|
|
1009
|
+
for k, v in filter.items():
|
|
1010
|
+
if k in ("deps_includes", "depsIncludes"):
|
|
1011
|
+
deps = desc.get("deps")
|
|
1012
|
+
if not isinstance(deps, list) or str(v) not in deps:
|
|
1013
|
+
return False
|
|
1014
|
+
continue
|
|
1015
|
+
if k in ("meta_has", "metaHas"):
|
|
1016
|
+
meta = desc.get("meta")
|
|
1017
|
+
if not isinstance(meta, dict) or str(v) not in meta:
|
|
1018
|
+
return False
|
|
1019
|
+
continue
|
|
1020
|
+
if desc.get(k) != v:
|
|
1021
|
+
return False
|
|
1022
|
+
return True
|
|
1023
|
+
|
|
1024
|
+
nodes_map = {p: d for p, d in raw["nodes"].items() if _match(d)}
|
|
1025
|
+
visible_after_filter = set(nodes_map.keys())
|
|
1026
|
+
edges_out = [
|
|
1027
|
+
e
|
|
1028
|
+
for e in raw["edges"]
|
|
1029
|
+
if e["from"] in visible_after_filter and e["to"] in visible_after_filter
|
|
1030
|
+
]
|
|
1031
|
+
sep = PATH_SEP
|
|
1032
|
+
subgraphs_out = [
|
|
1033
|
+
s
|
|
1034
|
+
for s in raw["subgraphs"]
|
|
1035
|
+
if any(p == s or p.startswith(f"{s}{sep}") for p in visible_after_filter)
|
|
1036
|
+
]
|
|
1037
|
+
raw = {**raw, "nodes": nodes_map, "edges": edges_out, "subgraphs": subgraphs_out}
|
|
1038
|
+
return raw
|
|
1039
|
+
|
|
1040
|
+
def observe(
|
|
1041
|
+
self,
|
|
1042
|
+
path: str | None = None,
|
|
1043
|
+
*,
|
|
1044
|
+
actor: Any | None = None,
|
|
1045
|
+
structured: bool = False,
|
|
1046
|
+
timeline: bool = False,
|
|
1047
|
+
causal: bool = False,
|
|
1048
|
+
derived: bool = False,
|
|
1049
|
+
) -> GraphObserveSource | ObserveResult:
|
|
1050
|
+
"""Live message stream for one node (and its path) or the whole graph (§3.6).
|
|
1051
|
+
|
|
1052
|
+
Use :meth:`GraphObserveSource.subscribe` to attach a sink. Graph-wide mode
|
|
1053
|
+
prefixes each batch with the node's qualified path.
|
|
1054
|
+
|
|
1055
|
+
Nodes with a ``guard`` require ``actor`` such that ``guard(actor, "observe")`` is
|
|
1056
|
+
true (default actor is system — :func:`~graphrefly.core.guard.system_actor`).
|
|
1057
|
+
|
|
1058
|
+
When ``structured=True`` (or ``timeline`` / ``causal`` / ``derived``),
|
|
1059
|
+
returns an :class:`ObserveResult` that accumulates
|
|
1060
|
+
events, tracks counts, and provides a ``dispose()`` method.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
path: Optional node path (``None`` for graph-wide).
|
|
1064
|
+
actor: Optional actor for guard checking.
|
|
1065
|
+
structured: If ``True``, return an :class:`ObserveResult` instead of
|
|
1066
|
+
a raw :class:`GraphObserveSource`.
|
|
1067
|
+
timeline: Include ``timestamp_ns`` and ``in_batch`` on events.
|
|
1068
|
+
causal: Include trigger dep info (single-path derived/compute nodes).
|
|
1069
|
+
derived: Include per-evaluation dep snapshots (single-path derived/compute nodes).
|
|
1070
|
+
"""
|
|
1071
|
+
source = GraphObserveSource(self, path, actor)
|
|
1072
|
+
wants_structured = structured or timeline or causal or derived
|
|
1073
|
+
if not wants_structured or not self.inspector_enabled:
|
|
1074
|
+
return source
|
|
1075
|
+
result = ObserveResult()
|
|
1076
|
+
last_trigger_dep_index: int | None = None
|
|
1077
|
+
last_run_dep_values: list[Any] | None = None
|
|
1078
|
+
detach_hook: Callable[[], None] | None = None
|
|
1079
|
+
|
|
1080
|
+
def _base_event(
|
|
1081
|
+
evt_type: str, *, data: Any = None, event_path: str | None = None
|
|
1082
|
+
) -> dict[str, Any]:
|
|
1083
|
+
entry: dict[str, Any] = {"type": evt_type}
|
|
1084
|
+
if event_path is not None:
|
|
1085
|
+
entry["path"] = event_path
|
|
1086
|
+
if data is not None:
|
|
1087
|
+
entry["data"] = data
|
|
1088
|
+
if timeline:
|
|
1089
|
+
entry["timestamp_ns"] = monotonic_ns()
|
|
1090
|
+
entry["in_batch"] = is_batching()
|
|
1091
|
+
return entry
|
|
1092
|
+
|
|
1093
|
+
if (causal or derived) and path is not None:
|
|
1094
|
+
n = self.node(path)
|
|
1095
|
+
if isinstance(n, NodeImpl):
|
|
1096
|
+
|
|
1097
|
+
def _hook(event: dict[str, Any]) -> None:
|
|
1098
|
+
nonlocal last_trigger_dep_index, last_run_dep_values
|
|
1099
|
+
kind = event.get("kind")
|
|
1100
|
+
if kind == "dep_message":
|
|
1101
|
+
idx = event.get("dep_index")
|
|
1102
|
+
last_trigger_dep_index = int(idx) if isinstance(idx, int) else None
|
|
1103
|
+
return
|
|
1104
|
+
if kind == "run":
|
|
1105
|
+
dep_vals_raw = event.get("dep_values")
|
|
1106
|
+
dep_vals = (
|
|
1107
|
+
list(dep_vals_raw)
|
|
1108
|
+
if isinstance(dep_vals_raw, list)
|
|
1109
|
+
else list(dep_vals_raw or [])
|
|
1110
|
+
)
|
|
1111
|
+
last_run_dep_values = dep_vals
|
|
1112
|
+
if derived:
|
|
1113
|
+
de = _base_event("derived")
|
|
1114
|
+
de["dep_values"] = dep_vals
|
|
1115
|
+
result.events.append(de)
|
|
1116
|
+
|
|
1117
|
+
detach_hook = n._set_inspector_hook(_hook)
|
|
1118
|
+
|
|
1119
|
+
if path is not None:
|
|
1120
|
+
|
|
1121
|
+
def _sink(msgs: Messages) -> None:
|
|
1122
|
+
for m in msgs:
|
|
1123
|
+
t = m[0]
|
|
1124
|
+
if t is MessageType.DATA:
|
|
1125
|
+
val = m[1] if len(m) > 1 else None
|
|
1126
|
+
result.values[path] = val
|
|
1127
|
+
event = _base_event("data", data=val)
|
|
1128
|
+
if causal and last_run_dep_values is not None:
|
|
1129
|
+
event["trigger_dep_index"] = last_trigger_dep_index
|
|
1130
|
+
trigger_dep = None
|
|
1131
|
+
if (
|
|
1132
|
+
isinstance(last_trigger_dep_index, int)
|
|
1133
|
+
and last_trigger_dep_index >= 0
|
|
1134
|
+
and isinstance(n, NodeImpl)
|
|
1135
|
+
and last_trigger_dep_index < len(n._deps)
|
|
1136
|
+
):
|
|
1137
|
+
trigger_dep = n._deps[last_trigger_dep_index]
|
|
1138
|
+
event["trigger_dep_name"] = trigger_dep.name
|
|
1139
|
+
# V0 backfill: include triggering dep's version (§6.0b).
|
|
1140
|
+
tv = trigger_dep.v if trigger_dep is not None else None
|
|
1141
|
+
if tv is not None:
|
|
1142
|
+
event["trigger_version"] = {"id": tv.id, "version": tv.version}
|
|
1143
|
+
event["dep_values"] = list(last_run_dep_values)
|
|
1144
|
+
result.events.append(event)
|
|
1145
|
+
elif t is MessageType.DIRTY:
|
|
1146
|
+
result.dirty_count += 1
|
|
1147
|
+
result.events.append(_base_event("dirty"))
|
|
1148
|
+
elif t is MessageType.RESOLVED:
|
|
1149
|
+
result.resolved_count += 1
|
|
1150
|
+
event = _base_event("resolved")
|
|
1151
|
+
if causal and last_run_dep_values is not None:
|
|
1152
|
+
event["trigger_dep_index"] = last_trigger_dep_index
|
|
1153
|
+
trigger_dep = None
|
|
1154
|
+
if (
|
|
1155
|
+
isinstance(last_trigger_dep_index, int)
|
|
1156
|
+
and last_trigger_dep_index >= 0
|
|
1157
|
+
and isinstance(n, NodeImpl)
|
|
1158
|
+
and last_trigger_dep_index < len(n._deps)
|
|
1159
|
+
):
|
|
1160
|
+
trigger_dep = n._deps[last_trigger_dep_index]
|
|
1161
|
+
event["trigger_dep_name"] = trigger_dep.name
|
|
1162
|
+
# V0 backfill: include triggering dep's version (§6.0b).
|
|
1163
|
+
tv = trigger_dep.v if trigger_dep is not None else None
|
|
1164
|
+
if tv is not None:
|
|
1165
|
+
event["trigger_version"] = {"id": tv.id, "version": tv.version}
|
|
1166
|
+
event["dep_values"] = list(last_run_dep_values)
|
|
1167
|
+
result.events.append(event)
|
|
1168
|
+
elif t is MessageType.COMPLETE:
|
|
1169
|
+
if not result.errored:
|
|
1170
|
+
result.completed_cleanly = True
|
|
1171
|
+
result.events.append(_base_event("complete"))
|
|
1172
|
+
elif t is MessageType.ERROR:
|
|
1173
|
+
result.errored = True
|
|
1174
|
+
result.events.append(
|
|
1175
|
+
_base_event("error", data=m[1] if len(m) > 1 else None)
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
unsub = source.subscribe(_sink)
|
|
1179
|
+
else:
|
|
1180
|
+
|
|
1181
|
+
def _graph_sink(qpath: str, msgs: Messages) -> None:
|
|
1182
|
+
for m in msgs:
|
|
1183
|
+
t = m[0]
|
|
1184
|
+
if t is MessageType.DATA:
|
|
1185
|
+
val = m[1] if len(m) > 1 else None
|
|
1186
|
+
result.values[qpath] = val
|
|
1187
|
+
result.events.append(_base_event("data", data=val, event_path=qpath))
|
|
1188
|
+
elif t is MessageType.DIRTY:
|
|
1189
|
+
result.dirty_count += 1
|
|
1190
|
+
result.events.append(_base_event("dirty", event_path=qpath))
|
|
1191
|
+
elif t is MessageType.RESOLVED:
|
|
1192
|
+
result.resolved_count += 1
|
|
1193
|
+
result.events.append(_base_event("resolved", event_path=qpath))
|
|
1194
|
+
elif t is MessageType.COMPLETE:
|
|
1195
|
+
if not result.errored:
|
|
1196
|
+
result.completed_cleanly = True
|
|
1197
|
+
result.events.append(_base_event("complete", event_path=qpath))
|
|
1198
|
+
elif t is MessageType.ERROR:
|
|
1199
|
+
result.errored = True
|
|
1200
|
+
result.events.append(
|
|
1201
|
+
_base_event(
|
|
1202
|
+
"error",
|
|
1203
|
+
data=m[1] if len(m) > 1 else None,
|
|
1204
|
+
event_path=qpath,
|
|
1205
|
+
)
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
unsub = source.subscribe(_graph_sink)
|
|
1209
|
+
|
|
1210
|
+
def _dispose() -> None:
|
|
1211
|
+
unsub()
|
|
1212
|
+
if detach_hook is not None:
|
|
1213
|
+
detach_hook()
|
|
1214
|
+
|
|
1215
|
+
result._dispose_fn = _dispose
|
|
1216
|
+
return result
|
|
1217
|
+
|
|
1218
|
+
def spy(
|
|
1219
|
+
self,
|
|
1220
|
+
path: str | None = None,
|
|
1221
|
+
*,
|
|
1222
|
+
actor: Any | None = None,
|
|
1223
|
+
include_types: list[str] | tuple[str, ...] | None = None,
|
|
1224
|
+
exclude_types: list[str] | tuple[str, ...] | None = None,
|
|
1225
|
+
theme: str | dict[str, str] | None = "ansi",
|
|
1226
|
+
format: str = "pretty",
|
|
1227
|
+
logger: Callable[[str, dict[str, Any]], None] | None = None,
|
|
1228
|
+
timeline: bool = True,
|
|
1229
|
+
causal: bool = False,
|
|
1230
|
+
derived: bool = False,
|
|
1231
|
+
) -> SpyHandle:
|
|
1232
|
+
"""Attach a live debugger that logs protocol events as they arrive.
|
|
1233
|
+
|
|
1234
|
+
Wraps :meth:`observe` and emits each formatted event via ``output``
|
|
1235
|
+
(default ``print``). Supports one-node and graph-wide modes, event
|
|
1236
|
+
filtering, and ANSI color themes. Returns a :class:`SpyHandle` with the
|
|
1237
|
+
accumulated :class:`ObserveResult` and a ``dispose()`` method.
|
|
1238
|
+
|
|
1239
|
+
Args:
|
|
1240
|
+
path: Node path to observe (``None`` = entire graph).
|
|
1241
|
+
output: Callable receiving each formatted log line (default ``print``).
|
|
1242
|
+
theme: ANSI color theme: ``"ansi"`` (default), ``"none"``, or a custom
|
|
1243
|
+
``dict`` of ANSI codes keyed by event type.
|
|
1244
|
+
filter: Set of message type labels to include (``None`` = all types).
|
|
1245
|
+
actor: Optional actor context for guarded nodes.
|
|
1246
|
+
|
|
1247
|
+
Returns:
|
|
1248
|
+
A :class:`SpyHandle` wrapping the live :class:`ObserveResult`.
|
|
1249
|
+
|
|
1250
|
+
Example:
|
|
1251
|
+
```python
|
|
1252
|
+
from graphrefly import Graph, state
|
|
1253
|
+
g = Graph("g")
|
|
1254
|
+
g.add("x", state(0))
|
|
1255
|
+
handle = g.spy("x")
|
|
1256
|
+
g.set("x", 1)
|
|
1257
|
+
handle.dispose()
|
|
1258
|
+
```
|
|
1259
|
+
"""
|
|
1260
|
+
include = set(include_types) if include_types is not None else None
|
|
1261
|
+
exclude = set(exclude_types or [])
|
|
1262
|
+
colors = _resolve_spy_theme(theme)
|
|
1263
|
+
sink = logger or (lambda line, _event: print(line))
|
|
1264
|
+
|
|
1265
|
+
def should_log(event_type: str) -> bool:
|
|
1266
|
+
if include is not None and event_type not in include:
|
|
1267
|
+
return False
|
|
1268
|
+
return event_type not in exclude
|
|
1269
|
+
|
|
1270
|
+
def render_event(event: dict[str, Any]) -> str:
|
|
1271
|
+
if format == "json":
|
|
1272
|
+
try:
|
|
1273
|
+
return json.dumps(event)
|
|
1274
|
+
except Exception:
|
|
1275
|
+
fallback = {
|
|
1276
|
+
"type": event.get("type"),
|
|
1277
|
+
"path": event.get("path"),
|
|
1278
|
+
"data": "[unserializable]",
|
|
1279
|
+
}
|
|
1280
|
+
return json.dumps(fallback)
|
|
1281
|
+
event_type = str(event.get("type") or "event")
|
|
1282
|
+
color = colors.get(event_type, "")
|
|
1283
|
+
path_part = ""
|
|
1284
|
+
if "path" in event:
|
|
1285
|
+
path_part = f"{colors['path']}{event['path']}{colors['reset']} "
|
|
1286
|
+
has_data = "data" in event and event["data"] is not None
|
|
1287
|
+
data_part = f" {_describe_data(event['data'])}" if has_data else ""
|
|
1288
|
+
trigger = ""
|
|
1289
|
+
if event.get("trigger_dep_name") is not None:
|
|
1290
|
+
trigger = f" <- {event['trigger_dep_name']}"
|
|
1291
|
+
elif event.get("trigger_dep_index") is not None:
|
|
1292
|
+
trigger = f" <- #{event['trigger_dep_index']}"
|
|
1293
|
+
batch_part = " [batch]" if event.get("in_batch") else ""
|
|
1294
|
+
return (
|
|
1295
|
+
f"{path_part}{color}{event_type.upper()}{colors['reset']}"
|
|
1296
|
+
f"{data_part}{trigger}{batch_part}"
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
# --- Helper: build an event dict from a raw message and accumulate into result ---
|
|
1300
|
+
def _push_event(result: ObserveResult, event_path: str | None, m: tuple[Any, ...]) -> None:
|
|
1301
|
+
event_type = _message_type_label(m[0])
|
|
1302
|
+
if event_type is None:
|
|
1303
|
+
return
|
|
1304
|
+
event: dict[str, Any] = {"type": event_type}
|
|
1305
|
+
if event_path is not None:
|
|
1306
|
+
event["path"] = event_path
|
|
1307
|
+
if timeline:
|
|
1308
|
+
event["timestamp_ns"] = monotonic_ns()
|
|
1309
|
+
event["in_batch"] = is_batching()
|
|
1310
|
+
if event_type in ("data", "error"):
|
|
1311
|
+
event["data"] = m[1] if len(m) > 1 else None
|
|
1312
|
+
if event_type == "data" and event_path is not None:
|
|
1313
|
+
result.values[event_path] = event.get("data")
|
|
1314
|
+
elif event_type == "dirty":
|
|
1315
|
+
result.dirty_count += 1
|
|
1316
|
+
elif event_type == "resolved":
|
|
1317
|
+
result.resolved_count += 1
|
|
1318
|
+
elif event_type == "complete" and not result.errored:
|
|
1319
|
+
result.completed_cleanly = True
|
|
1320
|
+
elif event_type == "error":
|
|
1321
|
+
result.errored = True
|
|
1322
|
+
result.events.append(event)
|
|
1323
|
+
if should_log(event_type):
|
|
1324
|
+
sink(render_event(event), event)
|
|
1325
|
+
|
|
1326
|
+
# --- Inspector-disabled fallback: manual accumulator via raw observe ---
|
|
1327
|
+
if not self.inspector_enabled:
|
|
1328
|
+
result = ObserveResult()
|
|
1329
|
+
unsub: Callable[[], None]
|
|
1330
|
+
|
|
1331
|
+
if path is not None:
|
|
1332
|
+
stream = self.observe(path, actor=actor)
|
|
1333
|
+
if not isinstance(stream, GraphObserveSource):
|
|
1334
|
+
msg = "spy expected GraphObserveSource in raw mode"
|
|
1335
|
+
raise TypeError(msg)
|
|
1336
|
+
|
|
1337
|
+
def _on_path_msgs(msgs: Any) -> None:
|
|
1338
|
+
for m in msgs:
|
|
1339
|
+
_push_event(result, path, m)
|
|
1340
|
+
|
|
1341
|
+
unsub = stream.subscribe(_on_path_msgs)
|
|
1342
|
+
else:
|
|
1343
|
+
stream = self.observe(actor=actor)
|
|
1344
|
+
if not isinstance(stream, GraphObserveSource):
|
|
1345
|
+
msg = "spy expected GraphObserveSource in raw mode"
|
|
1346
|
+
raise TypeError(msg)
|
|
1347
|
+
|
|
1348
|
+
def _on_qpath_msgs(qpath: str, msgs: Any) -> None:
|
|
1349
|
+
for m in msgs:
|
|
1350
|
+
_push_event(result, qpath, m)
|
|
1351
|
+
|
|
1352
|
+
unsub = stream.subscribe(_on_qpath_msgs)
|
|
1353
|
+
|
|
1354
|
+
result._dispose_fn = unsub
|
|
1355
|
+
return SpyHandle(result=result)
|
|
1356
|
+
|
|
1357
|
+
# --- Inspector-enabled path: use structured observe + flush loop ---
|
|
1358
|
+
structured_candidate = self.observe(
|
|
1359
|
+
path,
|
|
1360
|
+
actor=actor,
|
|
1361
|
+
structured=True,
|
|
1362
|
+
timeline=timeline,
|
|
1363
|
+
causal=causal,
|
|
1364
|
+
derived=derived,
|
|
1365
|
+
)
|
|
1366
|
+
result = (
|
|
1367
|
+
structured_candidate
|
|
1368
|
+
if isinstance(structured_candidate, ObserveResult)
|
|
1369
|
+
else ObserveResult()
|
|
1370
|
+
)
|
|
1371
|
+
structured_cleanup = (
|
|
1372
|
+
structured_candidate._dispose_fn
|
|
1373
|
+
if isinstance(structured_candidate, ObserveResult)
|
|
1374
|
+
else None
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
cursor = 0
|
|
1378
|
+
|
|
1379
|
+
def flush_new_events() -> None:
|
|
1380
|
+
nonlocal cursor
|
|
1381
|
+
next_events = result.events[cursor:]
|
|
1382
|
+
cursor = len(result.events)
|
|
1383
|
+
for event in next_events:
|
|
1384
|
+
event_type = str(event.get("type") or "")
|
|
1385
|
+
if not should_log(event_type):
|
|
1386
|
+
continue
|
|
1387
|
+
sink(render_event(event), event)
|
|
1388
|
+
|
|
1389
|
+
if path is not None:
|
|
1390
|
+
|
|
1391
|
+
def on_messages(msgs: Messages) -> None:
|
|
1392
|
+
for m in msgs:
|
|
1393
|
+
flush_new_events()
|
|
1394
|
+
if isinstance(structured_candidate, ObserveResult):
|
|
1395
|
+
continue
|
|
1396
|
+
_push_event(result, path, m)
|
|
1397
|
+
|
|
1398
|
+
stream_raw = self.observe(path, actor=actor)
|
|
1399
|
+
if not isinstance(stream_raw, GraphObserveSource):
|
|
1400
|
+
msg = "spy expected GraphObserveSource in raw mode"
|
|
1401
|
+
raise TypeError(msg)
|
|
1402
|
+
unsub = stream_raw.subscribe(on_messages)
|
|
1403
|
+
else:
|
|
1404
|
+
|
|
1405
|
+
def on_graph_messages(qpath: str, msgs: Messages) -> None:
|
|
1406
|
+
for m in msgs:
|
|
1407
|
+
flush_new_events()
|
|
1408
|
+
if isinstance(structured_candidate, ObserveResult):
|
|
1409
|
+
continue
|
|
1410
|
+
_push_event(result, qpath, m)
|
|
1411
|
+
|
|
1412
|
+
stream_raw = self.observe(actor=actor)
|
|
1413
|
+
if not isinstance(stream_raw, GraphObserveSource):
|
|
1414
|
+
msg = "spy expected GraphObserveSource in raw mode"
|
|
1415
|
+
raise TypeError(msg)
|
|
1416
|
+
unsub = stream_raw.subscribe(on_graph_messages)
|
|
1417
|
+
|
|
1418
|
+
def _dispose() -> None:
|
|
1419
|
+
unsub()
|
|
1420
|
+
flush_new_events()
|
|
1421
|
+
if structured_cleanup is not None:
|
|
1422
|
+
structured_cleanup()
|
|
1423
|
+
|
|
1424
|
+
result._dispose_fn = _dispose
|
|
1425
|
+
return SpyHandle(result=result)
|
|
1426
|
+
|
|
1427
|
+
def dump_graph(
|
|
1428
|
+
self,
|
|
1429
|
+
*,
|
|
1430
|
+
actor: Any | None = None,
|
|
1431
|
+
filter: Any = None,
|
|
1432
|
+
format: str = "pretty",
|
|
1433
|
+
indent: int = 2,
|
|
1434
|
+
include_edges: bool = True,
|
|
1435
|
+
include_subgraphs: bool = True,
|
|
1436
|
+
logger: Callable[[str], None] | None = None,
|
|
1437
|
+
) -> str:
|
|
1438
|
+
"""Return a CLI/debug-friendly text dump of the graph topology.
|
|
1439
|
+
|
|
1440
|
+
Built on :meth:`describe`; formats node names, types, statuses, and edges.
|
|
1441
|
+
|
|
1442
|
+
Args:
|
|
1443
|
+
actor: Optional actor context for scoping guarded nodes.
|
|
1444
|
+
|
|
1445
|
+
Returns:
|
|
1446
|
+
A multi-line ``str`` summary of the graph.
|
|
1447
|
+
|
|
1448
|
+
Example:
|
|
1449
|
+
```python
|
|
1450
|
+
from graphrefly import Graph, state
|
|
1451
|
+
g = Graph("g")
|
|
1452
|
+
g.add("x", state(1))
|
|
1453
|
+
print(g.dump_graph())
|
|
1454
|
+
```
|
|
1455
|
+
"""
|
|
1456
|
+
described = self.describe(actor=actor, filter=filter)
|
|
1457
|
+
if format == "json":
|
|
1458
|
+
payload = {
|
|
1459
|
+
"name": described["name"],
|
|
1460
|
+
"nodes": described["nodes"],
|
|
1461
|
+
"edges": described["edges"] if include_edges else [],
|
|
1462
|
+
"subgraphs": described["subgraphs"] if include_subgraphs else [],
|
|
1463
|
+
}
|
|
1464
|
+
text = json.dumps(payload, indent=indent, sort_keys=True)
|
|
1465
|
+
if logger is not None:
|
|
1466
|
+
logger(text)
|
|
1467
|
+
return text
|
|
1468
|
+
|
|
1469
|
+
lines: list[str] = [f"Graph {described['name']}", "Nodes:"]
|
|
1470
|
+
for node_path in sorted(described["nodes"]):
|
|
1471
|
+
entry = described["nodes"][node_path]
|
|
1472
|
+
lines.append(
|
|
1473
|
+
f"- {node_path} ({entry.get('type')}/{entry.get('status')}): "
|
|
1474
|
+
f"{_describe_data(entry.get('value'))}"
|
|
1475
|
+
)
|
|
1476
|
+
if include_edges:
|
|
1477
|
+
lines.append("Edges:")
|
|
1478
|
+
for edge in described["edges"]:
|
|
1479
|
+
lines.append(f"- {edge['from']} -> {edge['to']}")
|
|
1480
|
+
if include_subgraphs:
|
|
1481
|
+
lines.append("Subgraphs:")
|
|
1482
|
+
for subgraph in described["subgraphs"]:
|
|
1483
|
+
lines.append(f"- {subgraph}")
|
|
1484
|
+
text = "\n".join(lines)
|
|
1485
|
+
if logger is not None:
|
|
1486
|
+
logger(text)
|
|
1487
|
+
return text
|
|
1488
|
+
|
|
1489
|
+
def node(self, path: str) -> NodeImpl[Any]:
|
|
1490
|
+
"""Return the node for a local name or a ``::``-qualified path.
|
|
1491
|
+
|
|
1492
|
+
Args:
|
|
1493
|
+
path: Local name or fully-qualified path.
|
|
1494
|
+
|
|
1495
|
+
Returns:
|
|
1496
|
+
The :class:`~graphrefly.core.node.NodeImpl` at that path.
|
|
1497
|
+
|
|
1498
|
+
Example:
|
|
1499
|
+
```python
|
|
1500
|
+
from graphrefly import Graph, state
|
|
1501
|
+
g = Graph("g")
|
|
1502
|
+
g.add("x", state(0))
|
|
1503
|
+
assert g.node("x").get() == 0
|
|
1504
|
+
```
|
|
1505
|
+
"""
|
|
1506
|
+
if PATH_SEP in path:
|
|
1507
|
+
return self.resolve(path)
|
|
1508
|
+
with self._locked():
|
|
1509
|
+
try:
|
|
1510
|
+
return self._nodes[path]
|
|
1511
|
+
except KeyError as e:
|
|
1512
|
+
raise KeyError(path) from e
|
|
1513
|
+
|
|
1514
|
+
def get(self, node_name: str) -> Any:
|
|
1515
|
+
"""Return the current cached value of the node at ``node_name``.
|
|
1516
|
+
|
|
1517
|
+
Shorthand for ``graph.node(node_name).get()``. Accepts ``::``-qualified paths.
|
|
1518
|
+
|
|
1519
|
+
Args:
|
|
1520
|
+
node_name: Local name or qualified path.
|
|
1521
|
+
|
|
1522
|
+
Returns:
|
|
1523
|
+
The node's last settled value, or ``None`` if not yet settled.
|
|
1524
|
+
|
|
1525
|
+
Example:
|
|
1526
|
+
```python
|
|
1527
|
+
from graphrefly import Graph, state
|
|
1528
|
+
g = Graph("g")
|
|
1529
|
+
g.add("x", state(7))
|
|
1530
|
+
assert g.get("x") == 7
|
|
1531
|
+
```
|
|
1532
|
+
"""
|
|
1533
|
+
return self.node(node_name).get()
|
|
1534
|
+
|
|
1535
|
+
def set(
|
|
1536
|
+
self,
|
|
1537
|
+
node_name: str,
|
|
1538
|
+
value: Any,
|
|
1539
|
+
*,
|
|
1540
|
+
actor: Any | None = None,
|
|
1541
|
+
internal: bool = False,
|
|
1542
|
+
) -> None:
|
|
1543
|
+
"""Set the value of a node by pushing a ``DATA`` message.
|
|
1544
|
+
|
|
1545
|
+
Shorthand for ``graph.node(node_name).down([(DATA, value)], actor=actor)``.
|
|
1546
|
+
Accepts ``::``-qualified paths.
|
|
1547
|
+
|
|
1548
|
+
Args:
|
|
1549
|
+
node_name: Local name or qualified path.
|
|
1550
|
+
value: New value to push as ``DATA``.
|
|
1551
|
+
actor: Optional actor context checked against the node's guard.
|
|
1552
|
+
|
|
1553
|
+
Example:
|
|
1554
|
+
```python
|
|
1555
|
+
from graphrefly import Graph, state
|
|
1556
|
+
g = Graph("g")
|
|
1557
|
+
g.add("x", state(0))
|
|
1558
|
+
g.set("x", 42)
|
|
1559
|
+
assert g.get("x") == 42
|
|
1560
|
+
```
|
|
1561
|
+
"""
|
|
1562
|
+
try:
|
|
1563
|
+
self.node(node_name).down(
|
|
1564
|
+
[(MessageType.DATA, value)],
|
|
1565
|
+
actor=actor,
|
|
1566
|
+
internal=internal,
|
|
1567
|
+
guard_action="write",
|
|
1568
|
+
)
|
|
1569
|
+
except GuardDenied as e:
|
|
1570
|
+
raise GuardDenied(e.actor, node_name, e.action) from e
|
|
1571
|
+
|
|
1572
|
+
def connect(self, from_path: str, to_path: str) -> None:
|
|
1573
|
+
"""Record a pure graph wire from ``from_path`` to ``to_path``.
|
|
1574
|
+
|
|
1575
|
+
``to`` must already list ``from`` as a constructor dependency. This is an
|
|
1576
|
+
idempotent bookkeeping operation; it does not change ``NodeImpl`` dep lists.
|
|
1577
|
+
Both paths accept local names or ``mount::...`` qualified paths.
|
|
1578
|
+
|
|
1579
|
+
Args:
|
|
1580
|
+
from_path: Path of the upstream (source) node.
|
|
1581
|
+
to_path: Path of the downstream (sink) node.
|
|
1582
|
+
|
|
1583
|
+
Example:
|
|
1584
|
+
```python
|
|
1585
|
+
from graphrefly import Graph, state, node
|
|
1586
|
+
g = Graph("g")
|
|
1587
|
+
x = state(1)
|
|
1588
|
+
y = node([x], lambda deps, _: deps[0])
|
|
1589
|
+
g.add("x", x); g.add("y", y)
|
|
1590
|
+
g.connect("x", "y")
|
|
1591
|
+
```
|
|
1592
|
+
"""
|
|
1593
|
+
if not from_path or not to_path:
|
|
1594
|
+
msg = "connect/disconnect paths must be non-empty"
|
|
1595
|
+
raise ValueError(msg)
|
|
1596
|
+
from_g, from_local, from_n = self._resolve_endpoint(from_path)
|
|
1597
|
+
to_g, to_local, to_n = self._resolve_endpoint(to_path)
|
|
1598
|
+
if from_n is to_n:
|
|
1599
|
+
raise ValueError("cannot connect a node to itself")
|
|
1600
|
+
if not any(d is from_n for d in to_n._deps):
|
|
1601
|
+
raise ValueError(
|
|
1602
|
+
f"connect({from_path!r}, {to_path!r}): target must include the source "
|
|
1603
|
+
"node in its dependency list at construction (pure wire)"
|
|
1604
|
+
)
|
|
1605
|
+
if from_g is to_g:
|
|
1606
|
+
from_g._connect_local(from_local, to_local)
|
|
1607
|
+
else:
|
|
1608
|
+
key = (from_path, to_path)
|
|
1609
|
+
with self._locked():
|
|
1610
|
+
if key in self._edges:
|
|
1611
|
+
return
|
|
1612
|
+
self._edges.add(key)
|
|
1613
|
+
|
|
1614
|
+
def _connect_local(self, from_name: str, to_name: str) -> None:
|
|
1615
|
+
with self._locked():
|
|
1616
|
+
key = (from_name, to_name)
|
|
1617
|
+
if key in self._edges:
|
|
1618
|
+
return
|
|
1619
|
+
try:
|
|
1620
|
+
from_n = self._nodes[from_name]
|
|
1621
|
+
except KeyError as e:
|
|
1622
|
+
raise KeyError(f"unknown node: {from_name!r}") from e
|
|
1623
|
+
try:
|
|
1624
|
+
to_n = self._nodes[to_name]
|
|
1625
|
+
except KeyError as e:
|
|
1626
|
+
raise KeyError(f"unknown node: {to_name!r}") from e
|
|
1627
|
+
if from_n is to_n:
|
|
1628
|
+
raise ValueError("cannot connect a node to itself")
|
|
1629
|
+
if not any(d is from_n for d in to_n._deps):
|
|
1630
|
+
raise ValueError(
|
|
1631
|
+
f"connect({from_name!r}, {to_name!r}): {to_name!r} must include "
|
|
1632
|
+
f"{from_name!r} in its dependency list at construction (pure wire)"
|
|
1633
|
+
)
|
|
1634
|
+
self._edges.add(key)
|
|
1635
|
+
|
|
1636
|
+
def disconnect(self, from_path: str, to_path: str) -> None:
|
|
1637
|
+
"""Remove a registered edge between two nodes.
|
|
1638
|
+
|
|
1639
|
+
**Registry-only (§C resolved):** This drops the edge from the graph's edge
|
|
1640
|
+
registry only. It does **not** mutate the target node's constructor-time
|
|
1641
|
+
dependency list, bitmasks, or upstream subscriptions. Message flow follows
|
|
1642
|
+
constructor-time deps, not the edge registry. For runtime dep rewiring, use
|
|
1643
|
+
:func:`~graphrefly.core.dynamic_node.dynamic_node`.
|
|
1644
|
+
|
|
1645
|
+
Raises :exc:`ValueError` if the edge was never registered. Both paths
|
|
1646
|
+
accept ``::``-qualified formats.
|
|
1647
|
+
|
|
1648
|
+
Args:
|
|
1649
|
+
from_path: Path of the upstream node.
|
|
1650
|
+
to_path: Path of the downstream node.
|
|
1651
|
+
|
|
1652
|
+
Example:
|
|
1653
|
+
```python
|
|
1654
|
+
from graphrefly import Graph, state, node
|
|
1655
|
+
g = Graph("g")
|
|
1656
|
+
x = state(0)
|
|
1657
|
+
y = node([x], lambda deps, _: deps[0])
|
|
1658
|
+
g.add("x", x); g.add("y", y)
|
|
1659
|
+
g.connect("x", "y")
|
|
1660
|
+
g.disconnect("x", "y")
|
|
1661
|
+
```
|
|
1662
|
+
"""
|
|
1663
|
+
if not from_path or not to_path:
|
|
1664
|
+
msg = "connect/disconnect paths must be non-empty"
|
|
1665
|
+
raise ValueError(msg)
|
|
1666
|
+
from_g, from_local, _f = self._resolve_endpoint(from_path)
|
|
1667
|
+
to_g, to_local, _t = self._resolve_endpoint(to_path)
|
|
1668
|
+
if from_g is to_g:
|
|
1669
|
+
from_g._disconnect_local(from_local, to_local)
|
|
1670
|
+
else:
|
|
1671
|
+
key = (from_path, to_path)
|
|
1672
|
+
with self._locked():
|
|
1673
|
+
if key not in self._edges:
|
|
1674
|
+
msg = f'Graph "{self._name}": no registered edge {from_path} → {to_path}'
|
|
1675
|
+
raise ValueError(msg)
|
|
1676
|
+
self._edges.discard(key)
|
|
1677
|
+
|
|
1678
|
+
def _disconnect_local(self, from_name: str, to_name: str) -> None:
|
|
1679
|
+
with self._locked():
|
|
1680
|
+
key = (from_name, to_name)
|
|
1681
|
+
if key not in self._edges:
|
|
1682
|
+
msg = f'Graph "{self._name}": no registered edge {from_name} → {to_name}'
|
|
1683
|
+
raise ValueError(msg)
|
|
1684
|
+
self._edges.discard(key)
|
|
1685
|
+
|
|
1686
|
+
def edges(self) -> frozenset[tuple[str, str]]:
|
|
1687
|
+
"""Return all registered ``(from_name, to_name)`` edge pairs (read-only).
|
|
1688
|
+
|
|
1689
|
+
Returns:
|
|
1690
|
+
A ``frozenset`` of ``(from_name, to_name)`` string tuples.
|
|
1691
|
+
|
|
1692
|
+
Example:
|
|
1693
|
+
```python
|
|
1694
|
+
from graphrefly import Graph, state, node
|
|
1695
|
+
g = Graph("g")
|
|
1696
|
+
x = state(0); y = node([x], lambda d, _: d[0])
|
|
1697
|
+
g.add("x", x); g.add("y", y)
|
|
1698
|
+
g.connect("x", "y")
|
|
1699
|
+
assert ("x", "y") in g.edges()
|
|
1700
|
+
```
|
|
1701
|
+
"""
|
|
1702
|
+
with self._locked():
|
|
1703
|
+
return frozenset(self._edges)
|
|
1704
|
+
|
|
1705
|
+
def to_mermaid(self, *, direction: str = "LR") -> str:
|
|
1706
|
+
"""Export current topology as Mermaid flowchart text.
|
|
1707
|
+
|
|
1708
|
+
Uses :meth:`describe` to render qualified node paths and registered edges.
|
|
1709
|
+
|
|
1710
|
+
Args:
|
|
1711
|
+
direction: ``"TD"``, ``"LR"``, ``"BT"``, or ``"RL"`` (default ``"LR"``).
|
|
1712
|
+
|
|
1713
|
+
Returns:
|
|
1714
|
+
Mermaid source string.
|
|
1715
|
+
"""
|
|
1716
|
+
direction = _normalize_diagram_direction(direction)
|
|
1717
|
+
described = self.describe()
|
|
1718
|
+
paths = sorted(described["nodes"].keys())
|
|
1719
|
+
ids = {path: f"n{i}" for i, path in enumerate(paths)}
|
|
1720
|
+
lines: list[str] = [f"flowchart {direction}"]
|
|
1721
|
+
for path in paths:
|
|
1722
|
+
lines.append(f' {ids[path]}["{_escape_mermaid_label(path)}"]')
|
|
1723
|
+
drawn: set[tuple[str, str]] = set()
|
|
1724
|
+
for edge in described["edges"]:
|
|
1725
|
+
from_id = ids.get(edge["from"])
|
|
1726
|
+
to_id = ids.get(edge["to"])
|
|
1727
|
+
if from_id is None or to_id is None:
|
|
1728
|
+
continue
|
|
1729
|
+
pair = (edge["from"], edge["to"])
|
|
1730
|
+
if pair not in drawn:
|
|
1731
|
+
drawn.add(pair)
|
|
1732
|
+
lines.append(f" {from_id} --> {to_id}")
|
|
1733
|
+
for path in paths:
|
|
1734
|
+
node_desc = described["nodes"][path]
|
|
1735
|
+
for dep in node_desc.get("deps", []):
|
|
1736
|
+
pair = (dep, path)
|
|
1737
|
+
if pair in drawn:
|
|
1738
|
+
continue
|
|
1739
|
+
from_id = ids.get(dep)
|
|
1740
|
+
to_id = ids.get(path)
|
|
1741
|
+
if from_id is None or to_id is None:
|
|
1742
|
+
continue
|
|
1743
|
+
drawn.add(pair)
|
|
1744
|
+
lines.append(f" {from_id} --> {to_id}")
|
|
1745
|
+
return "\n".join(lines)
|
|
1746
|
+
|
|
1747
|
+
def to_d2(self, *, direction: str = "LR") -> str:
|
|
1748
|
+
"""Export current topology as D2 diagram text.
|
|
1749
|
+
|
|
1750
|
+
Uses :meth:`describe` to render qualified node paths and registered edges.
|
|
1751
|
+
|
|
1752
|
+
Args:
|
|
1753
|
+
direction: ``"TD"``, ``"LR"``, ``"BT"``, or ``"RL"`` (default ``"LR"``).
|
|
1754
|
+
|
|
1755
|
+
Returns:
|
|
1756
|
+
D2 source string.
|
|
1757
|
+
"""
|
|
1758
|
+
direction = _normalize_diagram_direction(direction)
|
|
1759
|
+
described = self.describe()
|
|
1760
|
+
paths = sorted(described["nodes"].keys())
|
|
1761
|
+
ids = {path: f"n{i}" for i, path in enumerate(paths)}
|
|
1762
|
+
lines: list[str] = [f"direction: {_d2_direction_from_graph_direction(direction)}"]
|
|
1763
|
+
for path in paths:
|
|
1764
|
+
lines.append(f'{ids[path]}: "{_escape_d2_label(path)}"')
|
|
1765
|
+
drawn: set[tuple[str, str]] = set()
|
|
1766
|
+
for edge in described["edges"]:
|
|
1767
|
+
from_id = ids.get(edge["from"])
|
|
1768
|
+
to_id = ids.get(edge["to"])
|
|
1769
|
+
if from_id is None or to_id is None:
|
|
1770
|
+
continue
|
|
1771
|
+
pair = (edge["from"], edge["to"])
|
|
1772
|
+
if pair not in drawn:
|
|
1773
|
+
drawn.add(pair)
|
|
1774
|
+
lines.append(f"{from_id} -> {to_id}")
|
|
1775
|
+
for path in paths:
|
|
1776
|
+
node_desc = described["nodes"][path]
|
|
1777
|
+
for dep in node_desc.get("deps", []):
|
|
1778
|
+
pair = (dep, path)
|
|
1779
|
+
if pair in drawn:
|
|
1780
|
+
continue
|
|
1781
|
+
from_id = ids.get(dep)
|
|
1782
|
+
to_id = ids.get(path)
|
|
1783
|
+
if from_id is None or to_id is None:
|
|
1784
|
+
continue
|
|
1785
|
+
drawn.add(pair)
|
|
1786
|
+
lines.append(f"{from_id} -> {to_id}")
|
|
1787
|
+
return "\n".join(lines)
|
|
1788
|
+
|
|
1789
|
+
def annotate(self, path: str, reason: str) -> None:
|
|
1790
|
+
"""Store an annotation for ``path`` and record it in the trace ring buffer.
|
|
1791
|
+
|
|
1792
|
+
Annotations are informational labels attached to node paths for debugging
|
|
1793
|
+
and auditing. Each call also appends a :class:`TraceEntry` to the ring buffer.
|
|
1794
|
+
|
|
1795
|
+
Args:
|
|
1796
|
+
path: Qualified node path.
|
|
1797
|
+
reason: Human-readable annotation text.
|
|
1798
|
+
"""
|
|
1799
|
+
if not self.inspector_enabled:
|
|
1800
|
+
return
|
|
1801
|
+
self.resolve(path)
|
|
1802
|
+
with self._locked():
|
|
1803
|
+
self._annotations[path] = reason
|
|
1804
|
+
self._trace_ring.append(
|
|
1805
|
+
TraceEntry(timestamp_ns=monotonic_ns(), path=path, reason=reason)
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
def trace_log(self) -> list[TraceEntry]:
|
|
1809
|
+
"""Return a copy of the trace ring buffer (most recent last).
|
|
1810
|
+
|
|
1811
|
+
Returns:
|
|
1812
|
+
A list of :class:`TraceEntry` objects.
|
|
1813
|
+
"""
|
|
1814
|
+
if not self.inspector_enabled:
|
|
1815
|
+
return []
|
|
1816
|
+
with self._locked():
|
|
1817
|
+
return list(self._trace_ring)
|
|
1818
|
+
|
|
1819
|
+
@staticmethod
|
|
1820
|
+
def diff(a: dict[str, Any], b: dict[str, Any]) -> GraphDiffResult:
|
|
1821
|
+
"""Structural diff of two :meth:`describe` outputs.
|
|
1822
|
+
|
|
1823
|
+
Compares ``nodes``, ``edges``, and ``subgraphs`` between snapshots *a* and *b*.
|
|
1824
|
+
|
|
1825
|
+
Args:
|
|
1826
|
+
a: First describe output (the "before" state).
|
|
1827
|
+
b: Second describe output (the "after" state).
|
|
1828
|
+
|
|
1829
|
+
Returns:
|
|
1830
|
+
A :class:`GraphDiffResult` with added, removed, and changed items.
|
|
1831
|
+
"""
|
|
1832
|
+
a_nodes = set(a.get("nodes", {}).keys())
|
|
1833
|
+
b_nodes = set(b.get("nodes", {}).keys())
|
|
1834
|
+
added_nodes = sorted(b_nodes - a_nodes)
|
|
1835
|
+
removed_nodes = sorted(a_nodes - b_nodes)
|
|
1836
|
+
changed_nodes: list[dict[str, Any]] = []
|
|
1837
|
+
for p in sorted(a_nodes & b_nodes):
|
|
1838
|
+
na = a["nodes"][p]
|
|
1839
|
+
nb = b["nodes"][p]
|
|
1840
|
+
if not isinstance(na, dict) or not isinstance(nb, dict):
|
|
1841
|
+
if na != nb:
|
|
1842
|
+
changed_nodes.append({"path": p, "field": "node", "from": na, "to": nb})
|
|
1843
|
+
continue
|
|
1844
|
+
# V0 optimization: skip value comparison when both nodes have matching versions.
|
|
1845
|
+
av = na.get("v")
|
|
1846
|
+
bv = nb.get("v")
|
|
1847
|
+
if (
|
|
1848
|
+
av is not None
|
|
1849
|
+
and bv is not None
|
|
1850
|
+
and av.get("id") == bv.get("id")
|
|
1851
|
+
and av.get("version") == bv.get("version")
|
|
1852
|
+
):
|
|
1853
|
+
for key in ("type", "status"):
|
|
1854
|
+
va = na.get(key)
|
|
1855
|
+
vb = nb.get(key)
|
|
1856
|
+
if va != vb:
|
|
1857
|
+
changed_nodes.append({"path": p, "field": key, "from": va, "to": vb})
|
|
1858
|
+
continue
|
|
1859
|
+
for key in ("type", "status", "value"):
|
|
1860
|
+
va = na.get(key)
|
|
1861
|
+
vb = nb.get(key)
|
|
1862
|
+
if va != vb:
|
|
1863
|
+
changed_nodes.append({"path": p, "field": key, "from": va, "to": vb})
|
|
1864
|
+
|
|
1865
|
+
a_edges = {(e["from"], e["to"]) for e in a.get("edges", [])}
|
|
1866
|
+
b_edges = {(e["from"], e["to"]) for e in b.get("edges", [])}
|
|
1867
|
+
added_edges = [{"from": f, "to": t} for f, t in sorted(b_edges - a_edges)]
|
|
1868
|
+
removed_edges = [{"from": f, "to": t} for f, t in sorted(a_edges - b_edges)]
|
|
1869
|
+
|
|
1870
|
+
a_subs = set(a.get("subgraphs", []))
|
|
1871
|
+
b_subs = set(b.get("subgraphs", []))
|
|
1872
|
+
added_subgraphs = sorted(b_subs - a_subs)
|
|
1873
|
+
removed_subgraphs = sorted(a_subs - b_subs)
|
|
1874
|
+
|
|
1875
|
+
return GraphDiffResult(
|
|
1876
|
+
nodesAdded=added_nodes,
|
|
1877
|
+
nodesRemoved=removed_nodes,
|
|
1878
|
+
nodesChanged=changed_nodes,
|
|
1879
|
+
edgesAdded=added_edges,
|
|
1880
|
+
edgesRemoved=removed_edges,
|
|
1881
|
+
subgraphsAdded=added_subgraphs,
|
|
1882
|
+
subgraphsRemoved=removed_subgraphs,
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def reachable(
|
|
1887
|
+
described: dict[str, Any],
|
|
1888
|
+
from_path: str,
|
|
1889
|
+
direction: str,
|
|
1890
|
+
*,
|
|
1891
|
+
max_depth: int | None = None,
|
|
1892
|
+
) -> list[str]:
|
|
1893
|
+
"""Perform a reachability query over a :meth:`Graph.describe` snapshot.
|
|
1894
|
+
|
|
1895
|
+
Traversal combines dependency links (``deps``) and explicit graph edges
|
|
1896
|
+
(``edges``):
|
|
1897
|
+
|
|
1898
|
+
- ``"upstream"``: follows ``deps`` and incoming edges.
|
|
1899
|
+
- ``"downstream"``: follows reverse-``deps`` and outgoing edges.
|
|
1900
|
+
|
|
1901
|
+
Args:
|
|
1902
|
+
described: Output of ``graph.describe()``.
|
|
1903
|
+
from_path: Start path (qualified node path).
|
|
1904
|
+
direction: Either ``"upstream"`` or ``"downstream"``.
|
|
1905
|
+
max_depth: Optional hop limit; ``0`` returns an empty list.
|
|
1906
|
+
|
|
1907
|
+
Returns:
|
|
1908
|
+
Sorted list of reachable paths, excluding ``from_path``.
|
|
1909
|
+
|
|
1910
|
+
Example:
|
|
1911
|
+
```python
|
|
1912
|
+
from graphrefly import Graph, state, node, reachable
|
|
1913
|
+
g = Graph("g")
|
|
1914
|
+
x = state(0)
|
|
1915
|
+
y = node([x], lambda deps, _: deps[0])
|
|
1916
|
+
g.add("x", x); g.add("y", y)
|
|
1917
|
+
g.connect("x", "y")
|
|
1918
|
+
desc = g.describe()
|
|
1919
|
+
assert "x" in reachable(desc, "y", "upstream")
|
|
1920
|
+
```
|
|
1921
|
+
"""
|
|
1922
|
+
if not from_path:
|
|
1923
|
+
return []
|
|
1924
|
+
if direction not in {"upstream", "downstream"}:
|
|
1925
|
+
msg = f"reachable direction must be 'upstream' or 'downstream', got {direction!r}"
|
|
1926
|
+
raise ValueError(msg)
|
|
1927
|
+
if max_depth is not None:
|
|
1928
|
+
if type(max_depth) is not int or max_depth < 0:
|
|
1929
|
+
msg = f"reachable max_depth must be an int >= 0, got {max_depth!r}"
|
|
1930
|
+
raise ValueError(msg)
|
|
1931
|
+
if max_depth == 0:
|
|
1932
|
+
return []
|
|
1933
|
+
|
|
1934
|
+
nodes_raw = described.get("nodes", {})
|
|
1935
|
+
edges_raw = described.get("edges", [])
|
|
1936
|
+
if not isinstance(nodes_raw, dict):
|
|
1937
|
+
return []
|
|
1938
|
+
if not isinstance(edges_raw, list):
|
|
1939
|
+
edges_raw = []
|
|
1940
|
+
|
|
1941
|
+
deps_by_path: dict[str, list[str]] = {}
|
|
1942
|
+
reverse_deps: dict[str, set[str]] = {}
|
|
1943
|
+
incoming_edges: dict[str, set[str]] = {}
|
|
1944
|
+
outgoing_edges: dict[str, set[str]] = {}
|
|
1945
|
+
universe: set[str] = set()
|
|
1946
|
+
|
|
1947
|
+
for path, node_desc in nodes_raw.items():
|
|
1948
|
+
if not isinstance(path, str):
|
|
1949
|
+
continue
|
|
1950
|
+
universe.add(path)
|
|
1951
|
+
deps: list[str] = []
|
|
1952
|
+
if isinstance(node_desc, dict):
|
|
1953
|
+
raw_deps = node_desc.get("deps")
|
|
1954
|
+
if isinstance(raw_deps, list):
|
|
1955
|
+
deps = [d for d in raw_deps if isinstance(d, str) and d]
|
|
1956
|
+
deps_by_path[path] = deps
|
|
1957
|
+
for dep in deps:
|
|
1958
|
+
universe.add(dep)
|
|
1959
|
+
reverse_deps.setdefault(dep, set()).add(path)
|
|
1960
|
+
|
|
1961
|
+
for edge in edges_raw:
|
|
1962
|
+
if not isinstance(edge, dict):
|
|
1963
|
+
continue
|
|
1964
|
+
edge_from = edge.get("from")
|
|
1965
|
+
edge_to = edge.get("to")
|
|
1966
|
+
if not isinstance(edge_from, str) or not edge_from:
|
|
1967
|
+
continue
|
|
1968
|
+
if not isinstance(edge_to, str) or not edge_to:
|
|
1969
|
+
continue
|
|
1970
|
+
universe.add(edge_from)
|
|
1971
|
+
universe.add(edge_to)
|
|
1972
|
+
outgoing_edges.setdefault(edge_from, set()).add(edge_to)
|
|
1973
|
+
incoming_edges.setdefault(edge_to, set()).add(edge_from)
|
|
1974
|
+
|
|
1975
|
+
if from_path not in universe:
|
|
1976
|
+
return []
|
|
1977
|
+
|
|
1978
|
+
def neighbors(path: str) -> list[str]:
|
|
1979
|
+
if direction == "upstream":
|
|
1980
|
+
return deps_by_path.get(path, []) + sorted(incoming_edges.get(path, set()))
|
|
1981
|
+
return sorted(reverse_deps.get(path, set())) + sorted(outgoing_edges.get(path, set()))
|
|
1982
|
+
|
|
1983
|
+
visited: set[str] = {from_path}
|
|
1984
|
+
out: set[str] = set()
|
|
1985
|
+
queue: deque[tuple[str, int]] = deque([(from_path, 0)])
|
|
1986
|
+
while queue:
|
|
1987
|
+
cur, depth = queue.popleft()
|
|
1988
|
+
if max_depth is not None and depth >= max_depth:
|
|
1989
|
+
continue
|
|
1990
|
+
for nb in neighbors(cur):
|
|
1991
|
+
if not nb or nb in visited:
|
|
1992
|
+
continue
|
|
1993
|
+
visited.add(nb)
|
|
1994
|
+
out.add(nb)
|
|
1995
|
+
queue.append((nb, depth + 1))
|
|
1996
|
+
return sorted(out)
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def _finish_resolve_from_node(base: NodeImpl[Any], tail: list[str], path: str) -> NodeImpl[Any]:
|
|
2000
|
+
if not tail:
|
|
2001
|
+
return base
|
|
2002
|
+
if tail[0] == GRAPH_META_SEGMENT:
|
|
2003
|
+
return _resolve_meta_chain(base, tail, path)
|
|
2004
|
+
msg = f"{path!r}: cannot traverse past a registered node (not a subgraph)"
|
|
2005
|
+
raise KeyError(msg)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def _resolve_meta_chain(n: NodeImpl[Any], parts: list[str], path: str) -> NodeImpl[Any]:
|
|
2009
|
+
if not parts:
|
|
2010
|
+
return n
|
|
2011
|
+
if parts[0] != GRAPH_META_SEGMENT:
|
|
2012
|
+
msg = f"expected {GRAPH_META_SEGMENT!r} segment in meta path {path!r}"
|
|
2013
|
+
raise KeyError(msg)
|
|
2014
|
+
if len(parts) < 2:
|
|
2015
|
+
msg = f"meta path requires a key after {GRAPH_META_SEGMENT!r} in {path!r}"
|
|
2016
|
+
raise ValueError(msg)
|
|
2017
|
+
key = parts[1]
|
|
2018
|
+
try:
|
|
2019
|
+
child = n.meta[key]
|
|
2020
|
+
except KeyError as e:
|
|
2021
|
+
raise KeyError(f"unknown meta key {key!r} in path {path!r}") from e
|
|
2022
|
+
rest = parts[2:]
|
|
2023
|
+
return _resolve_meta_chain(child, rest, path) if rest else child
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
_META_FILTERED_TYPES = frozenset(
|
|
2027
|
+
{MessageType.TEARDOWN, MessageType.INVALIDATE, MessageType.COMPLETE, MessageType.ERROR}
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _filter_meta_messages(messages: Messages) -> Messages:
|
|
2032
|
+
"""Strip lifecycle-destructive messages before delivering to meta companions (spec §2.3)."""
|
|
2033
|
+
return [m for m in messages if m[0] not in _META_FILTERED_TYPES]
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _signal_node_subtree(
|
|
2037
|
+
n: NodeImpl[Any],
|
|
2038
|
+
messages: Messages,
|
|
2039
|
+
visited: set[int],
|
|
2040
|
+
actor: dict[str, Any],
|
|
2041
|
+
path: str,
|
|
2042
|
+
*,
|
|
2043
|
+
internal: bool = False,
|
|
2044
|
+
) -> None:
|
|
2045
|
+
nid = id(n)
|
|
2046
|
+
if nid in visited:
|
|
2047
|
+
return
|
|
2048
|
+
visited.add(nid)
|
|
2049
|
+
if internal:
|
|
2050
|
+
n.down(messages, internal=True)
|
|
2051
|
+
else:
|
|
2052
|
+
try:
|
|
2053
|
+
n.down(messages, actor=actor, guard_action="signal")
|
|
2054
|
+
except GuardDenied as e:
|
|
2055
|
+
raise GuardDenied(e.actor, path, e.action) from e
|
|
2056
|
+
meta_msgs = _filter_meta_messages(messages)
|
|
2057
|
+
if not meta_msgs:
|
|
2058
|
+
return
|
|
2059
|
+
for k in sorted(n.meta):
|
|
2060
|
+
mp = f"{path}{PATH_SEP}{GRAPH_META_SEGMENT}{PATH_SEP}{k}"
|
|
2061
|
+
_signal_node_subtree(n.meta[k], meta_msgs, visited, actor, mp, internal=internal)
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
def _collect_observe_targets(g: Graph, prefix: str) -> list[tuple[str, NodeImpl[Any]]]:
|
|
2065
|
+
"""Collect all observe targets and sort by full qualified path (code-point order).
|
|
2066
|
+
|
|
2067
|
+
Cross-language alignment: both Python and TypeScript use full-path code-point
|
|
2068
|
+
sort so that ``observe()`` subscription order is deterministic and identical
|
|
2069
|
+
across implementations.
|
|
2070
|
+
"""
|
|
2071
|
+
out: list[tuple[str, NodeImpl[Any]]] = []
|
|
2072
|
+
_collect_observe_targets_unsorted(g, prefix, out)
|
|
2073
|
+
out.sort(key=lambda pair: pair[0])
|
|
2074
|
+
return out
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
def _collect_observe_targets_unsorted(
|
|
2078
|
+
g: Graph, prefix: str, out: list[tuple[str, NodeImpl[Any]]]
|
|
2079
|
+
) -> None:
|
|
2080
|
+
"""Recursively collect all targets without sorting (helper for full-path sort)."""
|
|
2081
|
+
with g._locked():
|
|
2082
|
+
mounts = list(g._mounts.items())
|
|
2083
|
+
node_items = list(g._nodes.items())
|
|
2084
|
+
for mn, ch in mounts:
|
|
2085
|
+
wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
|
|
2086
|
+
_collect_observe_targets_unsorted(ch, wp, out)
|
|
2087
|
+
for name, n in node_items:
|
|
2088
|
+
bp = f"{prefix}{name}" if prefix else name
|
|
2089
|
+
out.append((bp, n))
|
|
2090
|
+
_append_meta_observe_targets(n, bp, out)
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def _append_meta_observe_targets(
|
|
2094
|
+
n: NodeImpl[Any], base_path: str, out: list[tuple[str, NodeImpl[Any]]]
|
|
2095
|
+
) -> None:
|
|
2096
|
+
for k in sorted(n.meta):
|
|
2097
|
+
m = n.meta[k]
|
|
2098
|
+
mp = f"{base_path}{PATH_SEP}{GRAPH_META_SEGMENT}{PATH_SEP}{k}"
|
|
2099
|
+
out.append((mp, m))
|
|
2100
|
+
_append_meta_observe_targets(m, mp, out)
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def _node_describe_for_graph(n: NodeImpl[Any], paths_by_id: dict[int, str]) -> dict[str, Any]:
|
|
2104
|
+
d = describe_node(n)
|
|
2105
|
+
d["deps"] = [paths_by_id.get(id(dep), dep.name or "") for dep in n._deps]
|
|
2106
|
+
d.pop("name", None)
|
|
2107
|
+
return d
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def _collect_edges_qualified(g: Graph, prefix: str) -> list[dict[str, str]]:
|
|
2111
|
+
rows: list[dict[str, str]] = []
|
|
2112
|
+
with g._locked():
|
|
2113
|
+
loc_edges = frozenset(g._edges)
|
|
2114
|
+
mounts = sorted(g._mounts.items())
|
|
2115
|
+
for f, t in loc_edges:
|
|
2116
|
+
if PATH_SEP in f or PATH_SEP in t:
|
|
2117
|
+
rows.append({"from": f, "to": t})
|
|
2118
|
+
else:
|
|
2119
|
+
fq = f"{prefix}{f}" if prefix else f
|
|
2120
|
+
tq = f"{prefix}{t}" if prefix else t
|
|
2121
|
+
rows.append({"from": fq, "to": tq})
|
|
2122
|
+
for mn, ch in mounts:
|
|
2123
|
+
wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
|
|
2124
|
+
rows.extend(_collect_edges_qualified(ch, wp))
|
|
2125
|
+
return rows
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def _collect_subgraphs_qualified(g: Graph, prefix: str) -> list[str]:
|
|
2129
|
+
out: list[str] = []
|
|
2130
|
+
with g._locked():
|
|
2131
|
+
mounts = list(g._mounts.items())
|
|
2132
|
+
for mn, ch in mounts:
|
|
2133
|
+
q = f"{prefix}{mn}" if prefix else mn
|
|
2134
|
+
out.append(q)
|
|
2135
|
+
out.extend(_collect_subgraphs_qualified(ch, f"{q}{PATH_SEP}"))
|
|
2136
|
+
return out
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
class GraphObserveSource:
|
|
2140
|
+
"""Live observation handle from :meth:`Graph.observe` (GRAPHREFLY-SPEC §3.6)."""
|
|
2141
|
+
|
|
2142
|
+
__slots__ = ("_actor", "_graph", "_path")
|
|
2143
|
+
|
|
2144
|
+
def __init__(self, graph: Graph, path: str | None, actor: Any | None) -> None:
|
|
2145
|
+
self._graph = graph
|
|
2146
|
+
self._path = path
|
|
2147
|
+
self._actor = actor
|
|
2148
|
+
|
|
2149
|
+
def subscribe(self, sink: Any) -> Any:
|
|
2150
|
+
"""Attach ``sink``.
|
|
2151
|
+
|
|
2152
|
+
With a single-node path, ``sink(messages)`` receives :class:`Messages`.
|
|
2153
|
+
|
|
2154
|
+
With the graph-wide stream (``path is None``), ``sink(qualified_path, messages)``.
|
|
2155
|
+
Returns an unsubscribe callable.
|
|
2156
|
+
"""
|
|
2157
|
+
act = self._actor
|
|
2158
|
+
if self._path is not None:
|
|
2159
|
+
n = self._graph.node(self._path)
|
|
2160
|
+
try:
|
|
2161
|
+
return n.subscribe(sink, actor=act)
|
|
2162
|
+
except GuardDenied as e:
|
|
2163
|
+
raise GuardDenied(e.actor, self._path, e.action) from e
|
|
2164
|
+
unsubs: list[Any] = []
|
|
2165
|
+
a = normalize_actor(act)
|
|
2166
|
+
for qpath, n in _collect_observe_targets(self._graph, ""):
|
|
2167
|
+
if not _node_allows_observe(n, a):
|
|
2168
|
+
continue
|
|
2169
|
+
|
|
2170
|
+
def on_msgs(msgs: Messages, *, _p: str = qpath) -> None:
|
|
2171
|
+
sink(_p, msgs)
|
|
2172
|
+
|
|
2173
|
+
unsubs.append(n.subscribe(on_msgs, actor=act))
|
|
2174
|
+
|
|
2175
|
+
def cleanup() -> None:
|
|
2176
|
+
for u in unsubs:
|
|
2177
|
+
u()
|
|
2178
|
+
|
|
2179
|
+
return cleanup
|
|
2180
|
+
|
|
2181
|
+
def up(self, messages: Messages, path: str | None = None) -> None:
|
|
2182
|
+
"""Send messages upstream toward the observed node's sources.
|
|
2183
|
+
|
|
2184
|
+
For single-node observation (``self._path is not None``), *path* is
|
|
2185
|
+
ignored and messages go to the observed node. For graph-wide
|
|
2186
|
+
observation, *path* must be provided to target a specific node.
|
|
2187
|
+
|
|
2188
|
+
If the target node's guard denies the action, the messages are
|
|
2189
|
+
silently dropped (aligned with TS). This prevents ``GuardDenied``
|
|
2190
|
+
from propagating into backpressure controller callbacks.
|
|
2191
|
+
"""
|
|
2192
|
+
target_path = path if self._path is None else self._path
|
|
2193
|
+
if target_path is None:
|
|
2194
|
+
msg = "up() on graph-wide observe requires a path argument"
|
|
2195
|
+
raise ValueError(msg)
|
|
2196
|
+
n = self._graph.node(target_path)
|
|
2197
|
+
up_fn = getattr(n, "up", None)
|
|
2198
|
+
if up_fn is not None:
|
|
2199
|
+
try:
|
|
2200
|
+
up_fn(messages)
|
|
2201
|
+
except GuardDenied:
|
|
2202
|
+
return
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _teardown_mounted_graph(root: Graph) -> None:
|
|
2206
|
+
with root._locked():
|
|
2207
|
+
mounts = list(root._mounts.values())
|
|
2208
|
+
for m in mounts:
|
|
2209
|
+
_teardown_mounted_graph(m)
|
|
2210
|
+
with root._locked():
|
|
2211
|
+
nodes = list(root._nodes.values())
|
|
2212
|
+
for n in nodes:
|
|
2213
|
+
n.down([(MessageType.TEARDOWN,)], internal=True)
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def _signal_graph(
|
|
2217
|
+
g: Graph,
|
|
2218
|
+
messages: Messages,
|
|
2219
|
+
visited: set[int],
|
|
2220
|
+
actor: dict[str, Any],
|
|
2221
|
+
prefix: str,
|
|
2222
|
+
*,
|
|
2223
|
+
internal: bool = False,
|
|
2224
|
+
) -> None:
|
|
2225
|
+
with g._locked():
|
|
2226
|
+
mounts = sorted(g._mounts.items())
|
|
2227
|
+
nodes = sorted(g._nodes.items())
|
|
2228
|
+
for mn, m in mounts:
|
|
2229
|
+
wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
|
|
2230
|
+
_signal_graph(m, messages, visited, actor, wp, internal=internal)
|
|
2231
|
+
for name, n in nodes:
|
|
2232
|
+
q = f"{prefix}{name}" if prefix else name
|
|
2233
|
+
_signal_node_subtree(n, messages, visited, actor, q, internal=internal)
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
__all__ = [
|
|
2237
|
+
"GRAPH_META_SEGMENT",
|
|
2238
|
+
"GRAPH_SNAPSHOT_VERSION",
|
|
2239
|
+
"GraphAutoCheckpointHandle",
|
|
2240
|
+
"Graph",
|
|
2241
|
+
"GraphDiffResult",
|
|
2242
|
+
"GraphObserveSource",
|
|
2243
|
+
"META_PATH_SEG",
|
|
2244
|
+
"ObserveResult",
|
|
2245
|
+
"PATH_SEP",
|
|
2246
|
+
"SpyHandle",
|
|
2247
|
+
"TraceEntry",
|
|
2248
|
+
"reachable",
|
|
2249
|
+
]
|