riffdb-langgraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """RiffDB checkpoint-saver integration for LangGraph."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,234 @@
1
+ """Disposable real-driver conformance and StateGraph smoke runner used by ``riffdb dev --exec``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import operator
8
+ import os
9
+ import sys
10
+ from collections.abc import AsyncGenerator
11
+ from typing import Annotated, Any, TypedDict
12
+
13
+ from langgraph.checkpoint.base import empty_checkpoint
14
+ from langgraph.checkpoint.conformance import checkpointer_test, validate
15
+ from langgraph.checkpoint.serde.types import TASKS
16
+ from langgraph.graph import END, START, StateGraph
17
+ from langgraph.types import Send
18
+
19
+ from riffdb_langgraph.saver import RiffDBCheckpointSaver
20
+
21
+
22
+ class _AgentState(TypedDict):
23
+ messages: Annotated[list[str], operator.add]
24
+ items: list[str]
25
+ processed: Annotated[list[str], operator.add]
26
+
27
+
28
+ def _plan_node(state: _AgentState) -> dict[str, Any]:
29
+ return {
30
+ "messages": [f"planned:{len(state.get('items', []))}"],
31
+ }
32
+
33
+
34
+ def _fanout_edges(state: _AgentState) -> list[Send]:
35
+ return [Send("worker", {"item": item}) for item in state.get("items", [])]
36
+
37
+
38
+ def _worker_node(state: Any) -> dict[str, Any]:
39
+ item = str(state["item"])
40
+ return {
41
+ "processed": [item.upper()],
42
+ "messages": [f"worker:{item}"],
43
+ }
44
+
45
+
46
+ def _finalize_node(state: _AgentState) -> dict[str, Any]:
47
+ joined = ",".join(sorted(state.get("processed", [])))
48
+ return {
49
+ "messages": [f"done:{joined}"],
50
+ }
51
+
52
+
53
+ def _build_graph(saver: RiffDBCheckpointSaver) -> Any:
54
+ builder: StateGraph[Any, Any, Any, Any] = StateGraph(_AgentState)
55
+ builder.add_node("plan", _plan_node)
56
+ builder.add_node("worker", _worker_node)
57
+ builder.add_node("finalize", _finalize_node)
58
+ builder.add_edge(START, "plan")
59
+ builder.add_conditional_edges("plan", _fanout_edges, ["worker"])
60
+ builder.add_edge("worker", "finalize")
61
+ builder.add_edge("finalize", END)
62
+ return builder.compile(checkpointer=saver)
63
+
64
+
65
+ def _run_sync_smoke(uri: str) -> dict[str, Any]:
66
+ with RiffDBCheckpointSaver(uri, scope="sync-smoke") as saver:
67
+ graph = _build_graph(saver)
68
+ thread_id = "sync-thread-1"
69
+ config: Any = {"configurable": {"thread_id": thread_id}}
70
+ out = graph.invoke(
71
+ {"messages": ["init"], "items": ["alpha", "beta"], "processed": []},
72
+ config=config,
73
+ )
74
+ assert out["processed"] == ["ALPHA", "BETA"]
75
+ assert out["messages"][0] == "init"
76
+ assert out["messages"][-1] == "done:ALPHA,BETA"
77
+
78
+ snapshot = graph.get_state(config)
79
+ assert snapshot.values["processed"] == ["ALPHA", "BETA"]
80
+
81
+ history = list(graph.get_state_history(config))
82
+ assert len(history) >= 3
83
+
84
+ # Fork/update state and verify time-travel
85
+ graph.update_state(config, {"messages": ["manual-note"]})
86
+ updated = graph.get_state(config)
87
+ assert updated.values["messages"][-1] == "manual-note"
88
+
89
+ # Copy thread and prune sync
90
+ saver.copy_thread(thread_id, "sync-thread-1-copy")
91
+ copied_tuple = saver.get_tuple({"configurable": {"thread_id": "sync-thread-1-copy"}})
92
+ assert copied_tuple is not None
93
+ assert copied_tuple.checkpoint["channel_values"]["messages"][-1] == "manual-note"
94
+
95
+ saver.prune(["sync-thread-1-copy"], strategy="keep_latest")
96
+ pruned_list = list(saver.list({"configurable": {"thread_id": "sync-thread-1-copy"}}))
97
+ assert len(pruned_list) == 1
98
+
99
+ saver.delete_thread("sync-thread-1-copy")
100
+ assert saver.get_tuple({"configurable": {"thread_id": "sync-thread-1-copy"}}) is None
101
+
102
+ # Verify v<4 pending_sends migration on get_tuple
103
+ legacy_tid = "sync-legacy-v3"
104
+ parent_cp = empty_checkpoint()
105
+ parent_cp["v"] = 3
106
+ parent_cfg = saver.put(
107
+ {"configurable": {"thread_id": legacy_tid, "checkpoint_ns": ""}},
108
+ parent_cp,
109
+ {"source": "input", "step": 0},
110
+ {},
111
+ )
112
+ saver.put_writes(parent_cfg, [(TASKS, "migrated-send-payload")], task_id="task-legacy")
113
+ child_cp = empty_checkpoint()
114
+ child_cp["v"] = 3
115
+ child_cfg = saver.put(
116
+ parent_cfg,
117
+ child_cp,
118
+ {"source": "loop", "step": 1},
119
+ {},
120
+ )
121
+ migrated_tuple = saver.get_tuple(child_cfg)
122
+ assert migrated_tuple is not None
123
+ assert migrated_tuple.checkpoint["channel_values"].get(TASKS) == ["migrated-send-payload"]
124
+ saver.delete_thread(legacy_tid)
125
+
126
+ return {
127
+ "sync_messages": out["messages"],
128
+ "sync_processed": out["processed"],
129
+ "sync_history_depth": len(history),
130
+ }
131
+
132
+
133
+ async def _run_async_smoke(uri: str) -> dict[str, Any]:
134
+ async with RiffDBCheckpointSaver(uri, scope="async-smoke") as saver:
135
+ graph = _build_graph(saver)
136
+ thread_id = "async-thread-1"
137
+ config: Any = {"configurable": {"thread_id": thread_id}}
138
+ out = await graph.ainvoke(
139
+ {"messages": ["start"], "items": ["gamma", "delta"], "processed": []},
140
+ config=config,
141
+ )
142
+ assert out["processed"] == ["GAMMA", "DELTA"]
143
+ assert out["messages"][-1] == "done:DELTA,GAMMA"
144
+
145
+ snapshot = await graph.aget_state(config)
146
+ assert snapshot.values["processed"] == ["GAMMA", "DELTA"]
147
+
148
+ history = [s async for s in graph.aget_state_history(config)]
149
+ assert len(history) >= 3
150
+
151
+ return {
152
+ "async_messages": out["messages"],
153
+ "async_processed": out["processed"],
154
+ "async_history_depth": len(history),
155
+ }
156
+
157
+
158
+ async def _run_conformance(uri: str) -> dict[str, Any]:
159
+ suite_counter = 0
160
+
161
+ @checkpointer_test(name="RiffDBCheckpointSaver")
162
+ async def _factory() -> AsyncGenerator[RiffDBCheckpointSaver]:
163
+ nonlocal suite_counter
164
+ suite_counter += 1
165
+ scope = f"conf-{suite_counter}"
166
+ async with RiffDBCheckpointSaver(uri, scope=scope) as saver:
167
+ yield saver
168
+
169
+ report = await validate(_factory)
170
+ if not report.passed_all():
171
+ failures: dict[str, Any] = {}
172
+ for cap, res in report.results.items():
173
+ if not res.passed:
174
+ failures[cap] = {
175
+ "detected": res.detected,
176
+ "passed": res.tests_passed,
177
+ "failed": res.tests_failed,
178
+ "failures": res.failures,
179
+ }
180
+ raise RuntimeError(f"LangGraph checkpointer conformance failed: {json.dumps(failures)}")
181
+
182
+ summary: dict[str, dict[str, Any]] = {}
183
+ total_passed = 0
184
+ for cap, res in sorted(report.results.items()):
185
+ assert res.detected, f"Expected capability {cap} to be detected"
186
+ assert res.passed is True, f"Expected capability {cap} to pass"
187
+ total_passed += res.tests_passed
188
+ summary[cap] = {
189
+ "detected": res.detected,
190
+ "passed": res.passed,
191
+ "tests_passed": res.tests_passed,
192
+ "tests_failed": res.tests_failed,
193
+ }
194
+ return {
195
+ "all_passed": report.passed_all(),
196
+ "total_passed": total_passed,
197
+ "capabilities": summary,
198
+ }
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> int:
202
+ args = sys.argv[1:] if argv is None else argv
203
+ endpoint = args[0] if len(args) >= 1 else os.environ.get("RIFFDB_ENDPOINT", "")
204
+ cred_file = args[1] if len(args) >= 2 else os.environ.get("RIFFDB_CREDENTIAL_FILE", "")
205
+ database = (
206
+ args[2] if len(args) >= 3 else os.environ.get("RIFFDB_DATABASE", "langgraph-development")
207
+ )
208
+ if not endpoint or not cred_file:
209
+ raise SystemExit(
210
+ "usage: python -m riffdb_langgraph <endpoint> <credential_file> [database]"
211
+ )
212
+
213
+ os.environ["RIFFDB_LANGGRAPH_ENDPOINT"] = endpoint
214
+ os.environ["RIFFDB_LANGGRAPH_CREDENTIAL_FILE"] = cred_file
215
+ if os.environ.get("RIFFDB_TENANT") == "":
216
+ os.environ.pop("RIFFDB_TENANT", None)
217
+
218
+ uri = f"riffdb://{database}"
219
+ sync_summary = _run_sync_smoke(uri)
220
+ async_summary = asyncio.run(_run_async_smoke(uri))
221
+ conformance_summary = asyncio.run(_run_conformance(uri))
222
+
223
+ output = {
224
+ "database": database,
225
+ "conformance": conformance_summary,
226
+ "sync_smoke": sync_summary,
227
+ "async_smoke": async_summary,
228
+ }
229
+ print(json.dumps(output, sort_keys=True))
230
+ return 0
231
+
232
+
233
+ if __name__ == "__main__":
234
+ raise SystemExit(main())
@@ -0,0 +1,38 @@
1
+ """Strict, secret-free LangGraph checkpointer URI parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urlsplit
7
+
8
+
9
+ class InvalidCheckpointerURI(ValueError):
10
+ """Raised before transport work when a LangGraph RiffDB URI is unsafe or malformed."""
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CheckpointerConfig:
15
+ """The non-secret configuration carried by a LangGraph checkpointer URI."""
16
+
17
+ database: str
18
+
19
+ @classmethod
20
+ def from_uri(cls, uri: str) -> CheckpointerConfig:
21
+ parsed = urlsplit(uri)
22
+ if parsed.scheme != "riffdb":
23
+ raise InvalidCheckpointerURI("checkpointer URI must use the riffdb scheme")
24
+ if parsed.username is not None or parsed.password is not None:
25
+ raise InvalidCheckpointerURI("checkpointer URI must not contain credentials")
26
+ if parsed.port is not None:
27
+ raise InvalidCheckpointerURI("checkpointer URI must not contain a port")
28
+ if not parsed.hostname:
29
+ raise InvalidCheckpointerURI("checkpointer URI must name one database")
30
+ if parsed.path not in ("", "/"):
31
+ raise InvalidCheckpointerURI("checkpointer URI must not contain a path")
32
+ if parsed.query:
33
+ raise InvalidCheckpointerURI("checkpointer URI must not contain query parameters")
34
+ if parsed.fragment:
35
+ raise InvalidCheckpointerURI("checkpointer URI must not contain a fragment")
36
+ if parsed.hostname != parsed.netloc:
37
+ raise InvalidCheckpointerURI("checkpointer URI contains unsupported authority syntax")
38
+ return cls(database=parsed.hostname)