mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-28 18:59:42 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da59aba37 | ||
|
|
44805588b6 | ||
|
|
68f8893847 | ||
|
|
4cf12f9500 | ||
|
|
a8ab1b1638 | ||
|
|
c78270c40f | ||
|
|
9dc68d6986 | ||
|
|
d6c29f8157 | ||
|
|
4b3839af0f | ||
|
|
569f2d2d14 | ||
|
|
0d32281d5d | ||
|
|
69f2d3a430 | ||
|
|
95b41d058f | ||
|
|
e49c093f48 | ||
|
|
9032a3f90a | ||
|
|
1a989f22bb | ||
|
|
b1331fb9a6 | ||
|
|
b333d4c838 | ||
|
|
86baa5d08e |
@@ -63,6 +63,9 @@ The suite tests **base** capabilities (required) and **extended** capabilities (
|
||||
| `delete_for_runs` | no | `adelete_for_runs` |
|
||||
| `copy_thread` | no | `acopy_thread` |
|
||||
| `prune` | no | `aprune` |
|
||||
| `delta_channel_history` | no | `aget_delta_channel_history` |
|
||||
| `delta_channel_keepset` | no | `aget_delta_channel_keepset` |
|
||||
| `delta_channel_reconstruction` | no | `aput` |
|
||||
|
||||
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ class Capability(str, Enum):
|
||||
DELETE_FOR_RUNS = "delete_for_runs"
|
||||
COPY_THREAD = "copy_thread"
|
||||
PRUNE = "prune"
|
||||
DELTA_CHANNEL_HISTORY = "delta_channel_history"
|
||||
DELTA_CHANNEL_KEEPSET = "delta_channel_keepset"
|
||||
DELTA_CHANNEL_RECONSTRUCTION = "delta_channel_reconstruction"
|
||||
|
||||
|
||||
# Capabilities that every checkpointer must support.
|
||||
@@ -42,6 +45,9 @@ EXTENDED_CAPABILITIES = frozenset(
|
||||
Capability.DELETE_FOR_RUNS,
|
||||
Capability.COPY_THREAD,
|
||||
Capability.PRUNE,
|
||||
Capability.DELTA_CHANNEL_HISTORY,
|
||||
Capability.DELTA_CHANNEL_KEEPSET,
|
||||
Capability.DELTA_CHANNEL_RECONSTRUCTION,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,6 +63,9 @@ _CAPABILITY_METHOD_MAP: dict[Capability, str] = {
|
||||
Capability.DELETE_FOR_RUNS: "adelete_for_runs",
|
||||
Capability.COPY_THREAD: "acopy_thread",
|
||||
Capability.PRUNE: "aprune",
|
||||
Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history",
|
||||
Capability.DELTA_CHANNEL_KEEPSET: "aget_tuple",
|
||||
Capability.DELTA_CHANNEL_RECONSTRUCTION: "aput",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,15 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
|
||||
run_delete_thread_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
|
||||
run_delta_channel_history_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
|
||||
run_delta_channel_keepset_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
|
||||
run_delta_channel_reconstruction_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
|
||||
@@ -24,4 +33,7 @@ __all__ = [
|
||||
"run_delete_for_runs_tests",
|
||||
"run_copy_thread_tests",
|
||||
"run_prune_tests",
|
||||
"run_delta_channel_history_tests",
|
||||
"run_delta_channel_keepset_tests",
|
||||
"run_delta_channel_reconstruction_tests",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Shared fixtures for delta-channel conformance tests.
|
||||
|
||||
Builds a parent chain with `_DeltaSnapshot` blobs at known positions via
|
||||
direct `aput` / `aput_writes` calls. No langgraph or Pregel dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
|
||||
async def build_delta_chain(
|
||||
saver: BaseCheckpointSaver,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
checkpoint_ns: str = "",
|
||||
channel: str = "messages",
|
||||
snapshots_at_steps: Sequence[int] = (0,),
|
||||
total_steps: int = 6,
|
||||
write_value_fn: Any | None = None,
|
||||
) -> list[RunnableConfig]:
|
||||
"""Build a parent chain with `_DeltaSnapshot` at known positions.
|
||||
|
||||
Args:
|
||||
saver: Checkpointer instance.
|
||||
thread_id: Defaults to a random UUID.
|
||||
checkpoint_ns: Namespace (default root).
|
||||
channel: Channel name used for snapshots and writes.
|
||||
snapshots_at_steps: Steps at which a `_DeltaSnapshot` blob is stored
|
||||
in `channel_values[channel]`. Step 0 is the oldest checkpoint.
|
||||
total_steps: Number of checkpoints in the chain.
|
||||
write_value_fn: Callable(step) -> write value. Defaults to step index.
|
||||
|
||||
Returns:
|
||||
List of stored configs (oldest first), one per step.
|
||||
"""
|
||||
if write_value_fn is None:
|
||||
|
||||
def write_value_fn(step: int) -> Any:
|
||||
return step
|
||||
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
thread_id = thread_id or str(uuid4())
|
||||
snapshot_set = set(snapshots_at_steps)
|
||||
stored: list[RunnableConfig] = []
|
||||
parent_cfg: RunnableConfig | None = None
|
||||
|
||||
for step in range(total_steps):
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
}
|
||||
}
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
|
||||
channel_values: dict[str, Any] = {}
|
||||
channel_versions: dict[str, int] = {}
|
||||
if step in snapshot_set:
|
||||
channel_values[channel] = _DeltaSnapshot(
|
||||
write_value_fn(step),
|
||||
)
|
||||
channel_versions[channel] = step + 1
|
||||
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
ts="",
|
||||
channel_values=channel_values,
|
||||
channel_versions=channel_versions,
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
new_versions = dict(channel_versions)
|
||||
parent_cfg = await saver.aput(
|
||||
config, cp, generate_metadata(step=step), new_versions
|
||||
)
|
||||
stored.append(parent_cfg)
|
||||
|
||||
# Write a pending write for non-snapshot steps so the walk has
|
||||
# something to collect.
|
||||
if step not in snapshot_set:
|
||||
await saver.aput_writes(
|
||||
parent_cfg, [(channel, write_value_fn(step))], str(uuid4())
|
||||
)
|
||||
|
||||
return stored
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
"""DELTA_CHANNEL_HISTORY capability tests — aget_delta_channel_history contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
|
||||
|
||||
|
||||
async def test_history_returns_writes_oldest_first(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Writes are returned oldest-to-newest."""
|
||||
tid = str(uuid4())
|
||||
# 5 steps: snapshot at 0, writes at 1,2,3,4.
|
||||
# Head is step 4. Walk starts at step 3 (parent of head).
|
||||
# Collects writes from steps 1,2,3 (between snapshot at 0 and head's parent).
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
writes = result["ch"]["writes"]
|
||||
values = [w[2] for w in writes]
|
||||
assert values == [1, 2, 3], f"Expected [1,2,3], got {values}"
|
||||
|
||||
|
||||
async def test_history_seed_is_nearest_snapshot(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Seed is the value from the nearest ancestor with channel_values populated."""
|
||||
tid = str(uuid4())
|
||||
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
|
||||
# Head is step 5. Walk from step 4 backward stops at step 3 (snapshot).
|
||||
# Collects writes from step 4 only (between step 3 and head's parent step 4).
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="ch",
|
||||
snapshots_at_steps=[0, 3],
|
||||
total_steps=6,
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
assert "seed" in result["ch"], "Expected seed from snapshot at step 3"
|
||||
seed = result["ch"]["seed"]
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
actual_value = seed.value if isinstance(seed, _DeltaSnapshot) else seed
|
||||
assert actual_value == 3, f"Expected seed value 3 (step 3), got {actual_value}"
|
||||
writes = result["ch"]["writes"]
|
||||
values = [w[2] for w in writes]
|
||||
assert values == [4], f"Expected [4], got {values}"
|
||||
|
||||
|
||||
async def test_history_excludes_target_pending_writes(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Target's own pending_writes are NOT included in the history."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
|
||||
)
|
||||
head = configs[-1]
|
||||
# Add writes directly to the head checkpoint
|
||||
await saver.aput_writes(head, [("ch", "extra")], str(uuid4()))
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
writes = result["ch"]["writes"]
|
||||
values = [w[2] for w in writes]
|
||||
assert "extra" not in values, f"Target's writes should be excluded, got {values}"
|
||||
|
||||
|
||||
async def test_history_multi_channel(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Multiple channels have independent walk termination."""
|
||||
tid = str(uuid4())
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
for step in range(5):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cv: dict = {}
|
||||
cvs: dict = {}
|
||||
if step == 1:
|
||||
cv["a"] = _DeltaSnapshot("snap_a")
|
||||
cvs["a"] = step + 1
|
||||
if step == 3:
|
||||
cv["b"] = _DeltaSnapshot("snap_b")
|
||||
cvs["b"] = step + 1
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
ts="",
|
||||
channel_values=cv,
|
||||
channel_versions=cvs,
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
await saver.aput_writes(parent_cfg, [("a", step), ("b", step)], str(uuid4()))
|
||||
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["a", "b"])
|
||||
a_writes = [w[2] for w in result["a"]["writes"]]
|
||||
b_writes = [w[2] for w in result["b"]["writes"]]
|
||||
assert a_writes == [1, 2, 3], f"Expected a writes [1,2,3], got {a_writes}"
|
||||
assert b_writes == [3], f"Expected b writes [3], got {b_writes}"
|
||||
|
||||
|
||||
async def test_history_empty_channels_returns_empty(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Empty channels list returns empty mapping."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
|
||||
)
|
||||
result = await saver.aget_delta_channel_history(config=configs[-1], channels=[])
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_history_walk_to_root_no_seed(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Walk reaches root without finding seed — no 'seed' key in result."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="ch",
|
||||
snapshots_at_steps=[],
|
||||
total_steps=4,
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
assert "seed" not in result["ch"], f"Expected no seed, got {result['ch']}"
|
||||
|
||||
|
||||
async def test_history_migration_plain_value_as_seed(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Pre-delta plain value in channel_values acts as seed (migration case).
|
||||
|
||||
When a thread was originally using a regular channel (BinaryOperatorAggregate)
|
||||
and later switches to DeltaChannel, the old checkpoint has a plain value in
|
||||
channel_values[ch] (not a _DeltaSnapshot). The walk should treat it as the
|
||||
seed and terminate there.
|
||||
"""
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
tid = str(uuid4())
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
|
||||
for step in range(4):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cv: dict = {}
|
||||
cvs: dict = {}
|
||||
# Step 1: plain value (migration case — old checkpoint before delta)
|
||||
if step == 1:
|
||||
cv["ch"] = [10, 20, 30]
|
||||
cvs["ch"] = step + 1
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
ts="",
|
||||
channel_values=cv,
|
||||
channel_versions=cvs,
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
if step != 1:
|
||||
await saver.aput_writes(parent_cfg, [("ch", step)], str(uuid4()))
|
||||
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
# Seed should be the plain value from step 1
|
||||
assert "seed" in result["ch"], "Expected seed from migration plain value at step 1"
|
||||
seed = result["ch"]["seed"]
|
||||
assert seed == [10, 20, 30], f"Expected plain value [10,20,30], got {seed}"
|
||||
# Writes should be from step 2 only (between seed at step 1 and head's parent step 2)
|
||||
writes = result["ch"]["writes"]
|
||||
values = [w[2] for w in writes]
|
||||
assert values == [2], f"Expected [2], got {values}"
|
||||
|
||||
|
||||
ALL_DELTA_CHANNEL_HISTORY_TESTS = [
|
||||
test_history_returns_writes_oldest_first,
|
||||
test_history_seed_is_nearest_snapshot,
|
||||
test_history_excludes_target_pending_writes,
|
||||
test_history_multi_channel,
|
||||
test_history_empty_channels_returns_empty,
|
||||
test_history_walk_to_root_no_seed,
|
||||
test_history_migration_plain_value_as_seed,
|
||||
]
|
||||
|
||||
|
||||
async def run_delta_channel_history_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all delta_channel_history tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_DELTA_CHANNEL_HISTORY_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("delta_channel_history", test_fn.__name__, True, None)
|
||||
except Exception:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delta_channel_history",
|
||||
test_fn.__name__,
|
||||
False,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
return passed, failed, failures
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""DELTA_CHANNEL_KEEPSET capability tests — aget_delta_channel_keepset contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
|
||||
|
||||
|
||||
async def test_keepset_empty_channels_returns_target_only(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Empty channels → keep-set is just {target_id}."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=4
|
||||
)
|
||||
head = configs[-1]
|
||||
keep = await saver.aget_delta_channel_keepset(config=head, channels=[])
|
||||
head_id = head["configurable"]["checkpoint_id"]
|
||||
assert keep == {head_id}, f"Expected only target, got {keep}"
|
||||
|
||||
|
||||
async def test_keepset_snapshot_at_target(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""When target itself has a snapshot, keep-set is just {target_id}."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="ch",
|
||||
snapshots_at_steps=[0, 3],
|
||||
total_steps=4,
|
||||
)
|
||||
head = configs[3]
|
||||
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
|
||||
head_id = head["configurable"]["checkpoint_id"]
|
||||
assert keep == {head_id}, f"Snapshot at target should yield only target, got {keep}"
|
||||
|
||||
|
||||
async def test_keepset_snapshot_n_back(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Snapshot N steps back → target + intermediates + snapshot ancestor."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
|
||||
)
|
||||
head = configs[-1]
|
||||
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
|
||||
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs}
|
||||
assert keep == expected_ids, f"Expected all ancestors, got {keep}"
|
||||
|
||||
|
||||
async def test_keepset_multi_channel_union(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Multi-channel keep-set is the union (max chain per channel)."""
|
||||
tid = str(uuid4())
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
for step in range(6):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cv: dict = {}
|
||||
cvs: dict = {}
|
||||
# Channel "a" has snapshot at step 3 (recent)
|
||||
if step == 3:
|
||||
cv["a"] = _DeltaSnapshot("snap_a")
|
||||
cvs["a"] = step + 1
|
||||
# Channel "b" has snapshot at step 1 (further back)
|
||||
if step == 1:
|
||||
cv["b"] = _DeltaSnapshot("snap_b")
|
||||
cvs["b"] = step + 1
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
ts="",
|
||||
channel_values=cv,
|
||||
channel_versions=cvs,
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
|
||||
head = configs[-1]
|
||||
keep = await saver.aget_delta_channel_keepset(config=head, channels=["a", "b"])
|
||||
# Union: b needs back to step 1, so steps 1..5 (all except step 0) plus head
|
||||
expected_ids = {c["configurable"]["checkpoint_id"] for c in configs[1:]}
|
||||
expected_ids.add(head["configurable"]["checkpoint_id"])
|
||||
assert keep == expected_ids, f"Expected union, got {keep} vs {expected_ids}"
|
||||
|
||||
|
||||
async def test_keepset_walk_to_root(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""No snapshot anywhere → entire chain to root is in keep-set."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[], total_steps=4
|
||||
)
|
||||
head = configs[-1]
|
||||
keep = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
|
||||
all_ids = {c["configurable"]["checkpoint_id"] for c in configs}
|
||||
assert keep == all_ids, f"Expected full chain, got {keep}"
|
||||
|
||||
|
||||
async def test_keepset_deterministic(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Same inputs return identical sets."""
|
||||
tid = str(uuid4())
|
||||
configs = await build_delta_chain(
|
||||
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
|
||||
)
|
||||
head = configs[-1]
|
||||
keep1 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
|
||||
keep2 = await saver.aget_delta_channel_keepset(config=head, channels=["ch"])
|
||||
assert keep1 == keep2
|
||||
|
||||
|
||||
ALL_DELTA_CHANNEL_KEEPSET_TESTS = [
|
||||
test_keepset_empty_channels_returns_target_only,
|
||||
test_keepset_snapshot_at_target,
|
||||
test_keepset_snapshot_n_back,
|
||||
test_keepset_multi_channel_union,
|
||||
test_keepset_walk_to_root,
|
||||
test_keepset_deterministic,
|
||||
]
|
||||
|
||||
|
||||
async def run_delta_channel_keepset_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all delta_channel_keepset tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_DELTA_CHANNEL_KEEPSET_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("delta_channel_keepset", test_fn.__name__, True, None)
|
||||
except Exception:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delta_channel_keepset",
|
||||
test_fn.__name__,
|
||||
False,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
return passed, failed, failures
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"""DELTA_CHANNEL_RECONSTRUCTION capability tests — end-to-end round-trip.
|
||||
|
||||
Exercises: aput + aput_writes + aget_delta_channel_history + reconstruction.
|
||||
This catches the most common silent-corruption mode: failing to round-trip
|
||||
`_DeltaSnapshot` blobs through serialization.
|
||||
|
||||
NOTE: This test does NOT import from `langgraph` (which is not a dependency
|
||||
of checkpoint-conformance). Instead it inlines a minimal reconstruction
|
||||
equivalent: seed + fold writes through a simple list-append reducer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
|
||||
|
||||
|
||||
def _reconstruct(seed: Any, writes: list) -> list:
|
||||
"""Minimal DeltaChannel reconstruction: list-append reducer.
|
||||
|
||||
Mirrors DeltaChannel.from_checkpoint(seed) + replay_writes(writes).
|
||||
"""
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
if seed is None:
|
||||
base: list = []
|
||||
elif isinstance(seed, _DeltaSnapshot):
|
||||
base = list(seed.value)
|
||||
else:
|
||||
base = list(seed)
|
||||
for _task_id, _ch, value in writes:
|
||||
base = base + value
|
||||
return base
|
||||
|
||||
|
||||
async def test_reconstruction_basic(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Reconstruct delta channel value from history matches expected."""
|
||||
tid = str(uuid4())
|
||||
# 5 steps: snapshot at 0 (value=[0]), writes at 1,2,3,4.
|
||||
# Head = step 4. Walk from parent (step 3) collects writes 1,2,3.
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="msgs",
|
||||
snapshots_at_steps=[0],
|
||||
total_steps=5,
|
||||
write_value_fn=lambda step: [step],
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
|
||||
history = result["msgs"]
|
||||
|
||||
seed = history.get("seed")
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
# seed=[0] from step 0, writes from steps 1,2,3
|
||||
expected = [0] + [1] + [2] + [3]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
async def test_reconstruction_mid_chain_snapshot(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Reconstruction works when snapshot is mid-chain."""
|
||||
tid = str(uuid4())
|
||||
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
|
||||
# Head = step 5. Walk from step 4 stops at step 3 (snapshot).
|
||||
# Collects writes from step 4.
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="msgs",
|
||||
snapshots_at_steps=[0, 3],
|
||||
total_steps=6,
|
||||
write_value_fn=lambda step: [step],
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
|
||||
history = result["msgs"]
|
||||
|
||||
seed = history.get("seed")
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
# Snapshot at step 3 = [3], write from step 4
|
||||
expected = [3] + [4]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
async def test_reconstruction_no_snapshot(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Reconstruction from root (no snapshot) gives all writes accumulated."""
|
||||
tid = str(uuid4())
|
||||
# 4 steps: no snapshot, writes at 0,1,2,3.
|
||||
# Head = step 3. Walk from step 2 collects writes 0,1,2.
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
channel="msgs",
|
||||
snapshots_at_steps=[],
|
||||
total_steps=4,
|
||||
write_value_fn=lambda step: [step],
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["msgs"])
|
||||
history = result["msgs"]
|
||||
|
||||
seed = history.get("seed")
|
||||
reconstructed = _reconstruct(seed, history["writes"])
|
||||
|
||||
# No seed → start empty, writes from steps 0,1,2
|
||||
expected = [0] + [1] + [2]
|
||||
assert reconstructed == expected, (
|
||||
f"Reconstructed {reconstructed} != expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS = [
|
||||
test_reconstruction_basic,
|
||||
test_reconstruction_mid_chain_snapshot,
|
||||
test_reconstruction_no_snapshot,
|
||||
]
|
||||
|
||||
|
||||
async def run_delta_channel_reconstruction_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all reconstruction tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_DELTA_CHANNEL_RECONSTRUCTION_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delta_channel_reconstruction", test_fn.__name__, True, None
|
||||
)
|
||||
except Exception:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delta_channel_reconstruction",
|
||||
test_fn.__name__,
|
||||
False,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
return passed, failed, failures
|
||||
@@ -19,6 +19,15 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
|
||||
run_delete_thread_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
|
||||
run_delta_channel_history_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_keepset import (
|
||||
run_delta_channel_keepset_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delta_channel_reconstruction import (
|
||||
run_delta_channel_reconstruction_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
|
||||
@@ -35,6 +44,9 @@ _RUNNERS = {
|
||||
Capability.DELETE_FOR_RUNS: run_delete_for_runs_tests,
|
||||
Capability.COPY_THREAD: run_copy_thread_tests,
|
||||
Capability.PRUNE: run_prune_tests,
|
||||
Capability.DELTA_CHANNEL_HISTORY: run_delta_channel_history_tests,
|
||||
Capability.DELTA_CHANNEL_KEEPSET: run_delta_channel_keepset_tests,
|
||||
Capability.DELTA_CHANNEL_RECONSTRUCTION: run_delta_channel_reconstruction_tests,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,11 @@ asyncio_mode = "auto"
|
||||
# The extended methods (acopy_thread, adelete_for_runs, aprune) are checked
|
||||
# at runtime via capability detection and may not exist on the installed
|
||||
# base class. Dict literal inference is also overly strict for RunnableConfig.
|
||||
# Delta-channel tests import from `langgraph` (not a declared dep of this
|
||||
# package — at test time it is installed alongside); private `_DeltaSnapshot`
|
||||
# imports are intentional (beta surface).
|
||||
unresolved-attribute = "ignore"
|
||||
unresolved-import = "ignore"
|
||||
invalid-argument-type = "ignore"
|
||||
invalid-return-type = "ignore"
|
||||
|
||||
@@ -58,6 +62,9 @@ lint.select = [
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
[tool.uv.sources]
|
||||
langgraph-checkpoint = {path = "../checkpoint", editable = true}
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
|
||||
Generated
+605
-504
File diff suppressed because it is too large
Load Diff
@@ -279,8 +279,8 @@ def _build_delta_stage2_sql(
|
||||
)
|
||||
for _ in channels_with_seed:
|
||||
branches.append(
|
||||
"SELECT 'b'::text, NULL, channel, "
|
||||
"type, blob, NULL, NULL, version "
|
||||
"SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
|
||||
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
|
||||
"FROM checkpoint_blobs "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND version = %s"
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import random
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from contextlib import closing, contextmanager
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -16,12 +16,19 @@ from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
SerializerProtocol,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
from langgraph.checkpoint.sqlite._delta import (
|
||||
DELTA_STAGE1_SQL,
|
||||
build_delta_channels_writes_history,
|
||||
build_delta_stage2_sql,
|
||||
step_walk_with_row,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import search_where
|
||||
|
||||
_AIO_ERROR_MSG = (
|
||||
@@ -493,6 +500,88 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
(str(thread_id),),
|
||||
)
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
Two-stage query:
|
||||
|
||||
* Stage 1 (paged): newest-first slice of `checkpoints` returning
|
||||
`(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
|
||||
ancestor. Sqlite has no JSONB, so we ship the full serialized
|
||||
checkpoint blob and inspect `channel_values` in Python. Pages
|
||||
newest-first by `checkpoint_id` with a `< cursor` predicate;
|
||||
page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
|
||||
has found its seed or the chain is exhausted.
|
||||
|
||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||
`writes` filtered to that channel's specific `chain_cids`. No
|
||||
separate seed-blob fetch — sqlite stores `channel_values` inline
|
||||
in the checkpoint blob, so seeds come back from stage 1.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
thread_id = str(config["configurable"]["thread_id"])
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = self.get_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_val_by_ch: dict[str, Any] = {}
|
||||
walk_state: dict[str, Any] = {}
|
||||
seeded: set[str] = set()
|
||||
|
||||
with self.cursor(transaction=False) as cur:
|
||||
cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
|
||||
for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
serde=self.serde,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
walk_state=walk_state,
|
||||
seeded=seeded,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
stage2_sql = build_delta_stage2_sql(
|
||||
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
||||
)
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
stage2_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
||||
)
|
||||
cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = cast(
|
||||
"list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
|
||||
)
|
||||
else:
|
||||
stage2_rows = []
|
||||
|
||||
return build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
seeded=seeded,
|
||||
stage2_rows=stage2_rows,
|
||||
serde=self.serde,
|
||||
)
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Get a checkpoint tuple from the database asynchronously.
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Shared helpers for `get_delta_channel_history` on sqlite savers.
|
||||
|
||||
Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk +
|
||||
per-channel UNION ALL writes fetch), but adapted for sqlite's
|
||||
constraints. The structural differences:
|
||||
|
||||
* No JSONB — to inspect `channel_values` for a checkpoint we must
|
||||
deserialize the full blob. Stage 1 streams the cursor row-by-row and
|
||||
deserializes only the rows the merged walk visits, freeing each blob
|
||||
before advancing.
|
||||
* No separate blob table — `channel_values` lives inline in the
|
||||
checkpoint, so seeds come back from stage 1 with no second fetch.
|
||||
* Single merged walk (not K independent walks): each visited cid is
|
||||
deserialized exactly once, regardless of how many channels are still
|
||||
seeking their seed.
|
||||
|
||||
The streaming design keeps peak in-flight memory at roughly one
|
||||
deserialized checkpoint at a time, instead of holding the entire
|
||||
ancestor chain's worth of raw blobs as a `fetchall()`-materialized list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
|
||||
|
||||
# Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
|
||||
# predicate keeps target itself in the stream so we can read its
|
||||
# `parent_checkpoint_id` from the first row without a separate lookup;
|
||||
# the caller skips target's own writes/seed (matches the
|
||||
# `BaseCheckpointSaver` contract).
|
||||
DELTA_STAGE1_SQL = (
|
||||
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
||||
"FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
|
||||
"ORDER BY checkpoint_id DESC"
|
||||
)
|
||||
|
||||
|
||||
def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
||||
"""Stage-2 per-channel UNION ALL fetching writes from `writes`.
|
||||
|
||||
One branch per channel with a non-empty chain. Each branch inlines its
|
||||
own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind
|
||||
equivalent of postgres's `= ANY(%s)`. Caller passes parameters in
|
||||
matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per
|
||||
branch.
|
||||
|
||||
Returns an empty string when no channel has a chain (caller skips
|
||||
executing in that case). Per-channel UNION ALL avoids the over-fetch
|
||||
of a single `channel = ANY(channels)` filter when channels have
|
||||
different chain depths — same rationale as postgres.
|
||||
"""
|
||||
branches: list[str] = []
|
||||
for n in chain_lens:
|
||||
cid_placeholders = ",".join("?" * n)
|
||||
branches.append(
|
||||
"SELECT checkpoint_id, channel, task_id, idx, type, value "
|
||||
"FROM writes "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
|
||||
f"AND checkpoint_id IN ({cid_placeholders})"
|
||||
)
|
||||
return " UNION ALL ".join(branches)
|
||||
|
||||
|
||||
def step_walk_with_row(
|
||||
*,
|
||||
cid: str,
|
||||
parent_cid: str | None,
|
||||
type_tag: str,
|
||||
blob: bytes,
|
||||
target_id: str,
|
||||
serde: Any,
|
||||
chain_by_ch: dict[str, list[str]],
|
||||
seed_val_by_ch: dict[str, Any],
|
||||
walk_state: dict[str, Any],
|
||||
seeded: set[str],
|
||||
channels: Sequence[str],
|
||||
) -> bool:
|
||||
"""Process one streamed stage-1 row in the merged ancestor walk.
|
||||
|
||||
The cursor returns (cid, parent_cid, type, blob) rows in
|
||||
`checkpoint_id` DESC order starting at target. The first row is
|
||||
target itself; we read its parent_cid to seed the walk and otherwise
|
||||
skip it (target's own writes/seed are not part of the contract).
|
||||
|
||||
For each subsequent row, if `cid` matches the walk's current
|
||||
position, we deserialize the blob, append the cid to every
|
||||
not-yet-seeded channel's chain, and check `channel_values` for
|
||||
seeds. The deserialized checkpoint is dropped before advancing — no
|
||||
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
||||
|
||||
Off-path rows (different branch on the same thread) advance the
|
||||
cursor without doing any work.
|
||||
|
||||
Returns True when every requested channel is seeded — the caller
|
||||
can stop iterating and close the cursor.
|
||||
"""
|
||||
if "started" not in walk_state:
|
||||
if cid == target_id:
|
||||
walk_state["started"] = True
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
||||
# Not target yet (or target not present): keep streaming.
|
||||
return False
|
||||
active: set[str] = walk_state["active"]
|
||||
if not active:
|
||||
return True
|
||||
if cid != walk_state["cur_cid"]:
|
||||
# Off-path row from a sibling branch — skip without deserializing.
|
||||
return False
|
||||
for ch in active:
|
||||
chain_by_ch[ch].append(cid)
|
||||
ckpt = serde.loads_typed((type_tag, blob))
|
||||
channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
|
||||
for ch in [ch for ch in active if ch in channel_values]:
|
||||
seed_val_by_ch[ch] = channel_values[ch]
|
||||
seeded.add(ch)
|
||||
active.discard(ch)
|
||||
del ckpt, channel_values
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
return not active
|
||||
|
||||
|
||||
def build_delta_channels_writes_history(
|
||||
*,
|
||||
channels: Sequence[str],
|
||||
chain_by_ch: Mapping[str, list[str]],
|
||||
seed_val_by_ch: Mapping[str, Any],
|
||||
seeded: set[str],
|
||||
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
|
||||
serde: Any,
|
||||
) -> dict[str, DeltaChannelHistory]:
|
||||
"""Demux stage-2 rows per channel; produce per-channel histories.
|
||||
|
||||
Stage-2 rows are `(checkpoint_id, channel, task_id, idx, type, value)`.
|
||||
Final write order is oldest→newest globally and `(task_id, idx)` within
|
||||
a checkpoint, matching the contract on `DeltaChannelHistory.writes`.
|
||||
|
||||
`seed` is omitted when the walk reached a true root with no snapshot
|
||||
found (channel never entered `seeded`); consumers treat absence as
|
||||
"start empty".
|
||||
"""
|
||||
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
|
||||
ch: {} for ch in channels
|
||||
}
|
||||
for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows:
|
||||
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
||||
(type_tag, value_blob, task_id, idx)
|
||||
)
|
||||
for cid_map in writes_by_ch_by_cid.values():
|
||||
for ws in cid_map.values():
|
||||
ws.sort(key=lambda w: (w[2], w[3]))
|
||||
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
chain_cids = chain_by_ch.get(ch, [])
|
||||
cid_writes = writes_by_ch_by_cid.get(ch, {})
|
||||
collected: list[PendingWrite] = []
|
||||
# Chain is newest-first; iterate oldest-first for the public order.
|
||||
for cid in reversed(chain_cids):
|
||||
for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
|
||||
collected.append(
|
||||
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
||||
)
|
||||
entry: DeltaChannelHistory = {"writes": collected}
|
||||
if ch in seeded:
|
||||
entry["seed"] = seed_val_by_ch[ch]
|
||||
result[ch] = entry
|
||||
return result
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
@@ -17,12 +17,19 @@ from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
SerializerProtocol,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
from langgraph.checkpoint.sqlite._delta import (
|
||||
DELTA_STAGE1_SQL,
|
||||
build_delta_channels_writes_history,
|
||||
build_delta_stage2_sql,
|
||||
step_walk_with_row,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import search_where
|
||||
|
||||
T = TypeVar("T", bound=Callable)
|
||||
@@ -272,6 +279,29 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
self.adelete_thread(thread_id), self.loop
|
||||
).result()
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Sync bridge to `aget_delta_channel_history`.
|
||||
|
||||
Mirrors the same cross-thread guard as `get_tuple` /
|
||||
`delete_thread` — calling from the loop thread raises rather than
|
||||
deadlocking.
|
||||
"""
|
||||
try:
|
||||
if asyncio.get_running_loop() is self.loop:
|
||||
raise asyncio.InvalidStateError(
|
||||
"Synchronous calls to AsyncSqliteSaver are only allowed from a "
|
||||
"different thread. From the main thread, use the async interface. "
|
||||
"For example, use `await checkpointer.aget_delta_channel_history(...)`."
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aget_delta_channel_history(config=config, channels=channels),
|
||||
self.loop,
|
||||
).result()
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
@@ -589,6 +619,83 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def aget_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||
|
||||
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
||||
is the async equivalent using `aiosqlite` cursors. Stage 1 pages
|
||||
the parent chain newest-first and Python-deserializes each
|
||||
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
||||
only the relevant writes via per-channel UNION ALL.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
await self.setup()
|
||||
thread_id = str(config["configurable"]["thread_id"])
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = await self.aget_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_val_by_ch: dict[str, Any] = {}
|
||||
walk_state: dict[str, Any] = {}
|
||||
seeded: set[str] = set()
|
||||
|
||||
async with self.lock, self.conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
async for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
serde=self.serde,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
walk_state=walk_state,
|
||||
seeded=seeded,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
stage2_sql = build_delta_stage2_sql(
|
||||
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
||||
)
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
stage2_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
||||
)
|
||||
await cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = cast(
|
||||
"list[tuple[str, str, str, int, str, bytes]]",
|
||||
await cur.fetchall(),
|
||||
)
|
||||
else:
|
||||
stage2_rows = []
|
||||
|
||||
return build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
seeded=seeded,
|
||||
stage2_rows=stage2_rows,
|
||||
serde=self.serde,
|
||||
)
|
||||
|
||||
def get_next_version(self, current: str | None, channel: None) -> str:
|
||||
"""Generate the next version ID for a channel.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.0.3"
|
||||
version = "3.1.0a1"
|
||||
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=3,<5.0.0",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlite-vec>=0.1.6",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Run delta-channel conformance capabilities against AsyncSqliteSaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip(
|
||||
"langgraph.checkpoint.conformance",
|
||||
reason="langgraph-checkpoint-conformance not installed",
|
||||
)
|
||||
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_channel_conformance():
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
@checkpointer_test(name="AsyncSqliteSaver")
|
||||
async def sqlite_saver():
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
yield saver
|
||||
|
||||
report = await validate(
|
||||
sqlite_saver,
|
||||
capabilities={
|
||||
"delta_channel_history",
|
||||
"delta_channel_keepset",
|
||||
"delta_channel_reconstruction",
|
||||
},
|
||||
)
|
||||
for cap, result in report.results.items():
|
||||
if result.passed is False:
|
||||
details = "\n".join(result.failures or [])
|
||||
pytest.fail(f"Capability {cap} failed:\n{details}")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Sqlite-specific migration smoke tests: BinaryOperatorAggregate -> DeltaChannel.
|
||||
|
||||
Mirrors `libs/langgraph/tests/test_delta_channel_migration.py` (which
|
||||
covers `InMemorySaver` + a third-party fallback to the base default
|
||||
impl). This file exercises the same migration scenario through the
|
||||
sqlite-specific `SqliteSaver.get_delta_channel_history` override —
|
||||
specifically that the streaming ancestor walk finds a pre-migration
|
||||
plain `channel_values[ch]` entry and surfaces it as the `seed`, with
|
||||
post-migration writes folding on top through the reducer.
|
||||
|
||||
Pre-migration checkpoints under `BinaryOperatorAggregate` carry the
|
||||
full accumulated value at every settled super-step boundary. The
|
||||
override has to identify those as "real" seeds (not `_DeltaSnapshot`
|
||||
sentinels) — the saver layer is intentionally delta-agnostic and just
|
||||
returns whatever is stored in `channel_values[ch]`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
# `langgraph` core isn't a dep of `langgraph-checkpoint-sqlite`. Skip the
|
||||
# whole module rather than importerror-ing in the standalone CI shape.
|
||||
pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed")
|
||||
pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed")
|
||||
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
|
||||
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001
|
||||
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402
|
||||
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
|
||||
from typing_extensions import TypedDict # noqa: E402
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _noop(_state: Any) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _list_concat(state: list, writes: list) -> list:
|
||||
result = list(state)
|
||||
for w in writes:
|
||||
result.extend(w if isinstance(w, list) else [w])
|
||||
return result
|
||||
|
||||
|
||||
def _binop_graph(checkpointer: Any) -> Any:
|
||||
class BinopState(TypedDict):
|
||||
items: Annotated[list, BinaryOperatorAggregate(list, operator.add)]
|
||||
|
||||
return (
|
||||
StateGraph(BinopState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _delta_graph(checkpointer: Any) -> Any:
|
||||
class DeltaState(TypedDict):
|
||||
items: Annotated[list, DeltaChannel(_list_concat)]
|
||||
|
||||
return (
|
||||
StateGraph(DeltaState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _drive(graph: Any, config: RunnableConfig, tag: str, n: int) -> None:
|
||||
for i in range(n):
|
||||
graph.invoke({"items": [f"{tag}{i}"]}, config)
|
||||
|
||||
|
||||
async def _adrive(graph: Any, config: RunnableConfig, tag: str, n: int) -> None:
|
||||
for i in range(n):
|
||||
await graph.ainvoke({"items": [f"{tag}{i}"]}, config)
|
||||
|
||||
|
||||
def _settled_boundaries(history: list) -> list[tuple[RunnableConfig, list]]:
|
||||
"""`(config, items)` for every checkpoint with `next == ('__start__',)`
|
||||
— the stable inter-invoke boundaries that round-trip predictably.
|
||||
"""
|
||||
return [
|
||||
(s.config, list(s.values.get("items", [])))
|
||||
for s in history
|
||||
if s.next == ("__start__",)
|
||||
]
|
||||
|
||||
|
||||
def test_migration_preserves_pre_migration_state_sync() -> None:
|
||||
"""Drive 3 invokes under `BinaryOperatorAggregate`, swap the
|
||||
annotation to `DeltaChannel` on the same sqlite-backed thread, and
|
||||
verify every settled pre-migration boundary round-trips exactly.
|
||||
|
||||
The override's streaming walk must identify the plain accumulated
|
||||
list at each pre-migration ancestor as a valid `seed` even though
|
||||
no `_DeltaSnapshot` was ever written there.
|
||||
"""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "mig-sync"}}
|
||||
|
||||
binop = _binop_graph(saver)
|
||||
_drive(binop, config, "u", 3)
|
||||
|
||||
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
||||
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
|
||||
|
||||
delta = _delta_graph(saver)
|
||||
for cfg, items in pre_boundaries:
|
||||
snap = delta.get_state(cfg)
|
||||
assert list(snap.values.get("items", [])) == items, (
|
||||
f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: "
|
||||
f"expected {items}, got {snap.values.get('items', [])}"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_continued_thread_folds_deltas_on_seed_sync() -> None:
|
||||
"""After migration, driving one more super-step extends the
|
||||
pre-migration accumulated state via the delta reducer — the seed
|
||||
plus a single new write.
|
||||
"""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "mig-continue-sync"}}
|
||||
|
||||
binop = _binop_graph(saver)
|
||||
_drive(binop, config, "u", 3)
|
||||
|
||||
pre_history = list(binop.get_state_history(config))
|
||||
pre_boundaries = _settled_boundaries(pre_history)
|
||||
# Latest settled boundary — the leaf pre-migration state.
|
||||
leaf_cfg, leaf_items = pre_boundaries[0]
|
||||
assert leaf_items, "expected non-empty pre-migration leaf"
|
||||
|
||||
delta = _delta_graph(saver)
|
||||
delta.invoke({"items": ["after-migration"]}, leaf_cfg)
|
||||
new_state = delta.get_state(config).values["items"]
|
||||
assert new_state[: len(leaf_items)] == leaf_items
|
||||
assert "after-migration" in new_state
|
||||
|
||||
|
||||
async def test_migration_preserves_pre_migration_state_async() -> None:
|
||||
"""Async equivalent of the basic-migration round-trip check on
|
||||
`AsyncSqliteSaver`."""
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "mig-async"}}
|
||||
|
||||
binop = _binop_graph(saver)
|
||||
await _adrive(binop, config, "u", 3)
|
||||
|
||||
pre_history = [s async for s in binop.aget_state_history(config)]
|
||||
pre_boundaries = _settled_boundaries(pre_history)
|
||||
assert len(pre_boundaries) >= 2
|
||||
|
||||
delta = _delta_graph(saver)
|
||||
for cfg, items in pre_boundaries:
|
||||
snap = await delta.aget_state(cfg)
|
||||
assert list(snap.values.get("items", [])) == items, (
|
||||
f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}"
|
||||
)
|
||||
Generated
+1
-1
@@ -320,7 +320,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.0.3"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -63,6 +63,11 @@ class CheckpointMetadata(TypedDict, total=False):
|
||||
delta_updates_since_snapshot: dict[str, int]
|
||||
"""Per-channel update count since the last `_DeltaSnapshot` was written.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This metadata field backs `DeltaChannel` (beta). The key name and
|
||||
contents may change while the delta-channel design stabilizes.
|
||||
|
||||
Maps channel name → number of supersteps that wrote to this channel
|
||||
since its last snapshot blob. Used by `pregel.create_checkpoint` to
|
||||
decide when to write the next snapshot (when the count reaches the
|
||||
@@ -135,6 +140,11 @@ class CheckpointTuple(NamedTuple):
|
||||
class DeltaChannelHistory(TypedDict):
|
||||
"""Per-channel result entry from `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
Part of the `DeltaChannel` support surface; in beta. Field names and
|
||||
semantics may change.
|
||||
|
||||
Storage-level view of what one channel contributed across the ancestor
|
||||
chain of a target checkpoint:
|
||||
|
||||
@@ -317,6 +327,14 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
|
||||
Args:
|
||||
run_ids: The run IDs whose checkpoints should be deleted.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
Deleting a run that produced ancestor `checkpoint_writes` — or
|
||||
the only `_DeltaSnapshot` blob — for a still-live thread will
|
||||
break reconstruction of any `DeltaChannel` whose history
|
||||
depended on those rows. See the `DeltaChannel` note on `prune`
|
||||
for safe-recovery strategies.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -330,6 +348,17 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
Args:
|
||||
source_thread_id: The thread ID to copy from.
|
||||
target_thread_id: The thread ID to copy to.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
Implementations must copy the **complete** parent chain (all
|
||||
ancestor checkpoints and their `checkpoint_writes`) — copying
|
||||
only the head checkpoint will leave the target thread with
|
||||
`DeltaChannel` state that cannot be reconstructed (no path back
|
||||
to a `_DeltaSnapshot` ancestor). Equivalently, the copy must
|
||||
include enough ancestors that every `DeltaChannel`-backed key
|
||||
has either a `_DeltaSnapshot` in `channel_values` somewhere in
|
||||
the chain, or a complete write history back to the chain root.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -345,6 +374,34 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
thread_ids: The thread IDs to prune.
|
||||
strategy: The pruning strategy. `"keep_latest"` retains only the most
|
||||
recent checkpoint per namespace. `"delete"` removes all checkpoints.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
Custom implementations must be `DeltaChannel`-aware. `DeltaChannel`
|
||||
stores only a sentinel in `channel_values` for non-snapshot steps;
|
||||
reconstruction walks the parent chain via
|
||||
`get_delta_channel_history`, accumulating rows from
|
||||
`checkpoint_writes` until it reaches an ancestor whose
|
||||
`channel_values` contains a `_DeltaSnapshot` blob (written every
|
||||
`snapshot_frequency` updates).
|
||||
|
||||
A naive `"keep_latest"` that drops intermediate checkpoints and
|
||||
their writes can sever that chain: the surviving "latest"
|
||||
checkpoint is rarely a snapshot point itself, so its delta
|
||||
channels would silently reconstruct as empty (no error raised —
|
||||
`get_delta_channel_history` simply returns no `seed`). Safe
|
||||
options when the graph uses `DeltaChannel`:
|
||||
|
||||
* Walk back from each kept checkpoint and preserve every
|
||||
ancestor (plus its `checkpoint_writes`) up to the nearest one
|
||||
whose `channel_values` already contains a `_DeltaSnapshot` for
|
||||
every `DeltaChannel`-backed key.
|
||||
* Force a fresh snapshot on the kept checkpoint before deleting
|
||||
ancestors — rewrite `channel_values[k] = _DeltaSnapshot(value)`
|
||||
for each delta channel `k` (resolving `value` via the existing
|
||||
ancestor walk first), then prune.
|
||||
* Skip pruning threads whose graph uses `DeltaChannel` until one
|
||||
of the above is implemented.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -461,6 +518,13 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
|
||||
Args:
|
||||
run_ids: The run IDs whose checkpoints should be deleted.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
See `delete_for_runs` — deleting rows a still-live thread's
|
||||
`DeltaChannel` reconstruction depends on (writes between the
|
||||
head and its nearest `_DeltaSnapshot` ancestor) will silently
|
||||
corrupt that channel's state.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -474,6 +538,13 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
Args:
|
||||
source_thread_id: The thread ID to copy from.
|
||||
target_thread_id: The thread ID to copy to.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
See `copy_thread` — the copy must carry the complete parent
|
||||
chain (or at least back to a `_DeltaSnapshot` ancestor for every
|
||||
`DeltaChannel`) so the target thread can reconstruct delta
|
||||
state.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -489,6 +560,13 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
thread_ids: The thread IDs to prune.
|
||||
strategy: The pruning strategy. `"keep_latest"` retains only the most
|
||||
recent checkpoint per namespace. `"delete"` removes all checkpoints.
|
||||
|
||||
!!! warning "DeltaChannel"
|
||||
|
||||
See `prune` for the full `DeltaChannel` caveat. In short:
|
||||
`"keep_latest"` must not drop ancestor checkpoints / writes that
|
||||
sit between the kept checkpoint and the nearest `_DeltaSnapshot`
|
||||
ancestor, or delta channels will silently reconstruct as empty.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -497,6 +575,14 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Walk the parent chain returning per-channel writes + seed.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This method is part of the `DeltaChannel` support surface and is
|
||||
in beta. The signature, return shape (`DeltaChannelHistory`), and
|
||||
interaction with `_DeltaSnapshot` blobs may change. Override at
|
||||
your own risk; the default implementation will continue to work
|
||||
against the public `BaseCheckpointSaver` contract.
|
||||
|
||||
For each requested channel, walks ancestors of the checkpoint
|
||||
identified by `config` (following `parent_config`) and accumulates
|
||||
`pending_writes` for that channel. The walk terminates per-channel
|
||||
@@ -556,7 +642,13 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
async def aget_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Async version of `get_delta_channel_history`."""
|
||||
"""Async version of `get_delta_channel_history`.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This method is part of the `DeltaChannel` support surface and is
|
||||
in beta. See `get_delta_channel_history` for caveats.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
collected_by_ch: dict[str, list[PendingWrite]] = {c: [] for c in channels}
|
||||
@@ -588,6 +680,123 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
def get_delta_channel_keepset(
|
||||
self,
|
||||
*,
|
||||
config: RunnableConfig,
|
||||
channels: Sequence[str],
|
||||
) -> set[str]:
|
||||
"""Return ancestor checkpoint_ids that must survive deletion.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This method is part of the `DeltaChannel` support surface and is
|
||||
in beta. The signature may change while the delta-channel design
|
||||
stabilizes.
|
||||
|
||||
Walks the parent chain from `config` backward, collecting visited
|
||||
checkpoint_ids (inclusive of the target), and terminates per-channel
|
||||
when that channel has a populated `channel_values[ch]` (a
|
||||
`_DeltaSnapshot` blob or a pre-migration plain value). The returned
|
||||
set is the minimum keep-set: every checkpoint_id whose removal would
|
||||
break reconstruction of the listed channels at `config`.
|
||||
|
||||
Pass `channels=[]` to return just `{config.checkpoint_id}` — useful
|
||||
for graphs that don't use `DeltaChannel`.
|
||||
|
||||
Compose this into custom `prune` / `delete_for_runs` / `copy_thread`::
|
||||
|
||||
keep = saver.get_delta_channel_keepset(
|
||||
config=head_config, channels=delta_channels,
|
||||
)
|
||||
delete_rows_not_in(keep)
|
||||
|
||||
Note:
|
||||
The default implementation here uses repeated `get_tuple` calls
|
||||
to walk the parent chain. This is a basic reference implementation
|
||||
suitable for low-frequency maintenance operations (prune, etc.).
|
||||
Custom checkpointer backends may override with a more efficient
|
||||
version tailored to their data model (e.g. a single SQL query
|
||||
with a recursive CTE), but it is not required — the default works
|
||||
correctly for any saver that implements `get_tuple`.
|
||||
|
||||
Args:
|
||||
config: Configuration identifying the target checkpoint.
|
||||
channels: Channel names whose delta history must be preserved.
|
||||
Empty sequence means only the target checkpoint_id is kept.
|
||||
|
||||
Returns:
|
||||
Set of checkpoint_ids that must not be deleted.
|
||||
"""
|
||||
target_tuple = self.get_tuple(config)
|
||||
if target_tuple is None:
|
||||
return set()
|
||||
target_id = target_tuple.config["configurable"]["checkpoint_id"]
|
||||
keep: set[str] = {target_id}
|
||||
if not channels:
|
||||
return keep
|
||||
remaining: set[str] = set(channels)
|
||||
for ch in list(remaining):
|
||||
if ch in target_tuple.checkpoint["channel_values"]:
|
||||
remaining.discard(ch)
|
||||
if not remaining:
|
||||
return keep
|
||||
cursor_config: RunnableConfig | None = target_tuple.parent_config
|
||||
while cursor_config is not None and remaining:
|
||||
tup = self.get_tuple(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
cid = tup.config["configurable"]["checkpoint_id"]
|
||||
keep.add(cid)
|
||||
for ch in list(remaining):
|
||||
if ch in tup.checkpoint["channel_values"]:
|
||||
remaining.discard(ch)
|
||||
if not remaining:
|
||||
break
|
||||
cursor_config = tup.parent_config
|
||||
return keep
|
||||
|
||||
async def aget_delta_channel_keepset(
|
||||
self,
|
||||
*,
|
||||
config: RunnableConfig,
|
||||
channels: Sequence[str],
|
||||
) -> set[str]:
|
||||
"""Async version of `get_delta_channel_keepset`.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This method is part of the `DeltaChannel` support surface and is
|
||||
in beta. See `get_delta_channel_keepset` for full documentation.
|
||||
"""
|
||||
target_tuple = await self.aget_tuple(config)
|
||||
if target_tuple is None:
|
||||
return set()
|
||||
target_id = target_tuple.config["configurable"]["checkpoint_id"]
|
||||
keep: set[str] = {target_id}
|
||||
if not channels:
|
||||
return keep
|
||||
remaining: set[str] = set(channels)
|
||||
for ch in list(remaining):
|
||||
if ch in target_tuple.checkpoint["channel_values"]:
|
||||
remaining.discard(ch)
|
||||
if not remaining:
|
||||
return keep
|
||||
cursor_config: RunnableConfig | None = target_tuple.parent_config
|
||||
while cursor_config is not None and remaining:
|
||||
tup = await self.aget_tuple(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
cid = tup.config["configurable"]["checkpoint_id"]
|
||||
keep.add(cid)
|
||||
for ch in list(remaining):
|
||||
if ch in tup.checkpoint["channel_values"]:
|
||||
remaining.discard(ch)
|
||||
if not remaining:
|
||||
break
|
||||
cursor_config = tup.parent_config
|
||||
return keep
|
||||
|
||||
def get_next_version(self, current: V | None, channel: None) -> V:
|
||||
"""Generate the next version ID for a channel.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Run delta-channel conformance capabilities against InMemorySaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
conformance = pytest.importorskip(
|
||||
"langgraph.checkpoint.conformance",
|
||||
reason="langgraph-checkpoint-conformance not installed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_channel_conformance():
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
@checkpointer_test(name="InMemorySaver")
|
||||
async def mem_saver():
|
||||
yield InMemorySaver()
|
||||
|
||||
report = await validate(
|
||||
mem_saver,
|
||||
capabilities={
|
||||
"delta_channel_history",
|
||||
"delta_channel_keepset",
|
||||
"delta_channel_reconstruction",
|
||||
},
|
||||
)
|
||||
for cap, result in report.results.items():
|
||||
if result.passed is False:
|
||||
details = "\n".join(result.failures or [])
|
||||
pytest.fail(f"Capability {cap} failed:\n{details}")
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.24"
|
||||
__version__ = "0.4.25"
|
||||
|
||||
@@ -115,6 +115,172 @@ class BuildResult:
|
||||
show_build_logs_on_failure: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured output emitter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_emitter: "_Emitter | None" = None
|
||||
_no_input: bool = False
|
||||
|
||||
|
||||
class _Emitter:
|
||||
"""Dual-mode output: JSON-lines (``--json``) or human-readable click text."""
|
||||
|
||||
def __init__(self, json_mode: bool) -> None:
|
||||
self._json = json_mode
|
||||
|
||||
@property
|
||||
def json_mode(self) -> bool:
|
||||
return self._json
|
||||
|
||||
# -- Structured event helpers ------------------------------------------
|
||||
|
||||
def step(self, step: int, message: str, **extra: object) -> None:
|
||||
if self._json:
|
||||
self._write({"event": "step", "step": step, "message": message, **extra})
|
||||
else:
|
||||
click.secho(f"{step}. {message}", fg="cyan")
|
||||
|
||||
def info(self, message: str, **extra: object) -> None:
|
||||
if self._json:
|
||||
self._write({"event": "info", "message": message, **extra})
|
||||
else:
|
||||
click.secho(f" {message}", fg="green")
|
||||
|
||||
def warn(self, message: str, **extra: object) -> None:
|
||||
"""Warning nested under a step. Text mode indents; JSON mode strips leading whitespace."""
|
||||
if self._json:
|
||||
self._write({"event": "warn", "message": message.lstrip(), **extra})
|
||||
else:
|
||||
click.secho(f" {message}", fg="yellow")
|
||||
|
||||
def note(self, message: str, **extra: object) -> None:
|
||||
"""Top-level banner (pre-step). Text mode does not indent."""
|
||||
if self._json:
|
||||
self._write({"event": "note", "message": message, **extra})
|
||||
else:
|
||||
click.secho(message, fg="yellow")
|
||||
|
||||
def error(self, message: str, **extra: object) -> None:
|
||||
if self._json:
|
||||
self._write({"event": "error", "message": message, **extra})
|
||||
else:
|
||||
click.secho(f" {message}", fg="red")
|
||||
|
||||
def status_change(
|
||||
self,
|
||||
status: str,
|
||||
elapsed_seconds: float,
|
||||
finished: bool = False,
|
||||
) -> None:
|
||||
mins, secs = divmod(int(elapsed_seconds), 60)
|
||||
elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
|
||||
if self._json:
|
||||
self._write(
|
||||
{
|
||||
"event": "status_change",
|
||||
"status": status,
|
||||
"elapsed_seconds": round(elapsed_seconds, 1),
|
||||
"message": f"{status}... ({elapsed_str})",
|
||||
}
|
||||
)
|
||||
else:
|
||||
click.echo(f" {status}... ({elapsed_str})")
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
if self._json:
|
||||
self._write({"event": "log", "message": message})
|
||||
else:
|
||||
click.echo(f" | {message}")
|
||||
|
||||
def status_url(self, url: str) -> None:
|
||||
if self._json:
|
||||
self._write({"event": "status_url", "url": url})
|
||||
else:
|
||||
click.secho(f" View status: {url}", fg="cyan")
|
||||
|
||||
def result(
|
||||
self,
|
||||
status: str,
|
||||
*,
|
||||
deployment_id: str,
|
||||
url: str | None = None,
|
||||
status_url: str | None = None,
|
||||
fallback_status_message: str | None = None,
|
||||
) -> None:
|
||||
if self._json:
|
||||
if status == "succeeded":
|
||||
message = "Deployment successful!"
|
||||
elif status == "failed":
|
||||
message = "Deployment failed"
|
||||
else:
|
||||
message = "Timed out waiting for deployment."
|
||||
payload: dict = {
|
||||
"event": "result",
|
||||
"status": status,
|
||||
"deployment_id": deployment_id,
|
||||
"message": message,
|
||||
}
|
||||
if url:
|
||||
payload["url"] = url
|
||||
if status_url:
|
||||
payload["status_url"] = status_url
|
||||
self._write(payload)
|
||||
else:
|
||||
if status == "succeeded":
|
||||
click.secho(" Deployment successful!", fg="green")
|
||||
if url:
|
||||
click.secho(f" URL: {url}", fg="green")
|
||||
if status_url:
|
||||
click.secho(f" View status: {status_url}", fg="green")
|
||||
elif status == "failed":
|
||||
click.secho(" Deployment failed", fg="red")
|
||||
if status_url:
|
||||
click.secho(f" View status: {status_url}", fg="red")
|
||||
elif status == "timed_out":
|
||||
click.secho(" Timed out waiting for deployment.", fg="yellow")
|
||||
if status_url:
|
||||
click.secho(f" Check status at: {status_url}", fg="yellow")
|
||||
elif fallback_status_message:
|
||||
click.secho(f" {fallback_status_message}", fg="yellow")
|
||||
|
||||
def heartbeat(self, status: str, elapsed_seconds: float) -> None:
|
||||
if self._json:
|
||||
mins, secs = divmod(int(elapsed_seconds), 60)
|
||||
elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
|
||||
self._write(
|
||||
{
|
||||
"event": "heartbeat",
|
||||
"status": status,
|
||||
"elapsed_seconds": round(elapsed_seconds, 1),
|
||||
"message": f"{status}... ({elapsed_str})",
|
||||
}
|
||||
)
|
||||
|
||||
def upload_progress(self, size_mb: float, pct: int) -> None:
|
||||
if self._json:
|
||||
self._write(
|
||||
{
|
||||
"event": "upload_progress",
|
||||
"size_mb": round(size_mb, 1),
|
||||
"pct": pct,
|
||||
}
|
||||
)
|
||||
else:
|
||||
click.echo(f"\r Uploading ({size_mb:.1f} MB)... {pct}%", nl=False)
|
||||
|
||||
def _write(self, obj: dict) -> None:
|
||||
import sys as _sys
|
||||
|
||||
_sys.stdout.write(json_mod.dumps(obj, default=str) + "\n")
|
||||
_sys.stdout.flush()
|
||||
|
||||
|
||||
def _get_emitter() -> _Emitter:
|
||||
"""Return the module-level emitter (falls back to text mode)."""
|
||||
return _emitter or _Emitter(json_mode=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validators
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,15 +338,16 @@ def find_deployment_id_by_name(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_image_name(value: str | None) -> str:
|
||||
"""Sanitize a deployment/directory name into a valid Docker repository name.
|
||||
def normalize_name(value: str | None) -> str:
|
||||
"""Sanitize a deployment/directory name into a valid deployment name.
|
||||
|
||||
Docker repository names must be lowercase and may only contain
|
||||
[a-z0-9._-]. Invalid characters are replaced with hyphens.
|
||||
LangSmith Deployment names only allow lowercase
|
||||
alphanumeric characters and hyphens ([a-z0-9-]).
|
||||
Invalid characters are replaced with hyphens.
|
||||
"""
|
||||
if not value:
|
||||
return "app"
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-.")
|
||||
slug = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
||||
return slug or "app"
|
||||
|
||||
|
||||
@@ -307,9 +474,8 @@ def _resolve_env_path(
|
||||
if isinstance(env_field, str):
|
||||
env_path = (config_path.parent / env_field).resolve()
|
||||
if not env_path.exists():
|
||||
click.secho(
|
||||
f"Warning: env file '{env_field}' specified in langgraph.json not found.",
|
||||
fg="yellow",
|
||||
_get_emitter().note(
|
||||
f"Warning: env file '{env_field}' specified in langgraph.json not found."
|
||||
)
|
||||
return None
|
||||
return env_path
|
||||
@@ -343,7 +509,7 @@ def _secrets_from_env(
|
||||
secrets: list[dict[str, str]] = []
|
||||
for name, value in env_vars.items():
|
||||
if name in RESERVED_ENV_VARS:
|
||||
click.secho(f" Skipping reserved env var: {name}", fg="yellow")
|
||||
_get_emitter().note(f"Skipping reserved env var: {name}")
|
||||
continue
|
||||
if not value:
|
||||
continue
|
||||
@@ -386,8 +552,8 @@ def _resolve_build_mode(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _log_deploy_step(step: int, message: str) -> None:
|
||||
click.secho(f"{step}. {message}", fg="cyan")
|
||||
def _log_deploy_step(step: int, message: str, **extra: object) -> None:
|
||||
_get_emitter().step(step, message, **extra)
|
||||
|
||||
|
||||
def _resolve_deployment(
|
||||
@@ -411,12 +577,13 @@ def _resolve_deployment(
|
||||
found_id = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: find_deployment_id_by_name(c, name)
|
||||
)
|
||||
em = _get_emitter()
|
||||
if found_id:
|
||||
deployment_id = str(found_id)
|
||||
click.secho(f" Found existing deployment (ID: {deployment_id})", fg="green")
|
||||
em.info(f"Found existing deployment (ID: {deployment_id})")
|
||||
else:
|
||||
needs_creation = True
|
||||
click.secho(not_found_message, fg="yellow")
|
||||
em.warn(not_found_message)
|
||||
return deployment_id, needs_creation, step + 1
|
||||
|
||||
|
||||
@@ -444,7 +611,7 @@ def _create_deployment(
|
||||
raise HostBackendError(
|
||||
"POST /v2/deployments succeeded but response missing a valid 'id'"
|
||||
)
|
||||
click.secho(f" Deployment ID: {created_id}", fg="green")
|
||||
_get_emitter().info(f"Deployment ID: {created_id}", deployment_id=created_id)
|
||||
return created_id, step + 1
|
||||
|
||||
|
||||
@@ -458,21 +625,36 @@ def _smith_dashboard_base_url(host_url: str | None) -> str:
|
||||
hostname = parsed.hostname or ""
|
||||
if hostname in ("localhost", "127.0.0.1"):
|
||||
return host_url.rstrip("/")
|
||||
if hostname.startswith("eu."):
|
||||
return "https://eu.smith.langchain.com"
|
||||
|
||||
api_host_suffix = "api.host.langchain.com"
|
||||
if hostname == api_host_suffix:
|
||||
return "https://smith.langchain.com"
|
||||
if hostname.endswith(f".{api_host_suffix}"):
|
||||
prefix = hostname[: -(len(api_host_suffix) + 1)]
|
||||
return f"https://{prefix}.smith.langchain.com"
|
||||
|
||||
return "https://smith.langchain.com"
|
||||
|
||||
|
||||
def _print_deployment_status_url(
|
||||
def _get_deployment_status_url(
|
||||
updated: object, deployment_id: str, host_url: str | None = None
|
||||
) -> None:
|
||||
"""Print the deployment status URL when tenant metadata is available."""
|
||||
) -> str | None:
|
||||
"""Compute the LangSmith dashboard URL for a deployment, if possible."""
|
||||
tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None
|
||||
if not tenant_id:
|
||||
return
|
||||
return None
|
||||
base = _smith_dashboard_base_url(host_url)
|
||||
status_url = f"{base}/o/{tenant_id}/host/deployments/{deployment_id}"
|
||||
click.secho(f" View status: {status_url}", fg="cyan")
|
||||
return f"{base}/o/{tenant_id}/host/deployments/{deployment_id}"
|
||||
|
||||
|
||||
def _emit_deployment_status_url(
|
||||
updated: object, deployment_id: str, host_url: str | None = None
|
||||
) -> str | None:
|
||||
"""Emit the deployment status URL and return it."""
|
||||
url = _get_deployment_status_url(updated, deployment_id, host_url)
|
||||
if url:
|
||||
_get_emitter().status_url(url)
|
||||
return url
|
||||
|
||||
|
||||
def _poll_revision_status(
|
||||
@@ -486,6 +668,7 @@ def _poll_revision_status(
|
||||
on_interrupt: Callable[[str], None] | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Poll latest revision status until terminal status or timeout."""
|
||||
em = _get_emitter()
|
||||
revisions_resp = client.list_revisions(deployment_id, limit=1)
|
||||
resources = (
|
||||
revisions_resp.get("resources", []) if isinstance(revisions_resp, dict) else []
|
||||
@@ -497,7 +680,11 @@ def _poll_revision_status(
|
||||
last_status = ""
|
||||
deadline = time.time() + timeout_seconds
|
||||
start_time = time.monotonic()
|
||||
with Progress(message=progress_message, elapsed=True) as set_progress:
|
||||
last_heartbeat = start_time
|
||||
json_mode = em.json_mode
|
||||
with Progress(
|
||||
message=progress_message, elapsed=True, json_mode=json_mode
|
||||
) as set_progress:
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
rev = client.get_revision(deployment_id, revision_id)
|
||||
@@ -514,14 +701,15 @@ def _poll_revision_status(
|
||||
if status != last_status:
|
||||
set_progress("")
|
||||
if last_status:
|
||||
elapsed = time.monotonic() - start_time
|
||||
mins, secs = divmod(int(elapsed), 60)
|
||||
elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
|
||||
click.echo(f" {last_status}... ({elapsed_str})")
|
||||
em.status_change(last_status, time.monotonic() - start_time)
|
||||
last_status = status
|
||||
if status in _TERMINAL_STATUSES:
|
||||
break
|
||||
set_progress(f"{status}...")
|
||||
last_heartbeat = time.monotonic()
|
||||
elif json_mode and time.monotonic() - last_heartbeat > 10:
|
||||
em.heartbeat(last_status, time.monotonic() - start_time)
|
||||
last_heartbeat = time.monotonic()
|
||||
|
||||
if on_poll is not None:
|
||||
on_poll(status, revision_id, set_progress)
|
||||
@@ -538,8 +726,10 @@ def _print_deployment_result(
|
||||
last_status: str,
|
||||
*,
|
||||
dashboard_label: str,
|
||||
status_url: str | None = None,
|
||||
) -> None:
|
||||
"""Print final deployment status and raise on failure."""
|
||||
em = _get_emitter()
|
||||
dep_info = client.get_deployment(deployment_id)
|
||||
custom_url = None
|
||||
if isinstance(dep_info, dict):
|
||||
@@ -548,24 +738,28 @@ def _print_deployment_result(
|
||||
custom_url = sc.get("custom_url")
|
||||
|
||||
if last_status == "DEPLOYED":
|
||||
click.secho(" Deployment successful!", fg="green")
|
||||
if custom_url:
|
||||
click.secho(f" URL: {custom_url}", fg="green")
|
||||
em.result(
|
||||
"succeeded",
|
||||
deployment_id=deployment_id,
|
||||
url=custom_url,
|
||||
status_url=status_url,
|
||||
)
|
||||
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
|
||||
click.secho(f" Deployment failed: {last_status}", fg="red")
|
||||
em.result(
|
||||
"failed",
|
||||
deployment_id=deployment_id,
|
||||
status_url=status_url,
|
||||
)
|
||||
raise click.exceptions.Exit(1)
|
||||
else:
|
||||
click.secho(
|
||||
f" Timed out waiting for deployment (last status: {last_status}).",
|
||||
fg="yellow",
|
||||
em.result(
|
||||
"timed_out",
|
||||
deployment_id=deployment_id,
|
||||
status_url=status_url,
|
||||
fallback_status_message=(
|
||||
f"Check status in the LangSmith {dashboard_label}."
|
||||
),
|
||||
)
|
||||
if custom_url:
|
||||
click.secho(f" Check status at: {custom_url}", fg="yellow")
|
||||
else:
|
||||
click.secho(
|
||||
f" Check status in the LangSmith {dashboard_label}.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -594,14 +788,16 @@ def _docker_config_for_token(registry_host: str, token: str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_UPLOAD_TIMEOUT_SECONDS = 300
|
||||
_BYTES_PER_MIB = 1_048_576
|
||||
|
||||
|
||||
class _ProgressReader:
|
||||
"""File-like wrapper that displays upload progress via click."""
|
||||
"""File-like wrapper that reports upload progress via the emitter."""
|
||||
|
||||
def __init__(self, fobj, file_size: int):
|
||||
def __init__(self, fobj, file_size: int, emitter: "_Emitter"):
|
||||
self._fobj = fobj
|
||||
self._file_size = file_size
|
||||
self._emitter = emitter
|
||||
self._uploaded = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
@@ -611,10 +807,7 @@ class _ProgressReader:
|
||||
pct = (
|
||||
int(self._uploaded * 100 / self._file_size) if self._file_size else 100
|
||||
)
|
||||
click.echo(
|
||||
f"\r Uploading ({self._file_size / 1_048_576:.1f} MB)... {pct}%",
|
||||
nl=False,
|
||||
)
|
||||
self._emitter.upload_progress(self._file_size / _BYTES_PER_MIB, pct)
|
||||
return data
|
||||
|
||||
def __len__(self):
|
||||
@@ -626,10 +819,13 @@ def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
em = _get_emitter()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
reader = _ProgressReader(f, file_size, em)
|
||||
req = urllib.request.Request(
|
||||
signed_url,
|
||||
data=_ProgressReader(f, file_size),
|
||||
data=reader,
|
||||
method="PUT",
|
||||
headers={
|
||||
"Content-Type": "application/gzip",
|
||||
@@ -644,7 +840,8 @@ def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
|
||||
raise click.ClickException(
|
||||
f"Upload failed with status {err.code}: {detail}"
|
||||
) from None
|
||||
click.echo()
|
||||
if not em.json_mode:
|
||||
click.echo()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -752,7 +949,7 @@ def _run_local_build(
|
||||
if "://" in normalized_registry:
|
||||
normalized_registry = normalized_registry.split("//", 1)[1]
|
||||
repo_seed = image_name or name or config.parent.name
|
||||
repo_name = normalize_image_name(repo_seed)
|
||||
repo_name = normalize_name(repo_seed)
|
||||
tag_value = normalize_image_tag(tag)
|
||||
remote_image = f"{normalized_registry}/{repo_name}:{tag_value}"
|
||||
|
||||
@@ -811,9 +1008,8 @@ def _run_local_build(
|
||||
break
|
||||
except click.exceptions.Exit:
|
||||
if attempt < max_push_retries - 1:
|
||||
click.secho(
|
||||
f" Push failed, retrying (attempt {attempt + 2} of {max_push_retries})...",
|
||||
fg="yellow",
|
||||
_get_emitter().warn(
|
||||
f" Push failed, retrying (attempt {attempt + 2} of {max_push_retries})..."
|
||||
)
|
||||
else:
|
||||
raise
|
||||
@@ -847,9 +1043,10 @@ def _run_remote_build(
|
||||
"""Upload source tarball and trigger a remote build."""
|
||||
from langgraph_cli.archive import create_archive
|
||||
|
||||
em = _get_emitter()
|
||||
_log_deploy_step(step, "Creating source archive")
|
||||
with create_archive(config, config_json) as (archive_path, file_size, config_rel):
|
||||
click.secho(f" Archive created ({file_size / 1_048_576:.1f} MB)", fg="green")
|
||||
em.info(f"Archive created ({file_size / _BYTES_PER_MIB:.1f} MB)")
|
||||
step += 1
|
||||
|
||||
_log_deploy_step(step, "Requesting upload URL")
|
||||
@@ -897,12 +1094,12 @@ def _run_remote_build(
|
||||
if has_output:
|
||||
set_progress("")
|
||||
if not logs_header_printed:
|
||||
click.echo(f" {status} (build logs):")
|
||||
em.info(f"{status} (build logs):")
|
||||
logs_header_printed = True
|
||||
for entry in entries:
|
||||
msg = entry.get("message", "")
|
||||
if msg:
|
||||
click.echo(f" | {msg}")
|
||||
em.log(msg)
|
||||
log_offset = logs_resp.get("next_offset") or log_offset
|
||||
if has_output:
|
||||
set_progress(f"{status}...")
|
||||
@@ -910,11 +1107,10 @@ def _run_remote_build(
|
||||
pass
|
||||
|
||||
def _handle_interrupt(revision_id: str) -> None:
|
||||
click.secho(
|
||||
f"\n Interrupted. Deployment ID: {deployment_id}, Revision ID: {revision_id}",
|
||||
fg="yellow",
|
||||
em.warn(
|
||||
f"\nInterrupted. Deployment ID: {deployment_id}, Revision ID: {revision_id}"
|
||||
)
|
||||
click.secho(" The build will continue remotely.", fg="yellow")
|
||||
em.warn("The build will continue remotely.")
|
||||
|
||||
return BuildResult(
|
||||
updated=updated if isinstance(updated, dict) else {},
|
||||
@@ -952,12 +1148,20 @@ def _create_host_backend_client(
|
||||
resolved_api_key = val
|
||||
break
|
||||
if not resolved_api_key:
|
||||
if _no_input:
|
||||
raise click.ClickException(
|
||||
"No LangSmith API key found. Set LANGSMITH_API_KEY in the "
|
||||
"environment or .env file."
|
||||
)
|
||||
click.secho(
|
||||
"No LangSmith API key found. Create one at Settings > API Keys in LangSmith.",
|
||||
fg="yellow",
|
||||
)
|
||||
resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
|
||||
return HostBackendClient(host_url, resolved_api_key)
|
||||
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
|
||||
"LANGSMITH_TENANT_ID"
|
||||
)
|
||||
return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def _call_host_backend_with_optional_tenant(
|
||||
@@ -982,6 +1186,12 @@ def _call_host_backend_with_optional_tenant(
|
||||
and err.status_code == 403
|
||||
and "requires workspace specification" in err.message
|
||||
):
|
||||
if _no_input:
|
||||
raise click.ClickException(
|
||||
"API key is org-scoped and requires a workspace ID. "
|
||||
"Set LANGSMITH_TENANT_ID in your .env file or "
|
||||
"use a workspace-scoped API key."
|
||||
) from None
|
||||
click.secho(
|
||||
"Your API key is org-scoped and requires a workspace ID.",
|
||||
fg="yellow",
|
||||
@@ -1189,6 +1399,19 @@ def _deploy_base_options(
|
||||
"if Docker is not available locally."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--json",
|
||||
"json_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Emit structured JSON-lines to stdout instead of human-readable text.",
|
||||
),
|
||||
click.option(
|
||||
"--no-input",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Never prompt for input; fail with an error if a required value is missing.",
|
||||
),
|
||||
]
|
||||
if include_docker_args:
|
||||
# Only attach build args to the default command; on the group they
|
||||
@@ -1256,36 +1479,50 @@ def _deploy_cmd(
|
||||
no_wait: bool,
|
||||
remote_build_flag: bool | None,
|
||||
docker_build_args: Sequence[str],
|
||||
json_output: bool,
|
||||
no_input: bool,
|
||||
):
|
||||
click.secho(
|
||||
"Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.",
|
||||
fg="yellow",
|
||||
global _emitter, _no_input
|
||||
_emitter = _Emitter(json_mode=json_output)
|
||||
_no_input = no_input
|
||||
em = _emitter
|
||||
|
||||
em.note(
|
||||
"Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements."
|
||||
)
|
||||
click.echo()
|
||||
if not json_output:
|
||||
click.echo()
|
||||
|
||||
# -- 1. Preflight --
|
||||
validate_deploy_commands(install_command, build_command)
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
warn_non_wolfi_distro(config_json, emit=em.note)
|
||||
|
||||
env_vars = _parse_env_from_config(config_json, config)
|
||||
|
||||
if not deployment_id and not name:
|
||||
name = env_vars.get(_DEPLOYMENT_NAME_ENV)
|
||||
if not deployment_id and not name:
|
||||
default_name = normalize_image_name(pathlib.Path.cwd().name)
|
||||
name = click.prompt("Deployment name", default=default_name)
|
||||
env_path = _resolve_env_path(config_json, config)
|
||||
if env_path is not None:
|
||||
set_key(str(env_path), _DEPLOYMENT_NAME_ENV, name)
|
||||
click.echo(f"Saved deployment name to {env_path}")
|
||||
default_name = normalize_name(pathlib.Path.cwd().name)
|
||||
if no_input:
|
||||
name = default_name
|
||||
else:
|
||||
name = click.prompt("Deployment name", default=default_name)
|
||||
if name and not deployment_id:
|
||||
name = normalize_name(name)
|
||||
if not no_input:
|
||||
env_path = _resolve_env_path(config_json, config)
|
||||
if env_path is not None:
|
||||
set_key(str(env_path), _DEPLOYMENT_NAME_ENV, name)
|
||||
em.info(f"Saved deployment name to {env_path}")
|
||||
|
||||
secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
|
||||
|
||||
use_remote_build, local_build_error = _resolve_build_mode(remote_build_flag)
|
||||
if use_remote_build and remote_build_flag is None and local_build_error:
|
||||
click.secho(f"{local_build_error}\nUsing remote build instead.", fg="yellow")
|
||||
click.echo()
|
||||
em.note(f"{local_build_error}\nUsing remote build instead.")
|
||||
if not json_output:
|
||||
click.echo()
|
||||
|
||||
# -- 2. Resolve / create deployment --
|
||||
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
|
||||
@@ -1297,9 +1534,9 @@ def _deploy_cmd(
|
||||
deployment_id,
|
||||
name,
|
||||
not_found_message=(
|
||||
" No deployment found. Will create."
|
||||
"No deployment found. Will create."
|
||||
if use_remote_build
|
||||
else " No deployment found. Will create after build."
|
||||
else "No deployment found. Will create after build."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1350,10 +1587,14 @@ def _deploy_cmd(
|
||||
)
|
||||
|
||||
# -- 4. Shared wait + result --
|
||||
_print_deployment_status_url(build_result.updated, deployment_id, host_url)
|
||||
dep_status_url = _emit_deployment_status_url(
|
||||
build_result.updated,
|
||||
deployment_id,
|
||||
host_url,
|
||||
)
|
||||
|
||||
if no_wait:
|
||||
click.secho(f" {build_result.no_result_message}", fg="green")
|
||||
em.info(build_result.no_result_message)
|
||||
return
|
||||
|
||||
last_status, revision_id = _poll_revision_status(
|
||||
@@ -1366,7 +1607,7 @@ def _deploy_cmd(
|
||||
on_interrupt=build_result.on_interrupt,
|
||||
)
|
||||
if not last_status:
|
||||
click.secho(f" {build_result.no_result_message}", fg="green")
|
||||
em.info(build_result.no_result_message)
|
||||
return
|
||||
|
||||
if (
|
||||
@@ -1375,7 +1616,7 @@ def _deploy_cmd(
|
||||
and not verbose
|
||||
and revision_id is not None
|
||||
):
|
||||
click.secho(" Last build log lines:", fg="red")
|
||||
em.error("Last build log lines:")
|
||||
try:
|
||||
logs_resp = client.get_build_logs(
|
||||
deployment_id,
|
||||
@@ -1387,19 +1628,17 @@ def _deploy_cmd(
|
||||
for entry in entries:
|
||||
msg = entry.get("message", "")
|
||||
if msg:
|
||||
click.echo(f" | {msg}")
|
||||
em.log(msg)
|
||||
except Exception:
|
||||
click.secho(" (failed to fetch build logs)", fg="red")
|
||||
click.secho(
|
||||
" Re-run with --verbose to see full build output.",
|
||||
fg="yellow",
|
||||
)
|
||||
em.error("(failed to fetch build logs)")
|
||||
em.warn("Re-run with --verbose to see full build output.")
|
||||
|
||||
_print_deployment_result(
|
||||
client,
|
||||
deployment_id,
|
||||
last_status,
|
||||
dashboard_label="Deployment dashboard",
|
||||
status_url=dep_status_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ class Progress:
|
||||
while True:
|
||||
yield from "|/-\\"
|
||||
|
||||
def __init__(self, *, message="", elapsed: bool = False):
|
||||
def __init__(self, *, message="", elapsed: bool = False, json_mode: bool = False):
|
||||
self.message = message
|
||||
self._base_message = message
|
||||
self._show_elapsed = elapsed
|
||||
self._json_mode = json_mode
|
||||
# use this to make sure we don't kill thread when we set msg to ""
|
||||
self._stop = threading.Event()
|
||||
# signalled when the spinner has no text on screen
|
||||
@@ -69,6 +70,9 @@ class Progress:
|
||||
self._line_clear.set()
|
||||
|
||||
def __enter__(self) -> Callable[[str], None]:
|
||||
if self._json_mode:
|
||||
return lambda message: None
|
||||
|
||||
if sys.stdout.isatty():
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
@@ -90,6 +94,8 @@ class Progress:
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
if self._json_mode:
|
||||
return
|
||||
if sys.stdout.isatty():
|
||||
self.message = ""
|
||||
self._stop.set()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""General-purpose utilities shared across the LangGraph CLI."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@@ -7,21 +9,42 @@ def clean_empty_lines(input_str: str):
|
||||
return "\n".join(filter(None, input_str.splitlines()))
|
||||
|
||||
|
||||
def warn_non_wolfi_distro(config_json: dict) -> None:
|
||||
"""Show warning if image_distro is not set to 'wolfi'."""
|
||||
def warn_non_wolfi_distro(
|
||||
config_json: dict,
|
||||
*,
|
||||
emit: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Show warning if image_distro is not set to 'wolfi'.
|
||||
|
||||
When ``emit`` is provided, each warning line is sent through it (used by
|
||||
callers that need JSON-aware output). Otherwise falls back to colored
|
||||
``click.secho`` output.
|
||||
"""
|
||||
image_distro = config_json.get("image_distro", "debian") # Default is debian
|
||||
if image_distro != "wolfi":
|
||||
click.secho(
|
||||
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
|
||||
fg="yellow",
|
||||
bold=True,
|
||||
if image_distro == "wolfi":
|
||||
return
|
||||
if emit is not None:
|
||||
emit(
|
||||
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
|
||||
)
|
||||
click.secho(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
|
||||
fg="yellow",
|
||||
emit(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
|
||||
)
|
||||
click.secho(
|
||||
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
|
||||
fg="yellow",
|
||||
emit(
|
||||
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
|
||||
)
|
||||
click.secho("") # Empty line for better readability
|
||||
return
|
||||
click.secho(
|
||||
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
|
||||
fg="yellow",
|
||||
bold=True,
|
||||
)
|
||||
click.secho(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho("") # Empty line for better readability
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -8,12 +10,15 @@ import pytest
|
||||
|
||||
from langgraph_cli.deploy import (
|
||||
_call_host_backend_with_optional_tenant,
|
||||
_create_host_backend_client,
|
||||
_docker_config_for_token,
|
||||
_Emitter,
|
||||
_env_without_deployment_name,
|
||||
_parse_env_from_config,
|
||||
_resolve_env_path,
|
||||
normalize_image_name,
|
||||
_smith_dashboard_base_url,
|
||||
normalize_image_tag,
|
||||
normalize_name,
|
||||
)
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
|
||||
@@ -40,30 +45,33 @@ class TestDockerConfigForToken:
|
||||
assert "gcr.io" in data["auths"]
|
||||
|
||||
|
||||
class TestNormalizeImageName:
|
||||
class TestNormalizeName:
|
||||
def test_simple_name(self):
|
||||
assert normalize_image_name("myapp") == "myapp"
|
||||
assert normalize_name("myapp") == "myapp"
|
||||
|
||||
def test_uppercase_lowered(self):
|
||||
assert normalize_image_name("MyApp") == "myapp"
|
||||
assert normalize_name("MyApp") == "myapp"
|
||||
|
||||
def test_special_chars_replaced(self):
|
||||
assert normalize_image_name("my app!@#v2") == "my-app-v2"
|
||||
assert normalize_name("my app!@#v2") == "my-app-v2"
|
||||
|
||||
def test_dots_and_hyphens_kept(self):
|
||||
assert normalize_image_name("my-app.v2") == "my-app.v2"
|
||||
def test_dots_replaced_with_hyphens(self):
|
||||
assert normalize_name("my-app.v2") == "my-app-v2"
|
||||
|
||||
def test_underscores_replaced_with_hyphens(self):
|
||||
assert normalize_name("simple_graph_name") == "simple-graph-name"
|
||||
|
||||
def test_leading_trailing_stripped(self):
|
||||
assert normalize_image_name("--my-app..") == "my-app"
|
||||
assert normalize_name("--my-app..") == "my-app"
|
||||
|
||||
def test_empty_string_returns_app(self):
|
||||
assert normalize_image_name("") == "app"
|
||||
assert normalize_name("") == "app"
|
||||
|
||||
def test_none_returns_app(self):
|
||||
assert normalize_image_name(None) == "app"
|
||||
assert normalize_name(None) == "app"
|
||||
|
||||
def test_all_invalid_chars_returns_app(self):
|
||||
assert normalize_image_name("!!!") == "app"
|
||||
assert normalize_name("!!!") == "app"
|
||||
|
||||
|
||||
class TestNormalizeImageTag:
|
||||
@@ -271,3 +279,256 @@ class TestCallHostBackendWithOptionalTenant:
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
|
||||
def test_workspace_prompt_blocked_by_no_input(self, monkeypatch):
|
||||
"""With _no_input=True, 403 requiring workspace should raise ClickException."""
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
|
||||
requires_workspace = '{"detail":"requires workspace specification"}'
|
||||
client = self._make_client(
|
||||
lambda req: httpx.Response(403, text=requires_workspace)
|
||||
)
|
||||
with pytest.raises(click.ClickException, match="workspace"):
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _Emitter JSON mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmitterJsonMode:
|
||||
"""Verify that _Emitter in json_mode writes valid JSON-lines to stdout."""
|
||||
|
||||
def _capture(self, fn):
|
||||
"""Run fn with stdout captured and return parsed JSON objects."""
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
sys.stdout = old
|
||||
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
||||
return [json.loads(line) for line in lines]
|
||||
|
||||
def test_step_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.step(1, "Building image"))
|
||||
assert len(events) == 1
|
||||
assert events[0]["event"] == "step"
|
||||
assert events[0]["step"] == 1
|
||||
assert events[0]["message"] == "Building image"
|
||||
|
||||
def test_info_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.info("All good"))
|
||||
assert events[0]["event"] == "info"
|
||||
assert events[0]["message"] == "All good"
|
||||
|
||||
def test_warn_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.warn("Careful"))
|
||||
assert events[0]["event"] == "warn"
|
||||
|
||||
def test_error_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.error("Boom"))
|
||||
assert events[0]["event"] == "error"
|
||||
assert events[0]["message"] == "Boom"
|
||||
|
||||
def test_status_change_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.status_change("building", 12.345))
|
||||
assert events[0]["event"] == "status_change"
|
||||
assert events[0]["status"] == "building"
|
||||
assert events[0]["elapsed_seconds"] == 12.3
|
||||
assert events[0]["message"] == "building... (12s)"
|
||||
|
||||
def test_status_change_event_with_minutes(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.status_change("deploying", 95.0))
|
||||
assert events[0]["message"] == "deploying... (1m 35s)"
|
||||
|
||||
def test_log_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.log("some output"))
|
||||
assert events[0] == {"event": "log", "message": "some output"}
|
||||
|
||||
def test_status_url_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(
|
||||
lambda: em.status_url("https://smith.langchain.com/deploy/123")
|
||||
)
|
||||
assert events[0]["event"] == "status_url"
|
||||
assert events[0]["url"] == "https://smith.langchain.com/deploy/123"
|
||||
|
||||
def test_result_event_full(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(
|
||||
lambda: em.result(
|
||||
"succeeded",
|
||||
deployment_id="dep-1",
|
||||
url="https://app.example.com",
|
||||
status_url="https://smith.langchain.com/deploy/dep-1",
|
||||
)
|
||||
)
|
||||
assert events[0]["event"] == "result"
|
||||
assert events[0]["status"] == "succeeded"
|
||||
assert events[0]["deployment_id"] == "dep-1"
|
||||
assert events[0]["message"] == "Deployment successful!"
|
||||
assert events[0]["url"] == "https://app.example.com"
|
||||
assert events[0]["status_url"] == "https://smith.langchain.com/deploy/dep-1"
|
||||
|
||||
def test_result_event_minimal(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.result("failed", deployment_id="dep-2"))
|
||||
assert events[0]["event"] == "result"
|
||||
assert events[0]["status"] == "failed"
|
||||
assert events[0]["message"] == "Deployment failed"
|
||||
assert "url" not in events[0]
|
||||
assert "status_url" not in events[0]
|
||||
|
||||
def test_heartbeat_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.heartbeat("building", 30.789))
|
||||
assert events[0]["event"] == "heartbeat"
|
||||
assert events[0]["elapsed_seconds"] == 30.8
|
||||
assert events[0]["message"] == "building... (30s)"
|
||||
|
||||
def test_heartbeat_silent_in_text_mode(self, capsys):
|
||||
em = _Emitter(json_mode=False)
|
||||
em.heartbeat("building", 10.0)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_upload_progress_event(self):
|
||||
em = _Emitter(json_mode=True)
|
||||
events = self._capture(lambda: em.upload_progress(5.678, 42))
|
||||
assert events[0]["event"] == "upload_progress"
|
||||
assert events[0]["size_mb"] == 5.7
|
||||
assert events[0]["pct"] == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _Emitter text mode (non-json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmitterTextMode:
|
||||
"""Verify that _Emitter in text mode uses click.echo/click.secho."""
|
||||
|
||||
def test_step_writes_text(self, capsys):
|
||||
em = _Emitter(json_mode=False)
|
||||
em.step(1, "Hello")
|
||||
captured = capsys.readouterr()
|
||||
assert "1. Hello" in captured.out
|
||||
|
||||
def test_log_writes_text(self, capsys):
|
||||
em = _Emitter(json_mode=False)
|
||||
em.log("my line")
|
||||
captured = capsys.readouterr()
|
||||
assert "my line" in captured.out
|
||||
|
||||
def test_result_succeeded_text(self, capsys):
|
||||
em = _Emitter(json_mode=False)
|
||||
em.result("succeeded", deployment_id="d1", url="https://app.test")
|
||||
captured = capsys.readouterr()
|
||||
lines = [line.strip() for line in captured.out.splitlines() if line.strip()]
|
||||
assert "Deployment successful!" in lines
|
||||
assert "URL: https://app.test" in lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --no-input guard on _create_host_backend_client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateHostBackendClientNoInput:
|
||||
def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGCHAIN_API_KEY", raising=False)
|
||||
monkeypatch.delenv("LANGGRAPH_HOST_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(click.ClickException, match="API key"):
|
||||
_create_host_backend_client(
|
||||
host_url="https://api.example.com",
|
||||
api_key=None,
|
||||
env_vars={},
|
||||
)
|
||||
|
||||
def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path):
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
|
||||
client = _create_host_backend_client(
|
||||
host_url="https://api.example.com",
|
||||
api_key=None,
|
||||
env_vars={},
|
||||
)
|
||||
assert client is not None
|
||||
|
||||
|
||||
class TestSmithDashboardBaseUrl:
|
||||
def test_none_returns_default(self):
|
||||
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
|
||||
|
||||
def test_empty_returns_default(self):
|
||||
assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
|
||||
|
||||
def test_prod_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://api.host.langchain.com")
|
||||
== "https://smith.langchain.com"
|
||||
)
|
||||
|
||||
def test_dev_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://dev.api.host.langchain.com")
|
||||
== "https://dev.smith.langchain.com"
|
||||
)
|
||||
|
||||
def test_eu_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://eu.api.host.langchain.com")
|
||||
== "https://eu.smith.langchain.com"
|
||||
)
|
||||
|
||||
def test_staging_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://staging.api.host.langchain.com")
|
||||
== "https://staging.smith.langchain.com"
|
||||
)
|
||||
|
||||
def test_localhost(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://localhost:8080")
|
||||
== "http://localhost:8080"
|
||||
)
|
||||
|
||||
def test_localhost_trailing_slash(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://localhost:8080/")
|
||||
== "http://localhost:8080"
|
||||
)
|
||||
|
||||
def test_127_0_0_1(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://127.0.0.1:3000")
|
||||
== "http://127.0.0.1:3000"
|
||||
)
|
||||
|
||||
def test_unknown_domain_returns_default(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://custom.example.com")
|
||||
== "https://smith.langchain.com"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,15 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
|
||||
"""Reducer channel that stores only a sentinel in checkpoint blobs and
|
||||
reconstructs state by replaying ancestor writes through the reducer.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
`DeltaChannel` is in beta. The API and on-disk representation may
|
||||
change in future releases. Threads written with `DeltaChannel` today
|
||||
are expected to remain readable, but the surrounding contract
|
||||
(`BaseCheckpointSaver.get_delta_channel_history`, the
|
||||
`_DeltaSnapshot` blob shape, the `delta_updates_since_snapshot`
|
||||
metadata field) is not yet stable.
|
||||
|
||||
The reducer receives the current accumulated value and a batch of writes
|
||||
in one call: `reducer(state, [write1, write2, ...]) -> new_state`.
|
||||
|
||||
|
||||
@@ -34,28 +34,23 @@ def empty_checkpoint() -> Checkpoint:
|
||||
)
|
||||
|
||||
|
||||
def _should_snapshot_delta(
|
||||
name: str,
|
||||
ch: DeltaChannel,
|
||||
updates_since_snapshot: Mapping[str, int],
|
||||
*,
|
||||
force: bool,
|
||||
) -> bool:
|
||||
"""Decide whether `ch` should write a `_DeltaSnapshot` this step.
|
||||
def delta_channels_to_snapshot(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
counts: Mapping[str, int],
|
||||
) -> set[str]:
|
||||
"""Return the set of DeltaChannel names that should snapshot now.
|
||||
|
||||
Triggers:
|
||||
* `force` — always snapshot (used by `durability="exit"`).
|
||||
* Update-count: this channel has accumulated at least
|
||||
`snapshot_frequency` updates since its last snapshot. The count
|
||||
is supplied by the caller via `updates_since_snapshot[name]` and
|
||||
is reset to `0` whenever a snapshot fires.
|
||||
|
||||
Version-format-independent: works for `int`, `float`, and `str`
|
||||
versioning schemes alike.
|
||||
A channel snapshots when its accumulated update count (since the last
|
||||
snapshot) reaches or exceeds `snapshot_frequency`. This is a pure
|
||||
predicate — no mutation.
|
||||
"""
|
||||
if force:
|
||||
return True
|
||||
return updates_since_snapshot.get(name, 0) >= ch.snapshot_frequency
|
||||
return {
|
||||
name
|
||||
for name, ch in channels.items()
|
||||
if isinstance(ch, DeltaChannel)
|
||||
and ch.is_available()
|
||||
and counts.get(name, 0) >= ch.snapshot_frequency
|
||||
}
|
||||
|
||||
|
||||
def create_checkpoint(
|
||||
@@ -66,34 +61,19 @@ def create_checkpoint(
|
||||
id: str | None = None,
|
||||
updated_channels: set[str] | None = None,
|
||||
get_next_version: GetNextVersion | None = None,
|
||||
force_delta_snapshot: bool = False,
|
||||
updates_since_snapshot: Mapping[str, int] | None = None,
|
||||
new_updates_since_snapshot: dict[str, int] | None = None,
|
||||
channels_to_snapshot: set[str] | None = None,
|
||||
) -> Checkpoint:
|
||||
"""Create a checkpoint for the given channels.
|
||||
"""Build a new Checkpoint from the previous one and live channel state.
|
||||
|
||||
For each `DeltaChannel`, a `_DeltaSnapshot(value)` blob is written into
|
||||
`channel_values[k]` when this channel has accumulated at least
|
||||
`snapshot_frequency` updates since its last snapshot (counter supplied
|
||||
via `updates_since_snapshot`). Otherwise the channel is omitted from
|
||||
`channel_values`; its `channel_versions` entry still bumps so that the
|
||||
saver tracks the channel and the ancestor walk can replay writes.
|
||||
|
||||
Snapshots are eager: even if the channel had no write this step, a
|
||||
version bump is forced (via `get_next_version`) so `put()` includes
|
||||
the channel in `new_versions` and stores the blob.
|
||||
|
||||
`force_delta_snapshot` ignores the cadence and always snapshots —
|
||||
used by `durability="exit"` where intermediate writes are not stored
|
||||
as ancestor `checkpoint_writes`.
|
||||
|
||||
If `new_updates_since_snapshot` is provided, the function resets the
|
||||
counter to `0` for any channel that snapshotted this step. Counters
|
||||
for channels that did not snapshot are left untouched (the caller is
|
||||
responsible for incrementing them based on `updated_channels`).
|
||||
For each name in `channels_to_snapshot`, a `_DeltaSnapshot(value)` blob
|
||||
is written into `channel_values[k]`. Other delta channels are omitted
|
||||
from `channel_values` — the ancestor walk reconstructs their state
|
||||
from `checkpoint_writes`. Callers compute the set via
|
||||
`delta_channels_to_snapshot(channels, counts)`; defaults to empty
|
||||
(no snapshots) when not provided.
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
counts = updates_since_snapshot or {}
|
||||
channels_to_snapshot = channels_to_snapshot or set()
|
||||
if channels is None:
|
||||
values = checkpoint["channel_values"]
|
||||
channel_versions = checkpoint["channel_versions"]
|
||||
@@ -104,25 +84,23 @@ def create_checkpoint(
|
||||
if k not in channel_versions:
|
||||
continue
|
||||
ch = channels[k]
|
||||
if (
|
||||
isinstance(ch, DeltaChannel)
|
||||
and ch.is_available()
|
||||
and _should_snapshot_delta(
|
||||
k,
|
||||
ch,
|
||||
counts,
|
||||
force=force_delta_snapshot,
|
||||
)
|
||||
):
|
||||
# Eager snapshot: bump version if not already written this step
|
||||
# so put() includes this channel in new_versions and stores blob.
|
||||
if k in channels_to_snapshot:
|
||||
# In exit mode, the snapshot decision is deferred to exit
|
||||
# time (intermediate steps have do_checkpoint=False). The
|
||||
# channel's count may have reached snapshot_frequency over
|
||||
# several supersteps, but the LAST superstep may not have
|
||||
# written to this channel. In that case apply_writes()
|
||||
# (in _algo.py) didn't bump this channel's version, so
|
||||
# saver.put() wouldn't include it in new_versions and
|
||||
# the snapshot blob would be silently dropped. The manual
|
||||
# bump below closes the gap. In sync/async durability this
|
||||
# branch is effectively dead code (the step that pushes
|
||||
# the count to freq always writes the channel).
|
||||
if get_next_version is not None and (
|
||||
updated_channels is None or k not in updated_channels
|
||||
):
|
||||
channel_versions[k] = get_next_version(channel_versions[k], None)
|
||||
values[k] = _DeltaSnapshot(ch.get())
|
||||
if new_updates_since_snapshot is not None:
|
||||
new_updates_since_snapshot[k] = 0
|
||||
else:
|
||||
v = ch.checkpoint()
|
||||
if v is not MISSING:
|
||||
|
||||
@@ -100,6 +100,7 @@ from langgraph.pregel._checkpoint import (
|
||||
channels_from_checkpoint,
|
||||
copy_checkpoint,
|
||||
create_checkpoint,
|
||||
delta_channels_to_snapshot,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.pregel._executor import (
|
||||
@@ -194,8 +195,40 @@ class PregelLoop:
|
||||
_migrate_checkpoint: Callable[[Checkpoint], None] | None
|
||||
submit: Submit
|
||||
channels: Mapping[str, BaseChannel]
|
||||
# Only set on AsyncPregelLoop; sync loops keep this as None.
|
||||
# Futures from `checkpointer.put_writes` calls that produced delta-channel
|
||||
# writes. `_checkpointer_put_after_previous` drains this list (swap to a
|
||||
# local `futs` then reset to `[]` and wait/gather) before putting the
|
||||
# next checkpoint, so a checkpoint never becomes durable before the
|
||||
# writes that produced it. Initialised to `[]` in both sync and async
|
||||
# `__enter__`; stays `None` only when no checkpointer.
|
||||
_delta_write_futs: list[Any] | None = None
|
||||
|
||||
# Exit-mode accumulator: every delta-channel write produced during this
|
||||
# run (input writes from `_first` + per-superstep writes captured in
|
||||
# `after_tick`). At exit, `_put_exit_delta_writes` filters out channels
|
||||
# that will snapshot, then persists the rest under an anchor parent.
|
||||
# `None` when not in exit mode (so the capture sites are no-ops).
|
||||
# Each tuple is `(step, task_id, channel, value)` — `step` drives the
|
||||
# synthetic step-prefixed task_id used to preserve chronological order
|
||||
# under the saver's `ORDER BY task_id, idx` sorting.
|
||||
_exit_delta_writes: list[tuple[int, str, str, Any]] | None = None
|
||||
|
||||
# The checkpoint_config that points at the parent loaded at `__enter__`
|
||||
# (or the synthetic-empty checkpoint, on first run). We capture it
|
||||
# eagerly because every `_put_checkpoint` advances `self.checkpoint_config`
|
||||
# to the newly-saved checkpoint's id — by exit time the original parent
|
||||
# config would otherwise be lost. `_put_exit_delta_writes` uses this:
|
||||
# on resumed runs as the anchor for exit delta writes; on first runs
|
||||
# to derive the lazy stub's config (its `checkpoint_id` is the
|
||||
# synthetic-empty id we want the stub persisted under).
|
||||
_initial_checkpoint_config: RunnableConfig
|
||||
|
||||
# True iff the saver actually returned a tuple at `__enter__`. False
|
||||
# on the first-ever run for a thread (no parent persisted yet).
|
||||
# `_put_exit_delta_writes` uses this to decide between anchoring on
|
||||
# the existing parent (True) or creating a lazy stub (False).
|
||||
_has_persisted_parent: bool = False
|
||||
|
||||
managed: ManagedValueMapping
|
||||
checkpoint: Checkpoint
|
||||
checkpoint_id_saved: str
|
||||
@@ -637,6 +670,11 @@ class PregelLoop:
|
||||
self._emit(
|
||||
"values", map_output_values, self.output_keys, writes, self.channels
|
||||
)
|
||||
# capture delta-channel writes for exit-mode accumulator before clearing
|
||||
if self._exit_delta_writes is not None:
|
||||
for tid, ch, v in self.checkpoint_pending_writes:
|
||||
if isinstance(self.specs.get(ch), DeltaChannel):
|
||||
self._exit_delta_writes.append((self.step, tid, ch, v))
|
||||
# clear pending writes
|
||||
self.checkpoint_pending_writes.clear()
|
||||
# only replay (re-execute) done tasks on the first tick
|
||||
@@ -854,6 +892,27 @@ class PregelLoop:
|
||||
self.checkpointer_get_next_version,
|
||||
self.trigger_to_nodes,
|
||||
)
|
||||
# Input writes go through `apply_writes` directly (above) — they
|
||||
# never enter `checkpoint_pending_writes`, so the after_tick
|
||||
# capture site does not see them. In exit mode, capture them
|
||||
# here so `_exit_delta_writes` includes the input's delta writes
|
||||
# alongside per-superstep writes; otherwise the input would be
|
||||
# lost on read (it's not in final_checkpoint.channel_values for
|
||||
# sub-freq channels, and walks ignore target.pending_writes).
|
||||
if self._exit_delta_writes is not None:
|
||||
for c, v in input_writes:
|
||||
if isinstance(self.specs.get(c), DeltaChannel):
|
||||
self._exit_delta_writes.append((self.step, NULL_TASK_ID, c, v))
|
||||
# Persist delta-channel input writes so sub-freq inputs are
|
||||
# recoverable via ancestor walk (mirrors the Command input path).
|
||||
if self.durability != "exit":
|
||||
delta_input = [
|
||||
(c, v)
|
||||
for c, v in input_writes
|
||||
if isinstance(self.specs.get(c), DeltaChannel)
|
||||
]
|
||||
if delta_input:
|
||||
self.put_writes(NULL_TASK_ID, delta_input)
|
||||
# save input checkpoint
|
||||
self.updated_channels = updated_channels
|
||||
self._put_checkpoint({"source": "input"})
|
||||
@@ -905,36 +964,60 @@ class PregelLoop:
|
||||
return updated_channels
|
||||
|
||||
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
|
||||
# assign step and parents
|
||||
# `is` (object identity) — not `==`. Three of four call sites pass a
|
||||
# fresh dict ({"source":"input"|"loop"|"fork"}); only
|
||||
# `_suppress_interrupt`(will rename to _on_loop_exit soon)
|
||||
# at exit reuses the existing `self.checkpoint_metadata` instance. So
|
||||
# `metadata is self.checkpoint_metadata` is True only on the exit call,
|
||||
# which is what we use to gate exit-only behaviour (skip count-bump,
|
||||
# don't replace metadata). Could be replaced by an explicit
|
||||
# `exiting: bool = False` parameter; left as-is to match the existing
|
||||
# idiom in this file.
|
||||
# TODO: replace with an explicit `exiting: bool = False` parameter.
|
||||
exiting = metadata is self.checkpoint_metadata
|
||||
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
|
||||
# checkpoint already saved
|
||||
return
|
||||
# Carry per-delta-channel update bookkeeping forward across
|
||||
# supersteps. Capture from the OLD metadata before potentially
|
||||
# replacing it with a fresh dict that wouldn't contain it. Then
|
||||
# increment for any delta channel updated this step (so the count
|
||||
# reflects "supersteps that wrote to this channel since last
|
||||
# snapshot"). create_checkpoint will reset entries to 0 for any
|
||||
# channel that fires a snapshot this step.
|
||||
prev_counts = dict(
|
||||
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
)
|
||||
new_counts = dict(prev_counts)
|
||||
if self.updated_channels:
|
||||
for ch_name in self.updated_channels:
|
||||
ch_obj = self.channels.get(ch_name)
|
||||
if isinstance(ch_obj, DeltaChannel):
|
||||
new_counts[ch_name] = new_counts.get(ch_name, 0) + 1
|
||||
# Per-delta-channel update bookkeeping.
|
||||
#
|
||||
# `_put_checkpoint` is called once per superstep with a fresh
|
||||
# metadata dict (source="input"|"loop"|"fork") — those are the
|
||||
# intermediate calls that bump the count by +1 for each delta
|
||||
# channel touched that step. In exit mode,
|
||||
# `_suppress_interrupt`(will rename to _on_loop_exit soon)
|
||||
# additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT
|
||||
# EXIT to commit the final checkpoint — this runs *after* the last
|
||||
# intermediate call already counted the last superstep. So the
|
||||
# exit call must NOT bump again or it would double-count the last
|
||||
# superstep. (Sync/async durability does not call `_put_checkpoint`
|
||||
# at exit, so the issue only surfaces in exit mode. force_delta_snapshot
|
||||
# used to mask this latent bug by resetting every count to 0.)
|
||||
if not exiting:
|
||||
prev_counts = dict(
|
||||
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
)
|
||||
new_counts = dict(prev_counts)
|
||||
if self.updated_channels:
|
||||
for ch_name in self.updated_channels:
|
||||
if isinstance(self.channels.get(ch_name), DeltaChannel):
|
||||
new_counts[ch_name] = new_counts.get(ch_name, 0) + 1
|
||||
metadata["step"] = self.step
|
||||
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
self.checkpoint_metadata = metadata
|
||||
else:
|
||||
new_counts = dict(
|
||||
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
)
|
||||
# do checkpoint?
|
||||
do_checkpoint = self._checkpointer_put_after_previous is not None and (
|
||||
exiting or self.durability != "exit"
|
||||
)
|
||||
# create new checkpoint
|
||||
channels_to_snapshot = (
|
||||
delta_channels_to_snapshot(self.channels, new_counts)
|
||||
if do_checkpoint
|
||||
else set()
|
||||
)
|
||||
self.checkpoint = create_checkpoint(
|
||||
self.checkpoint,
|
||||
self.channels if do_checkpoint else None,
|
||||
@@ -944,10 +1027,10 @@ class PregelLoop:
|
||||
get_next_version=self.checkpointer_get_next_version
|
||||
if do_checkpoint
|
||||
else None,
|
||||
force_delta_snapshot=exiting and self.durability == "exit",
|
||||
updates_since_snapshot=new_counts,
|
||||
new_updates_since_snapshot=new_counts,
|
||||
channels_to_snapshot=channels_to_snapshot,
|
||||
)
|
||||
for k in channels_to_snapshot:
|
||||
new_counts[k] = 0
|
||||
if new_counts:
|
||||
self.checkpoint_metadata["delta_updates_since_snapshot"] = new_counts
|
||||
elif "delta_updates_since_snapshot" in self.checkpoint_metadata:
|
||||
@@ -1010,6 +1093,97 @@ class PregelLoop:
|
||||
# increment step
|
||||
self.step += 1
|
||||
|
||||
def _put_exit_delta_writes(self) -> None:
|
||||
"""Stage stub + accumulated delta writes so final_checkpoint's put
|
||||
waits on them (visibility invariant: both must be durable before
|
||||
final_checkpoint becomes visible to readers).
|
||||
|
||||
Stub is created lazily — only when no persisted parent exists AND at
|
||||
least one delta channel has writes that won't be snapshotted.
|
||||
"""
|
||||
if (
|
||||
not self._exit_delta_writes
|
||||
or self.checkpointer is None
|
||||
or self._checkpointer_put_after_previous is None
|
||||
or self.checkpointer_put_writes is None
|
||||
):
|
||||
return
|
||||
|
||||
counts = self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
channels_to_snapshot = delta_channels_to_snapshot(self.channels, counts)
|
||||
|
||||
pending = [
|
||||
(step, tid, ch, v)
|
||||
for (step, tid, ch, v) in self._exit_delta_writes
|
||||
if ch not in channels_to_snapshot
|
||||
]
|
||||
if not pending:
|
||||
return
|
||||
|
||||
if self._has_persisted_parent:
|
||||
# _initial_checkpoint_config's checkpoint_id is the saved parent's
|
||||
# id (saver returned a real tuple at __enter__).
|
||||
anchor_config = self._initial_checkpoint_config
|
||||
else:
|
||||
stub_cp = empty_checkpoint()
|
||||
stub_cp["id"] = self.checkpoint_id_saved
|
||||
stub_cp["ts"] = datetime.now(timezone.utc).isoformat()
|
||||
# Stub has no parent (checkpoint_id=None in config).
|
||||
stub_put_config = patch_configurable(
|
||||
self._initial_checkpoint_config,
|
||||
{CONFIG_KEY_CHECKPOINT_ID: None},
|
||||
)
|
||||
# Anchor config for put_writes: checkpoint_id = stub's id.
|
||||
anchor_config = patch_configurable(
|
||||
self._initial_checkpoint_config,
|
||||
{CONFIG_KEY_CHECKPOINT_ID: stub_cp["id"]},
|
||||
)
|
||||
self._put_checkpoint_fut = self.submit(
|
||||
self._checkpointer_put_after_previous,
|
||||
getattr(self, "_put_checkpoint_fut", None),
|
||||
stub_put_config,
|
||||
stub_cp,
|
||||
{"step": -2},
|
||||
{},
|
||||
)
|
||||
# Set checkpoint_config so final_checkpoint's _put_checkpoint
|
||||
# sees the stub as its parent.
|
||||
self.checkpoint_config = anchor_config
|
||||
|
||||
# Step-prefixed synthetic task_id preserves chronological superstep
|
||||
# order under the saver's ORDER BY task_id, idx sorting.
|
||||
grouped: dict[tuple[int, str], list[tuple[str, Any]]] = {}
|
||||
for step, tid, ch, v in pending:
|
||||
grouped.setdefault((step, tid), []).append((ch, v))
|
||||
anchor_write_config = patch_configurable(
|
||||
anchor_config,
|
||||
{
|
||||
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINT_NS, ""
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINT_ID: anchor_config[CONF][CONFIG_KEY_CHECKPOINT_ID],
|
||||
},
|
||||
)
|
||||
for (step, tid), entries in grouped.items():
|
||||
synth_tid = f"{step:08d}-{tid}"
|
||||
if self.checkpointer_put_writes_accepts_task_path:
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
anchor_write_config,
|
||||
entries,
|
||||
synth_tid,
|
||||
"",
|
||||
)
|
||||
else:
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
anchor_write_config,
|
||||
entries,
|
||||
synth_tid,
|
||||
)
|
||||
if self._delta_write_futs is not None:
|
||||
self._delta_write_futs.append(fut)
|
||||
|
||||
def _suppress_interrupt(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
@@ -1025,6 +1199,7 @@ class PregelLoop:
|
||||
# or a nested graph with checkpointer=True
|
||||
or all(NS_END not in part for part in self.checkpoint_ns)
|
||||
):
|
||||
self._put_exit_delta_writes()
|
||||
self._put_checkpoint(self.checkpoint_metadata)
|
||||
self._put_pending_writes()
|
||||
# suppress interrupt
|
||||
@@ -1230,6 +1405,9 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
if self._delta_write_futs:
|
||||
futs, self._delta_write_futs = self._delta_write_futs, []
|
||||
concurrent.futures.wait(futs)
|
||||
try:
|
||||
if prev is not None:
|
||||
prev.result()
|
||||
@@ -1347,6 +1525,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
# graph/thread. Returns None on first invocation.
|
||||
saved = self.checkpointer.get_tuple(self.checkpoint_config)
|
||||
|
||||
# Capture before the synthetic-empty fallback below overwrites `saved`.
|
||||
# `_put_exit_delta_writes` uses this on first run (no persisted parent)
|
||||
# to lazy-create a stub instead of anchoring delta writes on a parent.
|
||||
self._has_persisted_parent = saved is not None
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
@@ -1362,6 +1544,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
**saved.config.get(CONF, {}),
|
||||
},
|
||||
}
|
||||
self._initial_checkpoint_config = self.checkpoint_config
|
||||
self.prev_checkpoint_config = saved.parent_config
|
||||
self.checkpoint_id_saved = saved.checkpoint["id"]
|
||||
self.checkpoint = saved.checkpoint
|
||||
@@ -1371,6 +1554,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self._exit_delta_writes = (
|
||||
[] if self.durability == "exit" and self.checkpointer is not None else None
|
||||
)
|
||||
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs,
|
||||
@@ -1596,6 +1783,10 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
# graph/thread. Returns None on first invocation.
|
||||
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
|
||||
|
||||
# Capture before the synthetic-empty fallback below overwrites `saved`.
|
||||
# `_put_exit_delta_writes` uses this on first run (no persisted parent)
|
||||
# to lazy-create a stub instead of anchoring delta writes on a parent.
|
||||
self._has_persisted_parent = saved is not None
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
@@ -1611,6 +1802,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
**saved.config.get(CONF, {}),
|
||||
},
|
||||
}
|
||||
self._initial_checkpoint_config = self.checkpoint_config
|
||||
self.prev_checkpoint_config = saved.parent_config
|
||||
self.checkpoint_id_saved = saved.checkpoint["id"]
|
||||
self.checkpoint = saved.checkpoint
|
||||
@@ -1621,6 +1813,9 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self._exit_delta_writes = (
|
||||
[] if self.durability == "exit" and self.checkpointer is not None else None
|
||||
)
|
||||
self.submit = await self.stack.enter_async_context(
|
||||
AsyncBackgroundExecutor(self.config)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Tests for exit-mode delta channel persistence redesign.
|
||||
|
||||
Validates that `durability="exit"` correctly persists delta-channel writes
|
||||
using count-based snapshot decisions (rather than force-snapshotting every
|
||||
channel), lazy stub creation when no parent exists, and proper read-path
|
||||
reconstruction via ancestor walks.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _build_graph(
|
||||
checkpointer: InMemorySaver,
|
||||
*,
|
||||
freq: int = 1000,
|
||||
) -> Any:
|
||||
channel = DeltaChannel(_messages_delta_reducer, snapshot_frequency=freq)
|
||||
# Functional TypedDict form: class form can't reference `channel` (a
|
||||
# local variable) inside Annotated due to forward-ref evaluation rules.
|
||||
State = TypedDict("State", {"messages": Annotated[list, channel]}) # type: ignore[call-overload] # noqa: UP013
|
||||
|
||||
def respond(state: dict) -> dict:
|
||||
i = len(state["messages"])
|
||||
return {"messages": [AIMessage(content=f"reply-{i}", id=f"ai{i}")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8a. Write-path / structural tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_exit_first_run_no_delta_writes() -> None:
|
||||
"""Graph with delta channel invoked with input that doesn't touch it.
|
||||
Only one checkpoint row, no stub."""
|
||||
State = TypedDict( # noqa: UP013
|
||||
"State",
|
||||
{
|
||||
"messages": Annotated[list, DeltaChannel(_messages_delta_reducer)],
|
||||
"value": str,
|
||||
},
|
||||
) # type: ignore[call-overload]
|
||||
|
||||
def noop(state: dict) -> dict:
|
||||
return {"value": "done"}
|
||||
|
||||
saver = InMemorySaver()
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("noop", noop)
|
||||
builder.add_edge(START, "noop")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "no-delta-writes"}}
|
||||
|
||||
graph.invoke({"value": "start"}, config, durability="exit")
|
||||
|
||||
checkpoints = list(saver.list(config))
|
||||
assert len(checkpoints) == 1
|
||||
stubs = [t for t in checkpoints if t.metadata.get("step") == -2]
|
||||
assert len(stubs) == 0
|
||||
|
||||
|
||||
async def test_exit_first_run_all_snapshot() -> None:
|
||||
"""snapshot_frequency=1 forces every channel to snapshot.
|
||||
No stub needed; final_checkpoint has _DeltaSnapshot."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver, freq=1)
|
||||
config = {"configurable": {"thread_id": "all-snapshot"}}
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="hi", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
assert len(result["messages"]) == 2
|
||||
|
||||
checkpoints = list(saver.list(config))
|
||||
stubs = [t for t in checkpoints if t.metadata.get("step") == -2]
|
||||
assert len(stubs) == 0
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert [m.content for m in state.values["messages"]] == ["hi", "reply-1"]
|
||||
|
||||
|
||||
async def test_exit_first_run_sub_freq_with_writes() -> None:
|
||||
"""First run with default snapshot_frequency (1000), writes below threshold.
|
||||
A stub is created; writes are anchored under it; get_state reconstructs."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver)
|
||||
config = {"configurable": {"thread_id": "sub-freq-first"}}
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="hello", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
assert [m.content for m in result["messages"]] == ["hello", "reply-1"]
|
||||
|
||||
checkpoints = list(saver.list(config))
|
||||
stubs = [t for t in checkpoints if t.metadata.get("step") == -2]
|
||||
assert len(stubs) == 1, f"Expected 1 stub, got {len(stubs)}"
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
assert "messages" not in head.checkpoint["channel_values"]
|
||||
assert "messages" in head.checkpoint["channel_versions"]
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert [m.content for m in state.values["messages"]] == ["hello", "reply-1"]
|
||||
|
||||
|
||||
async def test_exit_resumed_run_sub_freq() -> None:
|
||||
"""Two consecutive exit runs. Second run anchors on the first's
|
||||
final_checkpoint (no new stub). Ordering preserved."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver)
|
||||
config = {"configurable": {"thread_id": "resumed-sub-freq"}}
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="msg1", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="msg2", id="h2")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
checkpoints = list(saver.list(config))
|
||||
stubs = [t for t in checkpoints if t.metadata.get("step") == -2]
|
||||
assert len(stubs) == 1
|
||||
|
||||
state = graph.get_state(config)
|
||||
contents = [m.content for m in state.values["messages"]]
|
||||
assert len(contents) == 4
|
||||
assert contents[0] == "msg1"
|
||||
assert contents[2] == "msg2"
|
||||
assert contents[0:4:2] == ["msg1", "msg2"]
|
||||
|
||||
|
||||
async def test_exit_count_parity_sync_vs_exit() -> None:
|
||||
"""Sync and exit durability produce the same delta_updates_since_snapshot
|
||||
after an equivalent run."""
|
||||
for durability in ("sync", "exit"):
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver)
|
||||
config = {"configurable": {"thread_id": f"parity-{durability}"}}
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="hi", id="h1")]},
|
||||
config,
|
||||
durability=durability,
|
||||
)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counts = head.metadata.get("delta_updates_since_snapshot", {})
|
||||
assert counts.get("messages") == 2, (
|
||||
f"durability={durability}: expected count=2, got {counts}"
|
||||
)
|
||||
|
||||
|
||||
async def test_exit_snapshot_fires_at_frequency() -> None:
|
||||
"""With snapshot_frequency=3, after 3 exit runs (each incrementing count
|
||||
by 2: input + superstep), the 2nd run hits count=4>=3, triggering snapshot.
|
||||
After that run, count resets to 0 and channel_values has _DeltaSnapshot."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver, freq=3)
|
||||
config = {"configurable": {"thread_id": "snapshot-at-freq"}}
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="m1", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
count1 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
|
||||
assert count1 == 2
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="m2", id="h2")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
count2 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
|
||||
assert count2 == 0, f"Expected reset to 0 after snapshot, got {count2}"
|
||||
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
|
||||
|
||||
|
||||
async def test_exit_mixed_snapshot_and_non_snapshot() -> None:
|
||||
"""One delta channel at freq=1 (always snapshot) and one at freq=1000
|
||||
(never snapshot within this test). Verify correct behavior for both."""
|
||||
|
||||
fast_ch = DeltaChannel(_messages_delta_reducer, snapshot_frequency=1)
|
||||
slow_ch = DeltaChannel(_messages_delta_reducer, snapshot_frequency=1000)
|
||||
State = TypedDict( # noqa: UP013
|
||||
"State",
|
||||
{"fast": Annotated[list, fast_ch], "slow": Annotated[list, slow_ch]},
|
||||
) # type: ignore[call-overload]
|
||||
|
||||
def respond(state: dict) -> dict:
|
||||
return {
|
||||
"fast": [AIMessage(content="fast-reply", id="f1")],
|
||||
"slow": [AIMessage(content="slow-reply", id="s1")],
|
||||
}
|
||||
|
||||
saver = InMemorySaver()
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "mixed-freq"}}
|
||||
|
||||
graph.invoke(
|
||||
{
|
||||
"fast": [HumanMessage(content="fast-in", id="fi")],
|
||||
"slow": [HumanMessage(content="slow-in", id="si")],
|
||||
},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
assert isinstance(head.checkpoint["channel_values"].get("fast"), _DeltaSnapshot)
|
||||
assert "slow" not in head.checkpoint["channel_values"]
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert [m.content for m in state.values["fast"]] == ["fast-in", "fast-reply"]
|
||||
assert [m.content for m in state.values["slow"]] == ["slow-in", "slow-reply"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8b. Read-path tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_exit_multi_run_replay_chain() -> None:
|
||||
"""K=4 consecutive exit runs, each adding a message. After each run,
|
||||
get_state returns all messages in chronological order."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver)
|
||||
config = {"configurable": {"thread_id": "replay-chain"}}
|
||||
|
||||
for i in range(4):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content=f"user-{i}", id=f"h{i}")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
state = graph.get_state(config)
|
||||
contents = [m.content for m in state.values["messages"]]
|
||||
user_msgs = [c for c in contents if c.startswith("user-")]
|
||||
assert user_msgs == [f"user-{j}" for j in range(i + 1)], (
|
||||
f"After run {i}: user messages out of order: {user_msgs}"
|
||||
)
|
||||
assert len(contents) == (i + 1) * 2
|
||||
|
||||
|
||||
async def test_exit_metadata_round_trip() -> None:
|
||||
"""K=5 consecutive exit runs with snapshot_frequency=5. Verify metadata
|
||||
delta_updates_since_snapshot increments correctly across runs."""
|
||||
freq = 5
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver, freq=freq)
|
||||
config = {"configurable": {"thread_id": "metadata-rt"}}
|
||||
|
||||
for i in range(1, 6):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content=f"m{i}", id=f"h{i}")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
count = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
|
||||
cumulative = i * 2
|
||||
if cumulative >= freq:
|
||||
assert count == 0 or count == cumulative % freq or count < freq, (
|
||||
f"After run {i}: count={count} should have reset or be partial"
|
||||
)
|
||||
else:
|
||||
assert count == cumulative, (
|
||||
f"After run {i}: expected {cumulative}, got {count}"
|
||||
)
|
||||
|
||||
|
||||
async def test_exit_mixed_durability_round_trip() -> None:
|
||||
"""Alternate sync and exit durability; verify counts stay monotonic
|
||||
and state accumulates correctly."""
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver)
|
||||
config = {"configurable": {"thread_id": "mixed-durability"}}
|
||||
|
||||
for i, dur in enumerate(["sync", "exit", "sync", "exit"]):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content=f"msg-{i}", id=f"h{i}")]},
|
||||
config,
|
||||
durability=dur,
|
||||
)
|
||||
|
||||
state = graph.get_state(config)
|
||||
contents = [m.content for m in state.values["messages"]]
|
||||
user_msgs = [c for c in contents if c.startswith("msg-")]
|
||||
assert user_msgs == [f"msg-{j}" for j in range(i + 1)], (
|
||||
f"After run {i} (durability={dur}): {user_msgs}"
|
||||
)
|
||||
assert len(contents) == (i + 1) * 2
|
||||
|
||||
|
||||
async def test_exit_snapshot_then_tail_deltas() -> None:
|
||||
"""Run 1 forces snapshot (freq=1). Run 2 at freq=1000 adds more writes
|
||||
that don't snapshot. Reading after run 2 must combine the snapshot seed
|
||||
with the tail deltas."""
|
||||
saver = InMemorySaver()
|
||||
|
||||
graph1 = _build_graph(saver, freq=1)
|
||||
config = {"configurable": {"thread_id": "snapshot-then-tail"}}
|
||||
graph1.invoke(
|
||||
{"messages": [HumanMessage(content="seed-msg", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
|
||||
|
||||
graph2 = _build_graph(saver, freq=1000)
|
||||
graph2.invoke(
|
||||
{"messages": [HumanMessage(content="tail-msg", id="h2")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
|
||||
state = graph2.get_state(config)
|
||||
contents = [m.content for m in state.values["messages"]]
|
||||
assert "seed-msg" in contents
|
||||
assert "tail-msg" in contents
|
||||
assert contents.index("seed-msg") < contents.index("tail-msg")
|
||||
Generated
+6
-6
@@ -1246,7 +1246,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-server"
|
||||
version = "2.17.0"
|
||||
version = "2.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1269,9 +1269,9 @@ dependencies = [
|
||||
{ name = "traitlets" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/ec/9302cec1ccacdd33c1b1312ac31681c8975cae56c626d783ab49edf9c681/jupyter_server-2.18.0.tar.gz", hash = "sha256:568b27bce4320a53c3eebf1bdcbee9acf48a8ab7f66ec83d900ca9909d4fb770", size = 751152, upload-time = "2026-05-04T13:39:29.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/f9/050312d92072ddb9ce14c11171804c07435790c98d4350935a780d9e10c2/jupyter_server-2.18.0-py3-none-any.whl", hash = "sha256:69a5397a039d689da81a45955f9b23e95ee167f6d8a8d64372fb616f2aac650a", size = 391687, upload-time = "2026-05-04T13:39:27.549Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1658,7 +1658,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.0.3"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -1849,14 +1849,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
|
||||
Generated
+3
-3
@@ -464,7 +464,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.0.3"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -618,14 +618,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
|
||||
@@ -110,7 +110,7 @@ def get_client(
|
||||
if url is None:
|
||||
url = "http://api"
|
||||
if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true":
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # ty: ignore[invalid-argument-type]
|
||||
_registered_transports.append(transport)
|
||||
else:
|
||||
try:
|
||||
@@ -122,7 +122,7 @@ def get_client(
|
||||
"Failed to connect to in-process LangGraph server. Deferring configuration.",
|
||||
exc_info=True,
|
||||
)
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # ty: ignore[invalid-argument-type]
|
||||
_registered_transports.append(transport)
|
||||
|
||||
if transport is None:
|
||||
@@ -131,7 +131,7 @@ def get_client(
|
||||
base_url=url,
|
||||
transport=transport,
|
||||
timeout=(
|
||||
httpx.Timeout(timeout) # type: ignore[arg-type]
|
||||
httpx.Timeout(timeout) # ty: ignore[invalid-argument-type]
|
||||
if timeout is not None
|
||||
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
|
||||
),
|
||||
|
||||
@@ -49,7 +49,7 @@ async def _wrap_stream_v2(
|
||||
async for part in raw:
|
||||
v2 = _sse_to_v2_dict(part.event, part.data)
|
||||
if v2 is not None:
|
||||
yield v2
|
||||
yield v2 # ty: ignore[invalid-yield]
|
||||
|
||||
|
||||
class RunsClient:
|
||||
|
||||
@@ -144,8 +144,9 @@ def _resolve_timezone(tz: str | tzinfo | ZoneInfo | None) -> str | None:
|
||||
return tz
|
||||
if isinstance(tz, tzinfo):
|
||||
# ZoneInfo objects have a .key attribute with the IANA name
|
||||
if hasattr(tz, "key"):
|
||||
return tz.key # type: ignore[union-attr]
|
||||
key = getattr(tz, "key", None)
|
||||
if isinstance(key, str):
|
||||
return key
|
||||
# Fall back to tzname for fixed-offset timezones like datetime.timezone.utc
|
||||
name = tz.tzname(None)
|
||||
if name is not None:
|
||||
@@ -209,7 +210,7 @@ def configure_loopback_transports(app: Any) -> None:
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_asgi_transport() -> type[httpx.ASGITransport]:
|
||||
try:
|
||||
from langgraph_api import asgi_transport # type: ignore[unresolved-import]
|
||||
from langgraph_api import asgi_transport # ty: ignore[unresolved-import]
|
||||
|
||||
return asgi_transport.ASGITransport
|
||||
except ImportError:
|
||||
|
||||
@@ -77,7 +77,7 @@ def get_sync_client(
|
||||
base_url=url,
|
||||
transport=transport,
|
||||
timeout=(
|
||||
httpx.Timeout(timeout) # type: ignore[arg-type]
|
||||
httpx.Timeout(timeout) # ty: ignore[invalid-argument-type]
|
||||
if timeout is not None
|
||||
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
|
||||
),
|
||||
|
||||
@@ -49,7 +49,7 @@ def _wrap_stream_v2_sync(
|
||||
for part in raw:
|
||||
v2 = _sse_to_v2_dict(part.event, part.data)
|
||||
if v2 is not None:
|
||||
yield v2
|
||||
yield v2 # ty: ignore[invalid-yield]
|
||||
|
||||
|
||||
class SyncRunsClient:
|
||||
|
||||
@@ -16,10 +16,10 @@ T = TypeVar("T")
|
||||
CacheStatus = Literal["miss", "fresh", "stale", "expired"]
|
||||
|
||||
try:
|
||||
from langgraph_api.cache import ( # type: ignore[unresolved-import]
|
||||
from langgraph_api.cache import ( # ty: ignore[unresolved-import]
|
||||
cache_get as _cache_get,
|
||||
)
|
||||
from langgraph_api.cache import ( # type: ignore[unresolved-import]
|
||||
from langgraph_api.cache import ( # ty: ignore[unresolved-import]
|
||||
cache_set as _cache_set,
|
||||
)
|
||||
except ImportError:
|
||||
@@ -28,8 +28,8 @@ except ImportError:
|
||||
|
||||
|
||||
try:
|
||||
from langgraph_api.cache import SWRResult # type: ignore[unresolved-import]
|
||||
from langgraph_api.cache import swr as _api_swr # type: ignore[unresolved-import]
|
||||
from langgraph_api.cache import SWRResult # ty: ignore[unresolved-import]
|
||||
from langgraph_api.cache import swr as _api_swr # ty: ignore[unresolved-import]
|
||||
|
||||
except ImportError:
|
||||
_api_swr = None
|
||||
@@ -40,7 +40,10 @@ except ImportError:
|
||||
value: T
|
||||
status: CacheStatus
|
||||
|
||||
async def mutate(self, value: T = ...) -> T: # type: ignore[assignment]
|
||||
async def mutate(
|
||||
self,
|
||||
value: T = ..., # ty: ignore[invalid-parameter-default]
|
||||
) -> T: # ty: ignore[empty-body]
|
||||
"""Update or revalidate the cached value."""
|
||||
...
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class APIError(httpx.HTTPStatusError, LangGraphError):
|
||||
req = response_or_request
|
||||
response = None
|
||||
|
||||
httpx.HTTPStatusError.__init__(self, message, request=req, response=response) # type: ignore[arg-type]
|
||||
httpx.HTTPStatusError.__init__(self, message, request=req, response=response) # ty: ignore[invalid-argument-type]
|
||||
LangGraphError.__init__(self, message)
|
||||
|
||||
self.request = req
|
||||
|
||||
@@ -156,7 +156,7 @@ class _ExecutionRuntime(_ServerRuntimeBase[ContextT], Generic[ContextT]):
|
||||
This API is in beta and may change in future releases.
|
||||
"""
|
||||
|
||||
context: ContextT = field(default=None) # type: ignore[assignment]
|
||||
context: ContextT = field(default=None) # ty: ignore[invalid-assignment]
|
||||
"""The graph run context, typed by the graph's `context_schema`.
|
||||
|
||||
Only available during `threads.create_run`.
|
||||
|
||||
@@ -55,7 +55,7 @@ class BytesLineDecoder:
|
||||
# Include any existing buffer in the first portion of the
|
||||
# splitlines result.
|
||||
self.buffer.extend(lines[0])
|
||||
lines = cast(list[BytesLike], [self.buffer, *lines[1:]])
|
||||
lines = [self.buffer, *lines[1:]]
|
||||
self.buffer = bytearray()
|
||||
|
||||
if not trailing_newline:
|
||||
@@ -69,7 +69,7 @@ class BytesLineDecoder:
|
||||
if not self.buffer and not self.trailing_cr:
|
||||
return []
|
||||
|
||||
lines = [self.buffer]
|
||||
lines: list[BytesLike] = [self.buffer]
|
||||
self.buffer = bytearray()
|
||||
self.trailing_cr = False
|
||||
return lines
|
||||
@@ -102,7 +102,7 @@ class SSEDecoder:
|
||||
|
||||
sse = StreamPart(
|
||||
event=self._event,
|
||||
data=orjson.loads(self._data) if self._data else None, # type: ignore[invalid-argument-type]
|
||||
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
|
||||
id=self.last_event_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ lint = [
|
||||
"ruff==0.15.12",
|
||||
"codespell",
|
||||
"mypy==1.20.2",
|
||||
"ty==0.0.23",
|
||||
"ty==0.0.33",
|
||||
"starlette",
|
||||
]
|
||||
dev = [
|
||||
|
||||
@@ -388,7 +388,7 @@ async def test_async_stream_v2_client_side_conversion() -> None:
|
||||
event="values", data={"messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
yield StreamPart(event="updates|sub:abc", data={"node": {"out": 1}})
|
||||
yield StreamPart(event="end", data=None) # type: ignore[arg-type]
|
||||
yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type]
|
||||
|
||||
parts: list[StreamPartV2] = [part async for part in _wrap_stream_v2(mock_stream())]
|
||||
assert len(parts) == 3
|
||||
@@ -420,7 +420,7 @@ def test_sync_stream_v2_client_side_conversion() -> None:
|
||||
def mock_stream() -> Any:
|
||||
yield StreamPart(event="metadata", data={"run_id": "r1"})
|
||||
yield StreamPart(event="values", data={"state": "full"})
|
||||
yield StreamPart(event="end", data=None) # type: ignore[arg-type]
|
||||
yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type]
|
||||
|
||||
parts: list[StreamPartV2] = list(_wrap_stream_v2_sync(mock_stream()))
|
||||
assert len(parts) == 2
|
||||
|
||||
@@ -67,6 +67,6 @@ class TestHandlerValidation:
|
||||
|
||||
with pytest.raises(TypeError, match="must accept exactly 2 parameters"):
|
||||
|
||||
@encryption.encrypt.blob # type: ignore[arg-type]
|
||||
@encryption.encrypt.blob # ty: ignore[invalid-argument-type]
|
||||
async def wrong_params(ctx):
|
||||
return ctx
|
||||
|
||||
Generated
+20
-20
@@ -533,14 +533,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -1275,26 +1275,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.23"
|
||||
version = "0.0.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user