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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,287 @@
1
+ """Backoff strategies for :func:`~graphrefly.extra.resilience.retry` and circuit tooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Callable
7
+ from typing import Literal
8
+
9
+ type JitterMode = Literal["none", "full", "equal"]
10
+ type BackoffPreset = Literal[
11
+ "constant", "linear", "exponential", "fibonacci", "decorrelated_jitter"
12
+ ]
13
+ type BackoffStrategy = Callable[[int, BaseException | None, int | None], int | None]
14
+
15
+ NS_PER_MS = 1_000_000
16
+ NS_PER_SEC = 1_000_000_000
17
+
18
+ __all__ = [
19
+ "BackoffPreset",
20
+ "BackoffStrategy",
21
+ "JitterMode",
22
+ "NS_PER_MS",
23
+ "NS_PER_SEC",
24
+ "constant",
25
+ "decorrelated_jitter",
26
+ "exponential",
27
+ "fibonacci",
28
+ "linear",
29
+ "resolve_backoff_preset",
30
+ "with_max_attempts",
31
+ ]
32
+
33
+
34
+ def _clamp_non_negative(value: int) -> int:
35
+ return 0 if value < 0 else value
36
+
37
+
38
+ def _apply_jitter(delay: int, jitter: JitterMode) -> int:
39
+ if jitter == "none":
40
+ return delay
41
+ if jitter == "full":
42
+ return random.randint(0, delay) if delay > 0 else 0
43
+ # "equal" — half deterministic, half random
44
+ half = delay // 2
45
+ return half + random.randint(0, delay - half) if delay > 0 else 0
46
+
47
+
48
+ def constant(delay_ns: int) -> BackoffStrategy:
49
+ """Create a backoff strategy that always returns the same delay.
50
+
51
+ Args:
52
+ delay_ns: Fixed delay in nanoseconds (clamped to 0 if negative).
53
+
54
+ Returns:
55
+ A :data:`BackoffStrategy` callable.
56
+
57
+ Example:
58
+ ```python
59
+ from graphrefly.extra.backoff import constant, NS_PER_SEC
60
+ s = constant(2 * NS_PER_SEC)
61
+ assert s(0, None, None) == 2_000_000_000
62
+ assert s(5, None, None) == 2_000_000_000
63
+ ```
64
+ """
65
+ safe = _clamp_non_negative(delay_ns)
66
+
67
+ def _strategy(
68
+ _attempt: int,
69
+ _error: BaseException | None = None,
70
+ _prev_delay: int | None = None,
71
+ ) -> int:
72
+ return safe
73
+
74
+ return _strategy
75
+
76
+
77
+ def linear(base_ns: int, step_ns: int | None = None) -> BackoffStrategy:
78
+ """Create a backoff strategy with linearly increasing delay.
79
+
80
+ Delay is ``base_ns + step_ns * attempt`` where *step_ns* defaults to *base_ns*.
81
+
82
+ Args:
83
+ base_ns: Starting delay in nanoseconds.
84
+ step_ns: Increment per attempt in nanoseconds (defaults to *base_ns*).
85
+
86
+ Returns:
87
+ A :data:`BackoffStrategy` callable.
88
+
89
+ Example:
90
+ ```python
91
+ from graphrefly.extra.backoff import linear, NS_PER_SEC
92
+ s = linear(1 * NS_PER_SEC)
93
+ assert s(0, None, None) == 1_000_000_000
94
+ assert s(2, None, None) == 3_000_000_000
95
+ ```
96
+ """
97
+ safe_base = _clamp_non_negative(base_ns)
98
+ safe_step = safe_base if step_ns is None else _clamp_non_negative(step_ns)
99
+
100
+ def _strategy(
101
+ attempt: int,
102
+ _error: BaseException | None = None,
103
+ _prev_delay: int | None = None,
104
+ ) -> int:
105
+ return safe_base + safe_step * max(0, attempt)
106
+
107
+ return _strategy
108
+
109
+
110
+ def exponential(
111
+ *,
112
+ base_ns: int = 100_000_000,
113
+ factor: float = 2.0,
114
+ max_delay_ns: int = 30_000_000_000,
115
+ jitter: JitterMode = "none",
116
+ ) -> BackoffStrategy:
117
+ """Create an exponential backoff strategy capped at ``max_delay_ns``.
118
+
119
+ Args:
120
+ base_ns: Initial delay in nanoseconds (default ``100_000_000`` = 100 ms).
121
+ factor: Multiplicative growth factor >= 1.0 (default ``2.0``).
122
+ max_delay_ns: Upper bound on delay in nanoseconds (default ``30_000_000_000`` = 30 s).
123
+ jitter: Jitter mode: ``"none"`` (default), ``"full"``, or ``"equal"``.
124
+
125
+ Returns:
126
+ A :data:`BackoffStrategy` callable.
127
+
128
+ Example:
129
+ ```python
130
+ from graphrefly.extra.backoff import exponential
131
+ s = exponential(base_ns=100_000_000, factor=2.0, max_delay_ns=30_000_000_000)
132
+ assert s(0, None, None) == 100_000_000
133
+ assert s(1, None, None) == 200_000_000
134
+ ```
135
+ """
136
+ safe_base = _clamp_non_negative(base_ns)
137
+ safe_factor = 1.0 if factor < 1.0 else factor
138
+ safe_max = _clamp_non_negative(max_delay_ns)
139
+
140
+ def _strategy(
141
+ attempt: int,
142
+ _error: BaseException | None = None,
143
+ _prev_delay: int | None = None,
144
+ ) -> int:
145
+ if safe_base == 0:
146
+ return _apply_jitter(0, jitter)
147
+ if safe_factor == 1.0:
148
+ return _apply_jitter(safe_base, jitter)
149
+
150
+ cap_ratio = safe_max / safe_base if safe_base > 0 else 0.0
151
+ raw_attempt = max(0, attempt)
152
+ growth = 1.0
153
+ for _ in range(raw_attempt):
154
+ if growth >= cap_ratio:
155
+ growth = cap_ratio
156
+ break
157
+ growth *= safe_factor
158
+ delay = int(safe_base * growth)
159
+ if delay > safe_max:
160
+ delay = safe_max
161
+ return _apply_jitter(delay, jitter)
162
+
163
+ return _strategy
164
+
165
+
166
+ def fibonacci(base_ns: int = 100_000_000, *, max_delay_ns: int = 30_000_000_000) -> BackoffStrategy:
167
+ """Create a backoff strategy with Fibonacci-scaled delays.
168
+
169
+ Delays follow the sequence ``1, 2, 3, 5, 8, ... * base_ns``, capped at
170
+ ``max_delay_ns``.
171
+
172
+ Args:
173
+ base_ns: Multiplier in nanoseconds (default ``100_000_000`` = 100 ms).
174
+ max_delay_ns: Upper bound in nanoseconds (default ``30_000_000_000`` = 30 s).
175
+
176
+ Returns:
177
+ A :data:`BackoffStrategy` callable.
178
+
179
+ Example:
180
+ ```python
181
+ from graphrefly.extra.backoff import fibonacci, NS_PER_SEC
182
+ s = fibonacci(1 * NS_PER_SEC)
183
+ assert s(0, None, None) == 1_000_000_000 # 1 * 1s
184
+ assert s(1, None, None) == 2_000_000_000 # 2 * 1s
185
+ ```
186
+ """
187
+ safe_base = _clamp_non_negative(base_ns)
188
+ safe_max = _clamp_non_negative(max_delay_ns)
189
+
190
+ def _fib_unit(attempt: int) -> int:
191
+ if attempt <= 0:
192
+ return 1
193
+ prev, cur = 1, 2
194
+ for _ in range(1, attempt):
195
+ prev, cur = cur, prev + cur
196
+ return cur
197
+
198
+ def _strategy(
199
+ attempt: int,
200
+ _error: BaseException | None = None,
201
+ _prev_delay: int | None = None,
202
+ ) -> int:
203
+ raw = _fib_unit(attempt) * safe_base
204
+ return raw if raw <= safe_max else safe_max
205
+
206
+ return _strategy
207
+
208
+
209
+ def decorrelated_jitter(
210
+ base_ns: int = 100_000_000,
211
+ max_delay_ns: int = 30_000_000_000,
212
+ ) -> BackoffStrategy:
213
+ """Decorrelated jitter (AWS-recommended): ``random(base_ns, min(max, prev * 3))``.
214
+
215
+ Stateless — uses ``prev_delay`` (passed by the consumer) instead of closure state.
216
+ Safe to share across concurrent retry sequences.
217
+
218
+ Args:
219
+ base_ns: Floor of the random range (nanoseconds, default ``100_000_000`` = 100 ms).
220
+ max_delay_ns: Ceiling cap (nanoseconds, default ``30_000_000_000`` = 30 s).
221
+ """
222
+ safe_base = _clamp_non_negative(base_ns)
223
+ safe_max = _clamp_non_negative(max_delay_ns)
224
+
225
+ def _strategy(
226
+ _attempt: int,
227
+ _error: BaseException | None = None,
228
+ prev_delay: int | None = None,
229
+ ) -> int:
230
+ last = prev_delay if prev_delay is not None else safe_base
231
+ ceiling = min(safe_max, last * 3)
232
+ if ceiling <= safe_base:
233
+ return safe_base
234
+ return random.randint(safe_base, ceiling)
235
+
236
+ return _strategy
237
+
238
+
239
+ def with_max_attempts(strategy: BackoffStrategy, max_attempts: int) -> BackoffStrategy:
240
+ """Cap any strategy at *max_attempts*; returns ``None`` after the cap.
241
+
242
+ Args:
243
+ strategy: Inner strategy to wrap.
244
+ max_attempts: Maximum number of attempts (inclusive).
245
+ """
246
+
247
+ def _strategy(
248
+ attempt: int,
249
+ error: BaseException | None = None,
250
+ prev_delay: int | None = None,
251
+ ) -> int | None:
252
+ if attempt >= max_attempts:
253
+ return None
254
+ return strategy(attempt, error, prev_delay)
255
+
256
+ return _strategy
257
+
258
+
259
+ def resolve_backoff_preset(name: BackoffPreset) -> BackoffStrategy:
260
+ """Resolve a preset name string to a :data:`BackoffStrategy` with default parameters.
261
+
262
+ Args:
263
+ name: One of ``"constant"``, ``"linear"``, ``"exponential"``,
264
+ ``"fibonacci"``, or ``"decorrelated_jitter"``.
265
+
266
+ Returns:
267
+ A :data:`BackoffStrategy` configured with default nanosecond parameters.
268
+
269
+ Example:
270
+ ```python
271
+ from graphrefly.extra.backoff import resolve_backoff_preset
272
+ s = resolve_backoff_preset("exponential")
273
+ assert s(0, None, None) == 100_000_000
274
+ ```
275
+ """
276
+ if name == "constant":
277
+ return constant(NS_PER_SEC)
278
+ if name == "linear":
279
+ return linear(NS_PER_SEC)
280
+ if name == "exponential":
281
+ return exponential()
282
+ if name == "fibonacci":
283
+ return fibonacci()
284
+ if name == "decorrelated_jitter":
285
+ return decorrelated_jitter()
286
+ msg = f"Unknown backoff preset: {name!r}"
287
+ raise ValueError(msg)
@@ -0,0 +1,113 @@
1
+ """Watermark-based backpressure controller — reactive PAUSE/RESUME flow control.
2
+
3
+ Purely synchronous, event-driven. No timers, no polling, no async.
4
+ Each controller instance uses a unique lock_id so multiple controllers
5
+ on the same upstream node do not collide.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, NamedTuple, Protocol
11
+
12
+ from graphrefly.core.protocol import MessageType
13
+
14
+
15
+ class WatermarkOptions(NamedTuple):
16
+ """Thresholds for watermark-based backpressure."""
17
+
18
+ high_water_mark: int
19
+ low_water_mark: int
20
+
21
+
22
+ class WatermarkController(Protocol):
23
+ """Watermark-based backpressure controller interface."""
24
+
25
+ @property
26
+ def pending(self) -> int: ...
27
+ @property
28
+ def paused(self) -> bool: ...
29
+ def on_enqueue(self) -> bool: ...
30
+ def on_dequeue(self) -> bool: ...
31
+ def dispose(self) -> None: ...
32
+
33
+
34
+ class _WatermarkControllerImpl:
35
+ """Internal implementation — use :func:`create_watermark_controller`."""
36
+
37
+ __slots__ = ("_send_up", "_high", "_low", "_lock_id", "_pending", "_paused")
38
+
39
+ def __init__(
40
+ self,
41
+ send_up: Any,
42
+ opts: WatermarkOptions,
43
+ ) -> None:
44
+ self._send_up = send_up
45
+ self._high = opts.high_water_mark
46
+ self._low = opts.low_water_mark
47
+ self._lock_id: object = object() # unforgeable identity, like TS Symbol
48
+ self._pending = 0
49
+ self._paused = False
50
+
51
+ @property
52
+ def pending(self) -> int:
53
+ """Number of un-consumed items."""
54
+ return self._pending
55
+
56
+ @property
57
+ def paused(self) -> bool:
58
+ """Whether upstream is currently paused by this controller."""
59
+ return self._paused
60
+
61
+ def on_enqueue(self) -> bool:
62
+ """Call when a DATA message is buffered. Returns ``True`` if PAUSE was sent."""
63
+ self._pending += 1
64
+ if not self._paused and self._pending >= self._high:
65
+ self._paused = True
66
+ self._send_up([(MessageType.PAUSE, self._lock_id)])
67
+ return True
68
+ return False
69
+
70
+ def on_dequeue(self) -> bool:
71
+ """Call when a buffered item is consumed. Returns ``True`` if RESUME was sent."""
72
+ if self._pending > 0:
73
+ self._pending -= 1
74
+ if self._paused and self._pending <= self._low:
75
+ self._paused = False
76
+ self._send_up([(MessageType.RESUME, self._lock_id)])
77
+ return True
78
+ return False
79
+
80
+ def dispose(self) -> None:
81
+ """If paused, send RESUME to unblock upstream."""
82
+ if self._paused:
83
+ self._paused = False
84
+ self._send_up([(MessageType.RESUME, self._lock_id)])
85
+
86
+
87
+ def create_watermark_controller(
88
+ send_up: Any,
89
+ opts: WatermarkOptions,
90
+ ) -> WatermarkController:
91
+ """Create a watermark-based backpressure controller.
92
+
93
+ Purely synchronous, event-driven. No timers, no polling, no async.
94
+ Each controller instance uses a unique ``lock_id`` (``object()``) so
95
+ multiple controllers on the same upstream node do not collide.
96
+
97
+ Args:
98
+ send_up: Callback that delivers messages upstream (e.g. ``handle.up``).
99
+ opts: High/low watermark thresholds (item counts).
100
+
101
+ Returns:
102
+ A :class:`WatermarkController`.
103
+
104
+ Raises:
105
+ ValueError: If watermark options are invalid.
106
+ """
107
+ if opts.high_water_mark < 1:
108
+ raise ValueError("high_water_mark must be >= 1")
109
+ if opts.low_water_mark < 0:
110
+ raise ValueError("low_water_mark must be >= 0")
111
+ if opts.low_water_mark >= opts.high_water_mark:
112
+ raise ValueError("low_water_mark must be < high_water_mark")
113
+ return _WatermarkControllerImpl(send_up, opts)
@@ -0,0 +1,307 @@
1
+ """Checkpoint adapters and :class:`~graphrefly.graph.Graph` save/restore helpers (roadmap §3.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import tempfile
9
+ import warnings
10
+ from contextlib import suppress
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
13
+
14
+ if TYPE_CHECKING:
15
+ from graphrefly.core.node import Node
16
+ from graphrefly.graph.graph import Graph
17
+
18
+ __all__ = [
19
+ "CheckpointAdapter",
20
+ "DictCheckpointAdapter",
21
+ "FileCheckpointAdapter",
22
+ "MemoryCheckpointAdapter",
23
+ "SqliteCheckpointAdapter",
24
+ "checkpoint_node_value",
25
+ "restore_graph_checkpoint",
26
+ "save_graph_checkpoint",
27
+ ]
28
+
29
+
30
+ @runtime_checkable
31
+ class CheckpointAdapter(Protocol):
32
+ """JSON-friendly snapshot persistence (single blob in / out)."""
33
+
34
+ def save(self, data: dict[str, Any]) -> None: ...
35
+ def load(self) -> dict[str, Any] | None: ...
36
+
37
+
38
+ class MemoryCheckpointAdapter:
39
+ """In-memory checkpoint adapter (process-local; useful for tests and embedding).
40
+
41
+ Stores a deep-copy of the snapshot so mutations to the saved dict do not
42
+ affect the stored state.
43
+
44
+ Example:
45
+ ```python
46
+ from graphrefly import Graph, state
47
+ from graphrefly.extra.checkpoint import MemoryCheckpointAdapter, save_graph_checkpoint
48
+ g = Graph("g"); g.add("x", state(1))
49
+ adapter = MemoryCheckpointAdapter()
50
+ save_graph_checkpoint(g, adapter)
51
+ assert adapter.load()["nodes"]["x"]["value"] == 1
52
+ ```
53
+ """
54
+
55
+ __slots__ = ("_data",)
56
+
57
+ def __init__(self) -> None:
58
+ self._data: dict[str, Any] | None = None
59
+
60
+ def save(self, data: dict[str, Any]) -> None:
61
+ self._data = json.loads(json.dumps(data, ensure_ascii=False))
62
+
63
+ def load(self) -> dict[str, Any] | None:
64
+ return None if self._data is None else dict(self._data)
65
+
66
+
67
+ class DictCheckpointAdapter:
68
+ """Store a checkpoint under a fixed key inside a caller-owned ``dict``.
69
+
70
+ Useful for tests or environments where you already manage a shared dict.
71
+
72
+ Args:
73
+ storage: The dict to store the checkpoint in.
74
+ key: Key under which the snapshot is stored (default ``"graphrefly_checkpoint"``).
75
+
76
+ Example:
77
+ ```python
78
+ from graphrefly.extra.checkpoint import DictCheckpointAdapter
79
+ store = {}
80
+ adapter = DictCheckpointAdapter(store)
81
+ adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
82
+ assert "graphrefly_checkpoint" in store
83
+ ```
84
+ """
85
+
86
+ __slots__ = ("_key", "_storage")
87
+
88
+ def __init__(self, storage: dict[str, Any], *, key: str = "graphrefly_checkpoint") -> None:
89
+ self._storage = storage
90
+ self._key = key
91
+
92
+ def save(self, data: dict[str, Any]) -> None:
93
+ self._storage[self._key] = json.loads(json.dumps(data, ensure_ascii=False))
94
+
95
+ def load(self) -> dict[str, Any] | None:
96
+ raw = self._storage.get(self._key)
97
+ return dict(raw) if isinstance(raw, dict) else None
98
+
99
+
100
+ class FileCheckpointAdapter:
101
+ """Persist checkpoint data as JSON to a file using atomic write-then-replace.
102
+
103
+ Writes to a temporary file in the same directory, then renames it over the
104
+ target path to avoid partial writes.
105
+
106
+ Args:
107
+ path: Destination file path (``str`` or :class:`pathlib.Path`).
108
+
109
+ Example:
110
+ ```python
111
+ import tempfile, os
112
+ from graphrefly.extra.checkpoint import FileCheckpointAdapter
113
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
114
+ tmp = f.name
115
+ adapter = FileCheckpointAdapter(tmp)
116
+ adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
117
+ assert os.path.exists(tmp)
118
+ os.unlink(tmp)
119
+ ```
120
+ """
121
+
122
+ __slots__ = ("_path",)
123
+
124
+ def __init__(self, path: str | Path) -> None:
125
+ self._path = Path(path)
126
+
127
+ def save(self, data: dict[str, Any]) -> None:
128
+ self._path.parent.mkdir(parents=True, exist_ok=True)
129
+ payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
130
+ fd, tmp = tempfile.mkstemp(
131
+ dir=self._path.parent,
132
+ prefix=f".{self._path.name}.",
133
+ suffix=".tmp",
134
+ )
135
+ try:
136
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
137
+ f.write(payload)
138
+ f.write("\n")
139
+ os.replace(tmp, self._path)
140
+ except BaseException:
141
+ with suppress(FileNotFoundError):
142
+ os.unlink(tmp)
143
+ raise
144
+
145
+ def load(self) -> dict[str, Any] | None:
146
+ if not self._path.is_file():
147
+ return None
148
+ text = self._path.read_text(encoding="utf-8")
149
+ if not text.strip():
150
+ return None
151
+ data = json.loads(text)
152
+ return data if isinstance(data, dict) else None
153
+
154
+
155
+ def _stable_snapshot_json(data: dict[str, Any]) -> str:
156
+ return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
157
+
158
+
159
+ class SqliteCheckpointAdapter:
160
+ """Persist one checkpoint blob under a fixed key using :mod:`sqlite3` (stdlib, zero deps).
161
+
162
+ Uses a single-row table. Call :meth:`close` when the adapter is no longer needed.
163
+
164
+ Args:
165
+ path: Path to the SQLite database file (``str`` or :class:`pathlib.Path`).
166
+ key: Row key for the checkpoint (default ``"graphrefly_checkpoint"``).
167
+
168
+ Example:
169
+ ```python
170
+ import tempfile, os
171
+ from graphrefly.extra.checkpoint import SqliteCheckpointAdapter
172
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
173
+ tmp = f.name
174
+ adapter = SqliteCheckpointAdapter(tmp)
175
+ adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
176
+ assert adapter.load()["version"] == 1
177
+ adapter.close()
178
+ os.unlink(tmp)
179
+ ```
180
+ """
181
+
182
+ __slots__ = ("_conn", "_key")
183
+
184
+ def __init__(self, path: str | Path, *, key: str = "graphrefly_checkpoint") -> None:
185
+ self._conn = sqlite3.connect(str(path))
186
+ self._key = key
187
+ self._conn.execute(
188
+ "CREATE TABLE IF NOT EXISTS graphrefly_checkpoint (k TEXT PRIMARY KEY, v TEXT NOT NULL)"
189
+ )
190
+ self._conn.commit()
191
+
192
+ def save(self, data: dict[str, Any]) -> None:
193
+ payload = _stable_snapshot_json(data)
194
+ self._conn.execute(
195
+ "INSERT OR REPLACE INTO graphrefly_checkpoint (k, v) VALUES (?, ?)",
196
+ (self._key, payload),
197
+ )
198
+ self._conn.commit()
199
+
200
+ def load(self) -> dict[str, Any] | None:
201
+ row = self._conn.execute(
202
+ "SELECT v FROM graphrefly_checkpoint WHERE k = ?", (self._key,)
203
+ ).fetchone()
204
+ if row is None or not isinstance(row[0], str) or not row[0].strip():
205
+ return None
206
+ parsed = json.loads(row[0])
207
+ return parsed if isinstance(parsed, dict) else None
208
+
209
+ def close(self) -> None:
210
+ """Close the underlying SQLite connection (safe to call more than once)."""
211
+ with suppress(Exception):
212
+ self._conn.close()
213
+
214
+
215
+ def _check_json_serializable(data: dict[str, Any]) -> None:
216
+ """Warn when snapshot values are not JSON-serializable."""
217
+ try:
218
+ json.dumps(data, ensure_ascii=False)
219
+ except (TypeError, ValueError) as exc:
220
+ warnings.warn(
221
+ f"Snapshot contains non-JSON-serializable values: {exc}. "
222
+ "This may cause errors when persisting to JSON-based adapters.",
223
+ stacklevel=3,
224
+ )
225
+
226
+
227
+ def save_graph_checkpoint(graph: Graph, adapter: CheckpointAdapter) -> None:
228
+ """Persist a :meth:`~graphrefly.graph.Graph.snapshot` via a :class:`CheckpointAdapter`.
229
+
230
+ Args:
231
+ graph: The :class:`~graphrefly.graph.Graph` to snapshot.
232
+ adapter: Any :class:`CheckpointAdapter` (memory, file, SQLite, etc.).
233
+
234
+ Example:
235
+ ```python
236
+ from graphrefly import Graph, state
237
+ from graphrefly.extra.checkpoint import MemoryCheckpointAdapter, save_graph_checkpoint
238
+ g = Graph("g"); g.add("x", state(5))
239
+ adapter = MemoryCheckpointAdapter()
240
+ save_graph_checkpoint(g, adapter)
241
+ ```
242
+ """
243
+ snap = graph.snapshot()
244
+ _check_json_serializable(snap)
245
+ adapter.save(snap)
246
+
247
+
248
+ def restore_graph_checkpoint(graph: Graph, adapter: CheckpointAdapter) -> bool:
249
+ """Load a snapshot from *adapter* and apply it to *graph*.
250
+
251
+ Uses :meth:`~graphrefly.graph.Graph.restore` for application.
252
+
253
+ Args:
254
+ graph: The target :class:`~graphrefly.graph.Graph`.
255
+ adapter: Any :class:`CheckpointAdapter` to load from.
256
+
257
+ Returns:
258
+ ``True`` if snapshot data existed and was applied; ``False`` if the adapter
259
+ had no saved data.
260
+
261
+ Example:
262
+ ```python
263
+ from graphrefly import Graph, state
264
+ from graphrefly.extra.checkpoint import (
265
+ MemoryCheckpointAdapter,
266
+ restore_graph_checkpoint,
267
+ save_graph_checkpoint,
268
+ )
269
+ g = Graph("g"); x = state(0); g.add("x", x)
270
+ adapter = MemoryCheckpointAdapter()
271
+ save_graph_checkpoint(g, adapter)
272
+ x.down([("DATA", 99)])
273
+ restored = restore_graph_checkpoint(g, adapter)
274
+ assert restored and g.get("x") == 0
275
+ ```
276
+ """
277
+ data = adapter.load()
278
+ if data is None:
279
+ return False
280
+ graph.restore(data)
281
+ return True
282
+
283
+
284
+ def checkpoint_node_value(node: Node[Any]) -> dict[str, Any]:
285
+ """Build a minimal versioned JSON payload for a single node's last cached value.
286
+
287
+ Useful for custom adapters that persist individual nodes rather than whole
288
+ graph snapshots. Emits a warning when the value is not JSON-serializable.
289
+
290
+ Args:
291
+ node: Any :class:`~graphrefly.core.node.Node` whose ``get()`` value to capture.
292
+
293
+ Returns:
294
+ A ``dict`` with ``version`` (``1``) and ``value`` keys.
295
+
296
+ Example:
297
+ ```python
298
+ from graphrefly import state
299
+ from graphrefly.extra.checkpoint import checkpoint_node_value
300
+ x = state(42)
301
+ payload = checkpoint_node_value(x)
302
+ assert payload == {"version": 1, "value": 42}
303
+ ```
304
+ """
305
+ result: dict[str, Any] = {"version": 1, "value": node.get()}
306
+ _check_json_serializable(result)
307
+ return result