mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24716a2f94 | ||
|
|
9169af196c | ||
|
|
506fc7eaf3 | ||
|
|
80db5a9523 | ||
|
|
7bf325d8a0 | ||
|
|
3e511592fe |
@@ -58,18 +58,14 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Annotation
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.files.outputs.added_modified_renamed }}
|
||||
BENCHMARK_OUTPUT: ${{ steps.benchmark.outputs.OUTPUT }}
|
||||
COMPARE_OUTPUT: ${{ steps.compare.outputs.OUTPUT }}
|
||||
with:
|
||||
script: |
|
||||
const file = JSON.parse(process.env.CHANGED_FILES || "[]")[0]
|
||||
core.notice(process.env.BENCHMARK_OUTPUT || "", {
|
||||
const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
|
||||
core.notice(`${{ steps.benchmark.outputs.OUTPUT }}`, {
|
||||
title: 'Benchmark results',
|
||||
file,
|
||||
})
|
||||
core.notice(process.env.COMPARE_OUTPUT || "", {
|
||||
core.notice(`${{ steps.compare.outputs.OUTPUT }}`, {
|
||||
title: 'Comparison against main',
|
||||
file,
|
||||
})
|
||||
|
||||
@@ -32,7 +32,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
@@ -269,7 +268,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
@@ -311,7 +309,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
|
||||
@@ -63,7 +63,6 @@ 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` |
|
||||
|
||||
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ class Capability(str, Enum):
|
||||
DELETE_FOR_RUNS = "delete_for_runs"
|
||||
COPY_THREAD = "copy_thread"
|
||||
PRUNE = "prune"
|
||||
DELTA_CHANNEL_HISTORY = "delta_channel_history"
|
||||
|
||||
|
||||
# Capabilities that every checkpointer must support.
|
||||
@@ -43,7 +42,6 @@ EXTENDED_CAPABILITIES = frozenset(
|
||||
Capability.DELETE_FOR_RUNS,
|
||||
Capability.COPY_THREAD,
|
||||
Capability.PRUNE,
|
||||
Capability.DELTA_CHANNEL_HISTORY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,7 +57,6 @@ _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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ 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_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
|
||||
@@ -27,5 +24,4 @@ __all__ = [
|
||||
"run_delete_for_runs_tests",
|
||||
"run_copy_thread_tests",
|
||||
"run_prune_tests",
|
||||
"run_delta_channel_history_tests",
|
||||
]
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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
@@ -1,247 +0,0 @@
|
||||
"""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
|
||||
@@ -19,9 +19,6 @@ 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_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
|
||||
@@ -38,7 +35,6 @@ _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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,11 +43,7 @@ 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"
|
||||
|
||||
@@ -62,9 +58,6 @@ 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
+504
-605
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 AS _kind, NULL::text AS checkpoint_id, channel, "
|
||||
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
|
||||
"SELECT 'b'::text, NULL, channel, "
|
||||
"type, blob, NULL, NULL, version "
|
||||
"FROM checkpoint_blobs "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND version = %s"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a4"
|
||||
description = "Library with a Postgres 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>=4.1.0,<5.0.0",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
|
||||
Generated
+14
-14
@@ -205,11 +205,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -244,7 +244,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -257,9 +257,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -276,7 +276,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -324,7 +324,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -399,7 +399,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -412,9 +412,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1234,11 +1234,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
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>=4.1.0,<5.0.0",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlite-vec>=0.1.6",
|
||||
]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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",
|
||||
},
|
||||
)
|
||||
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}")
|
||||
Generated
+14
-27
@@ -214,11 +214,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -253,11 +253,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -266,26 +265,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -333,7 +320,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -402,7 +389,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -415,9 +402,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1161,11 +1148,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -60,29 +60,15 @@ class CheckpointMetadata(TypedDict, total=False):
|
||||
"""
|
||||
run_id: str
|
||||
"""The ID of the run that created this checkpoint."""
|
||||
counters_since_delta_snapshot: dict[str, tuple[int, int]]
|
||||
"""Per-channel counters since the last `_DeltaSnapshot` was written.
|
||||
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 -> `(updates, supersteps)`:
|
||||
|
||||
- index 0 (`updates`): number of supersteps that wrote to this channel
|
||||
since its last snapshot blob.
|
||||
- index 1 (`supersteps`): total supersteps elapsed since this channel's
|
||||
last snapshot, regardless of whether the channel was written.
|
||||
|
||||
A snapshot fires when EITHER `updates >= ch.snapshot_frequency` OR
|
||||
`supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` (system-wide bound,
|
||||
default 5000, env `LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`).
|
||||
The supersteps bound prevents unbounded ancestor walks on threads where
|
||||
a delta channel exists but is no longer being updated.
|
||||
|
||||
Absent on threads that don't use delta channels. Persisted as a
|
||||
2-element list in JSON (no native tuple).
|
||||
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
|
||||
channel's `snapshot_frequency`, snapshot fires and the count resets
|
||||
to 0). Absent on threads that don't use delta channels. Version-format
|
||||
independent — works for int, float, and string version schemes.
|
||||
"""
|
||||
|
||||
|
||||
@@ -149,11 +135,6 @@ 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:
|
||||
|
||||
@@ -336,14 +317,6 @@ 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
|
||||
|
||||
@@ -357,17 +330,6 @@ 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
|
||||
|
||||
@@ -383,34 +345,6 @@ 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
|
||||
|
||||
@@ -527,13 +461,6 @@ 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
|
||||
|
||||
@@ -547,13 +474,6 @@ 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
|
||||
|
||||
@@ -569,13 +489,6 @@ 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
|
||||
|
||||
@@ -584,14 +497,6 @@ 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
|
||||
@@ -651,13 +556,7 @@ 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`.
|
||||
|
||||
!!! warning "Beta"
|
||||
|
||||
This method is part of the `DeltaChannel` support surface and is
|
||||
in beta. See `get_delta_channel_history` for caveats.
|
||||
"""
|
||||
"""Async version of `get_delta_channel_history`."""
|
||||
if not channels:
|
||||
return {}
|
||||
collected_by_ch: dict[str, list[PendingWrite]] = {c: [] for c in channels}
|
||||
|
||||
@@ -44,7 +44,7 @@ if TYPE_CHECKING:
|
||||
AllowedMsgpackModules,
|
||||
)
|
||||
|
||||
LC_REVIVER = Reviver(allowed_objects="core")
|
||||
LC_REVIVER = Reviver()
|
||||
EMPTY_BYTES = b""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,6 +157,27 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
)
|
||||
return clone
|
||||
|
||||
def _encode_constructor_args(
|
||||
self,
|
||||
constructor: Callable | type[Any],
|
||||
*,
|
||||
method: None | str | Sequence[None | str] = None,
|
||||
args: Sequence[Any] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
out = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": (*constructor.__module__.split("."), constructor.__name__),
|
||||
}
|
||||
if method is not None:
|
||||
out["method"] = method
|
||||
if args is not None:
|
||||
out["args"] = args
|
||||
if kwargs is not None:
|
||||
out["kwargs"] = kwargs
|
||||
return out
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if (
|
||||
value.get("lc", None) == 2
|
||||
@@ -182,46 +203,45 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
self._check_allowed_json_modules(value)
|
||||
|
||||
[*module, name] = value["id"]
|
||||
# The `method` field on lc:2 envelopes is intentionally ignored.
|
||||
# Revival is restricted to the default constructor (no `getattr`
|
||||
# dispatch on attacker-influenced names). The framework's own
|
||||
# encoder has not emitted `method=` since the msgpack migration,
|
||||
# and the only legacy emission was `method=(None, "construct")`
|
||||
# for pydantic models, where the first entry (`None`) already
|
||||
# meant "default constructor", which is what we do here.
|
||||
try:
|
||||
mod = importlib.import_module(".".join(module))
|
||||
cls = getattr(mod, name)
|
||||
if isclass(cls) and issubclass(cls, BaseException):
|
||||
return None
|
||||
method = value.get("method")
|
||||
if isinstance(method, str):
|
||||
methods = [getattr(cls, method)]
|
||||
elif isinstance(method, list):
|
||||
methods = [cls if m is None else getattr(cls, m) for m in method]
|
||||
else:
|
||||
methods = [cls]
|
||||
args = value.get("args")
|
||||
kwargs = value.get("kwargs")
|
||||
if args and kwargs:
|
||||
return cls(*args, **kwargs)
|
||||
elif args:
|
||||
return cls(*args)
|
||||
elif kwargs:
|
||||
return cls(**kwargs)
|
||||
else:
|
||||
return cls()
|
||||
except Exception as exc:
|
||||
# Method-field dispatch has been removed (GHSA-fjqc-hq36-qh5p), so
|
||||
# legacy pydantic payloads emitting `method=[None, "construct"]`
|
||||
# no longer fall back to `cls.construct(**kwargs)` when the
|
||||
# default constructor rejects the serialized kwargs. Surface a
|
||||
# one-line warning so operators can spot payloads that now revive
|
||||
# to `None` instead of silently degrading to the raw envelope.
|
||||
logger.warning(
|
||||
"Failed to revive lc:2 envelope %s "
|
||||
"(legacy_method_field=%s, error=%s); returning None",
|
||||
".".join((*module, name)),
|
||||
"method" in value,
|
||||
type(exc).__name__,
|
||||
)
|
||||
for method in methods:
|
||||
try:
|
||||
if isclass(method) and issubclass(method, BaseException):
|
||||
return None
|
||||
if args and kwargs:
|
||||
return method(*args, **kwargs)
|
||||
elif args:
|
||||
return method(*args)
|
||||
elif kwargs:
|
||||
return method(**kwargs)
|
||||
else:
|
||||
return method()
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
|
||||
needed = tuple(value["id"])
|
||||
method = value.get("method")
|
||||
if isinstance(method, list):
|
||||
method_display = ",".join(m or "<init>" for m in method)
|
||||
elif isinstance(method, str):
|
||||
method_display = method
|
||||
else:
|
||||
method_display = "<init>"
|
||||
|
||||
dotted = ".".join(needed)
|
||||
# Safe types (the same set already allowed for msgpack deserialization) are
|
||||
# permitted without an explicit allowlist — they are known-safe LangGraph and
|
||||
@@ -232,7 +252,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
|
||||
if not self._allowed_json_modules:
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted}. "
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
"No allowed_json_modules configured.\n\n"
|
||||
"Unblock with ONE of:\n"
|
||||
f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
|
||||
@@ -247,7 +267,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return
|
||||
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted}. "
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
"Symbol is not in the deserialization allowlist.\n\n"
|
||||
"Add exactly this symbol to unblock:\n"
|
||||
f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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",
|
||||
},
|
||||
)
|
||||
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}")
|
||||
@@ -398,193 +398,6 @@ def test_deserde_invalid_module() -> None:
|
||||
serde.loads_typed(("json", json.dumps(load).encode("utf-8")))
|
||||
|
||||
|
||||
def test_lc2_json_method_field_is_ignored() -> None:
|
||||
"""The `method` field on lc=2 envelopes is ignored.
|
||||
|
||||
Regression test for GHSA-fjqc-hq36-qh5p: `_revive_lc2` previously resolved
|
||||
`getattr(cls, method)` from the envelope, which let an attacker pivot a
|
||||
safe pydantic class (e.g., AIMessage) to `parse_raw(..., allow_pickle=True)`
|
||||
and reach `pickle.loads`. Revival now uses only the default constructor.
|
||||
|
||||
Verifies that an envelope carrying ``method="parse_raw"`` does not dispatch
|
||||
to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
|
||||
produce, which proves the default constructor ran instead of ``parse_raw``.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": ["default-ctor-ran"],
|
||||
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
# Default constructor accepts `content` as first positional arg. If parse_raw
|
||||
# had been invoked instead, it would have attempted JSON/pickle parsing and
|
||||
# raised (or executed the pickle gadget); neither would produce this result.
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "default-ctor-ran"
|
||||
|
||||
|
||||
def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
|
||||
"""The `method` field is ignored even when the class is explicitly allowlisted.
|
||||
|
||||
A user who configures ``allowed_json_modules`` for a class no longer gets
|
||||
method dispatch as a side effect. Revival is restricted to the default
|
||||
constructor regardless of how the class reached the revival path.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
|
||||
)
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": ["default-ctor-ran"],
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "default-ctor-ran"
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_init_still_works() -> None:
|
||||
"""SAFE-type lc=2 revival without a `method` field still constructs the class."""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"kwargs": {"content": "hi", "type": "ai"},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "hi"
|
||||
|
||||
|
||||
def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
|
||||
"""Legacy ``method=(None, "construct")`` envelopes still revive via the default ctor.
|
||||
|
||||
Pre-October-2025 langgraph emitted pydantic models with
|
||||
``method=(None, "construct")`` meaning "try default constructor, fall back
|
||||
to pydantic ``construct``". The first entry (``None``) was always the
|
||||
default constructor, which is what we now do unconditionally. Envelopes of
|
||||
this shape continue to revive correctly as long as the default constructor
|
||||
accepts the serialized kwargs.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": [None, "construct"],
|
||||
"kwargs": {"content": "legacy", "type": "ai"},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "legacy"
|
||||
|
||||
|
||||
def test_lc2_json_legacy_construct_payload_logs_warning_when_default_init_rejects(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Legacy `method=[None, "construct"]` envelopes whose kwargs the default
|
||||
`__init__` rejects now revive to `None` and emit an observable warning.
|
||||
|
||||
Pre-October-2025 langgraph emitted pydantic payloads with
|
||||
`method=[None, "construct"]` so the reviver could fall back to
|
||||
`cls.construct(**kwargs)` when the default constructor raised a
|
||||
validation error. That fallback was removed with method-field dispatch
|
||||
(GHSA-fjqc-hq36-qh5p), so these payloads now silently fail validation. A
|
||||
`logger.warning` makes the regression observable to operators instead of
|
||||
letting the envelope quietly degrade to its raw-dict form.
|
||||
"""
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
# Legacy two-entry method tuple: try default ctor, fall back to construct.
|
||||
"method": [None, "construct"],
|
||||
# ``type="not-a-real-message-type"`` fails AIMessage's Literal["ai"]
|
||||
# validator under the default constructor. Before the GHSA patch this
|
||||
# would have fallen back to ``cls.construct(**kwargs)``; now it must
|
||||
# return None and log.
|
||||
"kwargs": {"content": "legacy", "type": "not-a-real-message-type"},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus"):
|
||||
result = serde._revive_lc2(load)
|
||||
|
||||
assert result is None, (
|
||||
"Legacy method=[None, 'construct'] payloads with kwargs the default "
|
||||
"ctor rejects must now return None (no construct() fallback)."
|
||||
)
|
||||
|
||||
matching = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.name == "langgraph.checkpoint.serde.jsonplus"
|
||||
and r.levelno == logging.WARNING
|
||||
and "langchain_core.messages.ai.AIMessage" in r.getMessage()
|
||||
and "legacy_method_field=True" in r.getMessage()
|
||||
]
|
||||
assert matching, (
|
||||
"Expected a WARNING from langgraph.checkpoint.serde.jsonplus "
|
||||
"referencing the class id and legacy_method_field=True; "
|
||||
f"got records: {[(r.name, r.levelname, r.getMessage()) for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
|
||||
"""End-to-end: a `parse_raw` pickle gadget payload on a SAFE type must not run.
|
||||
|
||||
With method dispatch removed from `_revive_lc2`, the gadget bytes are never
|
||||
passed to `parse_raw` and therefore never reach `pickle.loads`.
|
||||
"""
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
|
||||
marker = tempfile.NamedTemporaryFile(
|
||||
prefix="lc2_block_proof_", suffix=".out", delete=False
|
||||
).name
|
||||
os.remove(marker) # ensure absent before the test runs
|
||||
|
||||
class _Gadget:
|
||||
def __reduce__(self) -> tuple:
|
||||
return (os.system, (f"touch {marker}",))
|
||||
|
||||
gadget_bytes = pickle.dumps(_Gadget(), protocol=0).decode("latin1")
|
||||
envelope = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": [gadget_bytes],
|
||||
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
|
||||
}
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
try:
|
||||
serde.loads_typed(("json", json.dumps(envelope).encode()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert not os.path.exists(marker), (
|
||||
"Pickle gadget executed via parse_raw on AIMessage lc=2 envelope"
|
||||
)
|
||||
|
||||
|
||||
def test_serde_jsonplus_bytearray() -> None:
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
|
||||
Generated
+13
-13
@@ -229,11 +229,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -281,9 +281,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -300,7 +300,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -383,7 +383,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -396,9 +396,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1463,11 +1463,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3675,11 +3675,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190"
|
||||
integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
leven@^3.1.0:
|
||||
version "3.1.0"
|
||||
@@ -4847,7 +4848,7 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^10.0.0:
|
||||
uuid@10.0.0, uuid@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.9.14",
|
||||
"turbo": "^2.9.7",
|
||||
"typescript": "^5.9.3",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
|
||||
@@ -166,35 +166,35 @@
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
|
||||
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
|
||||
|
||||
"@turbo/darwin-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.14.tgz#b9ec6ac637b9c5fdba5dae9d743f5d121f091242"
|
||||
integrity sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==
|
||||
"@turbo/darwin-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.7.tgz#46fddae01ea7817192dc6ff36a678cc3879b753b"
|
||||
integrity sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==
|
||||
|
||||
"@turbo/darwin-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.14.tgz#99d19f3e59842c595d2828c72a2e4ef537408f38"
|
||||
integrity sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==
|
||||
"@turbo/darwin-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.7.tgz#cd7cf3a024509af0f59ae3885216d62fd090fdb6"
|
||||
integrity sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA==
|
||||
|
||||
"@turbo/linux-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.14.tgz#9c907434f091cd75529f5496516f79b71935d2bb"
|
||||
integrity sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==
|
||||
"@turbo/linux-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.7.tgz#25ee9a2cb3042498a3203e14653b14041105020f"
|
||||
integrity sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA==
|
||||
|
||||
"@turbo/linux-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.14.tgz#79ae9060e6ee9e0fb784ae0747e980f582e75313"
|
||||
integrity sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==
|
||||
"@turbo/linux-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.7.tgz#5b19d6249a50cb8153b8f2bc22ad10bbda614fbe"
|
||||
integrity sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg==
|
||||
|
||||
"@turbo/windows-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.14.tgz#68a80f299f35189314184c88301caad982d1c0c2"
|
||||
integrity sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==
|
||||
"@turbo/windows-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.7.tgz#900645776fa44ff8333e801d890992736f6dbc72"
|
||||
integrity sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA==
|
||||
|
||||
"@turbo/windows-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.14.tgz#4dc16684f0ddce53fafe56ad8f55205c4473d17a"
|
||||
integrity sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==
|
||||
"@turbo/windows-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.7.tgz#e678d73fdbcbb12a679403fe7ba070fb67f37810"
|
||||
integrity sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
@@ -1327,11 +1327,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971"
|
||||
integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg==
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
@@ -1813,17 +1814,17 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo@^2.9.14:
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.14.tgz#d412fcc4c9bd8dba29cec5bbd54d5a74ab2b1bee"
|
||||
integrity sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==
|
||||
turbo@^2.9.7:
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.7.tgz#da6d5a821f0bde5174e1a55af6690dad5c2fb004"
|
||||
integrity sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==
|
||||
optionalDependencies:
|
||||
"@turbo/darwin-64" "2.9.14"
|
||||
"@turbo/darwin-arm64" "2.9.14"
|
||||
"@turbo/linux-64" "2.9.14"
|
||||
"@turbo/linux-arm64" "2.9.14"
|
||||
"@turbo/windows-64" "2.9.14"
|
||||
"@turbo/windows-arm64" "2.9.14"
|
||||
"@turbo/darwin-64" "2.9.7"
|
||||
"@turbo/darwin-arm64" "2.9.7"
|
||||
"@turbo/linux-64" "2.9.7"
|
||||
"@turbo/linux-arm64" "2.9.7"
|
||||
"@turbo/windows-64" "2.9.7"
|
||||
"@turbo/windows-arm64" "2.9.7"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -1899,7 +1900,7 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^10.0.0:
|
||||
uuid@10.0.0, uuid@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.26"
|
||||
__version__ = "0.4.24"
|
||||
|
||||
@@ -35,12 +35,6 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
|
||||
# This blocks background execution (cmd &) while allowing command
|
||||
# chaining (cmd1 && cmd2) which is common in build commands.
|
||||
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
||||
_API_VERSION_PATTERN = re.compile(
|
||||
r"^(?P<major>\d+)"
|
||||
r"(?:\.(?P<minor>\d+))?"
|
||||
r"(?:\.(?P<patch>\d+))?"
|
||||
r"(?:(?:\.|)(?:[A-Za-z][0-9A-Za-z]*))?$"
|
||||
)
|
||||
|
||||
|
||||
def has_disallowed_build_command_content(command: str) -> bool:
|
||||
@@ -129,18 +123,6 @@ def _parse_node_version(version_str: str) -> int:
|
||||
) from None
|
||||
|
||||
|
||||
def _parse_api_version_parts(version_str: str) -> tuple[int, ...]:
|
||||
"""Parse an API version into numeric components.
|
||||
|
||||
Supports optional prerelease suffixes, e.g. `0.9.0rc1`.
|
||||
"""
|
||||
version_core = version_str.split("-", 1)[0]
|
||||
match = _API_VERSION_PATTERN.fullmatch(version_core)
|
||||
if not match:
|
||||
raise ValueError("Version must be major or major.minor or major.minor.patch.")
|
||||
return tuple(int(part) for part in match.groups() if part is not None)
|
||||
|
||||
|
||||
def _is_node_graph(spec: str | dict) -> bool:
|
||||
"""Check if a graph is a Node.js graph based on the file extension."""
|
||||
if isinstance(spec, dict):
|
||||
@@ -194,12 +176,12 @@ def validate_config(config: Config) -> Config:
|
||||
)
|
||||
if api_version:
|
||||
try:
|
||||
parts = _parse_api_version_parts(api_version)
|
||||
parts = tuple(map(int, api_version.split("-")[0].split(".")))
|
||||
if len(parts) > 3:
|
||||
raise ValueError(
|
||||
"Version must be major or major.minor or major.minor.patch."
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError:
|
||||
raise click.UsageError(
|
||||
f"Invalid version format: {api_version}.\n\n"
|
||||
"Pin to a minor version, e.g.:\n"
|
||||
|
||||
@@ -2944,23 +2944,6 @@ def test_docker_tag_with_api_version(in_config: bool):
|
||||
assert tag == f"langchain/langgraph-server:{version}-py3.11"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("in_config", [False, True])
|
||||
@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
|
||||
def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
|
||||
"""Test docker_tag with prerelease and dev api_version values."""
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"api_version": version if in_config else None,
|
||||
}
|
||||
)
|
||||
|
||||
tag = docker_tag(config, api_version=version if not in_config else None)
|
||||
assert tag == f"langchain/langgraph-api:{version}-py3.11"
|
||||
|
||||
|
||||
def test_config_to_docker_with_api_version():
|
||||
"""Test config_to_docker function with api_version parameter."""
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "uv workspace monorepo example for LangGraph CLI integration test"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"langgraph>=0.6.0,<2",
|
||||
"langchain-core>=1.3.3",
|
||||
"langchain-core>=0.2.14",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
Generated
+10
-23
@@ -215,11 +215,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -228,21 +227,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -303,7 +290,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -316,9 +303,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -677,11 +664,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -724,7 +711,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-core", specifier = ">=0.2.14" },
|
||||
{ name = "langgraph", specifier = ">=0.6.0,<2" },
|
||||
]
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Simple single-package uv example for LangGraph CLI integration te
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"langgraph>=0.6.0,<2",
|
||||
"langchain-core>=1.3.3",
|
||||
"langchain-core>=0.2.14",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
Generated
+10
-23
@@ -191,11 +191,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -204,21 +203,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -279,7 +266,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -292,9 +279,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -627,7 +614,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-core", specifier = ">=0.2.14" },
|
||||
{ name = "langgraph", specifier = ">=0.6.0,<2" },
|
||||
]
|
||||
|
||||
@@ -663,11 +650,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+12
-25
@@ -797,11 +797,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -946,11 +946,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-protocol", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
|
||||
@@ -959,21 +958,9 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "uuid-utils", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ae/8b74458fc3850ec3d150eb9f45e857db129dafa801fb5cf173dfc9f8bbf3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182", size = 915041, upload-time = "2026-05-05T19:02:36.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/4771b7ab2af1d1aba5b710bd8f13d9225c609425214b357590a17b01be77/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1", size = 543857, upload-time = "2026-05-05T19:02:34.52Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,7 +1161,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "python_full_version >= '3.11'" },
|
||||
@@ -1187,9 +1174,9 @@ dependencies = [
|
||||
{ name = "xxhash", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "zstandard", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2320,11 +2307,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -29,9 +29,6 @@ from langgraph._internal._constants import (
|
||||
)
|
||||
|
||||
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
|
||||
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT = int(
|
||||
getenv("LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", "5000")
|
||||
)
|
||||
|
||||
|
||||
def recast_checkpoint_ns(ns: str) -> str:
|
||||
|
||||
@@ -26,15 +26,6 @@ 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 `counters_since_delta_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`.
|
||||
|
||||
@@ -47,12 +38,9 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
|
||||
This lets LangGraph replay checkpointed writes in larger batches than they
|
||||
were originally produced without changing reconstructed state.
|
||||
|
||||
Snapshot cadence is driven by two counters: per-channel update count and
|
||||
total supersteps since last snapshot. `create_checkpoint` writes a full
|
||||
`_DeltaSnapshot` blob when EITHER the update count reaches
|
||||
`snapshot_frequency` OR the supersteps count reaches the system-wide
|
||||
`DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT` bound (default 5000), bounding
|
||||
replay depth even for channels that stop receiving writes.
|
||||
Snapshot cadence is driven by per-channel update count. `create_checkpoint`
|
||||
writes a full `_DeltaSnapshot` blob every `snapshot_frequency` updates to
|
||||
this channel, bounding replay depth.
|
||||
|
||||
Parameters:
|
||||
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
|
||||
|
||||
@@ -6,7 +6,7 @@ import typing
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable, Hashable, Sequence
|
||||
from dataclasses import dataclass, is_dataclass
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isclass, isfunction, ismethod, signature
|
||||
@@ -95,17 +95,6 @@ __all__ = ("StateGraph", "CompiledStateGraph")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHANNEL_BRANCH_TO = "branch:to:{}"
|
||||
_DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _NodeDefaults:
|
||||
"""Default node policies applied to every node at compile time."""
|
||||
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None
|
||||
cache_policy: CachePolicy | None = None
|
||||
error_handler: StateNode[Any, Any] | None = None
|
||||
timeout: TimeoutPolicy | None = None
|
||||
|
||||
|
||||
def _warn_invalid_state_schema(schema: type[Any] | Any) -> None:
|
||||
@@ -262,77 +251,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
self.output_schema = cast(type[OutputT], output_schema or state_schema)
|
||||
self.context_schema = context_schema
|
||||
|
||||
self._node_defaults: _NodeDefaults = _NodeDefaults()
|
||||
|
||||
self._add_schema(self.state_schema)
|
||||
self._add_schema(self.input_schema, allow_managed=False)
|
||||
self._add_schema(self.output_schema, allow_managed=False)
|
||||
|
||||
def set_node_defaults(
|
||||
self,
|
||||
*,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
) -> Self:
|
||||
"""Set default node policies that apply to every node in this graph.
|
||||
|
||||
Per-node values passed to `add_node` always take precedence over these
|
||||
defaults. Defaults are applied at `compile()` time. Policies set here
|
||||
are **not** inherited by subgraphs.
|
||||
|
||||
`retry_policy` and `timeout` defaults apply to **all** nodes,
|
||||
including error-handler nodes. `cache_policy` and `error_handler`
|
||||
defaults only apply to regular nodes -- caching error-handler results
|
||||
is unsafe, and handlers must never catch themselves.
|
||||
|
||||
Args:
|
||||
retry_policy: Default retry policy for nodes that don't specify
|
||||
their own via `add_node(..., retry_policy=...)`. Also applies
|
||||
to error-handler nodes.
|
||||
cache_policy: Default cache policy for nodes that don't specify
|
||||
their own via `add_node(..., cache_policy=...)`. Does **not**
|
||||
apply to error-handler nodes.
|
||||
error_handler: Default error handler invoked when any regular node
|
||||
raises and does not have its own `error_handler` set via
|
||||
`add_node`. The handler is **not** invoked when an
|
||||
error-handler node itself raises -- handler failures fail the
|
||||
run.
|
||||
timeout: Default timeout policy for nodes that don't specify their
|
||||
own via `add_node(..., timeout=...)`. Also applies to
|
||||
error-handler nodes. Accepts a `TimeoutPolicy`, a number of
|
||||
seconds (`float`), or a `timedelta`.
|
||||
|
||||
Returns:
|
||||
Self: The builder instance, for chaining.
|
||||
|
||||
Example:
|
||||
```python
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(
|
||||
retry_policy=RetryPolicy(max_attempts=3),
|
||||
error_handler=my_fallback_handler,
|
||||
)
|
||||
.add_node("a", node_a)
|
||||
.add_node("b", node_b, retry_policy=custom_retry) # overrides default
|
||||
.add_edge(START, "a")
|
||||
.compile()
|
||||
)
|
||||
```
|
||||
"""
|
||||
defaults = self._node_defaults
|
||||
if retry_policy is not None:
|
||||
defaults.retry_policy = retry_policy
|
||||
if cache_policy is not None:
|
||||
defaults.cache_policy = cache_policy
|
||||
if error_handler is not None:
|
||||
defaults.error_handler = error_handler
|
||||
if timeout is not None:
|
||||
defaults.timeout = coerce_timeout_policy(timeout)
|
||||
return self
|
||||
|
||||
@property
|
||||
def _all_edges(self) -> set[tuple[str, str]]:
|
||||
return self.edges | {
|
||||
@@ -1271,63 +1193,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
key for key, val in self.channels.items() if not is_managed_value(val)
|
||||
]
|
||||
)
|
||||
# Apply builder defaults to node specs. Per-node values always win.
|
||||
# Error-handler routing and cache_policy are only assigned to regular
|
||||
# nodes. Retry and timeout defaults also apply to error-handler nodes.
|
||||
defaults = self._node_defaults
|
||||
default_handler_name: str | None = None
|
||||
if defaults.error_handler is not None:
|
||||
if _DEFAULT_ERROR_HANDLER_NODE in self.nodes:
|
||||
raise ValueError(
|
||||
f"Auto-generated default error handler node "
|
||||
f"`{_DEFAULT_ERROR_HANDLER_NODE}` already exists."
|
||||
)
|
||||
default_handler_name = _DEFAULT_ERROR_HANDLER_NODE
|
||||
self.nodes[default_handler_name] = StateNodeSpec[Any, ContextT](
|
||||
coerce_to_runnable(
|
||||
defaults.error_handler, # type: ignore[arg-type]
|
||||
name=default_handler_name,
|
||||
trace=False,
|
||||
),
|
||||
metadata=None,
|
||||
input_schema=self.state_schema,
|
||||
retry_policy=None,
|
||||
cache_policy=None,
|
||||
is_error_handler=True,
|
||||
)
|
||||
|
||||
# Apply builder defaults to node specs. Per-node values always win.
|
||||
for spec in self.nodes.values():
|
||||
# error_handler: regular nodes only — handlers must never
|
||||
# catch themselves or other handlers.
|
||||
if (
|
||||
not spec.is_error_handler
|
||||
and default_handler_name is not None
|
||||
and spec.error_handler_node is None
|
||||
):
|
||||
spec.error_handler_node = default_handler_name
|
||||
# retry: all nodes — handlers should be retried on transient
|
||||
# failures just like regular nodes.
|
||||
if defaults.retry_policy is not None and spec.retry_policy is None:
|
||||
spec.retry_policy = defaults.retry_policy
|
||||
# cache: regular nodes only — caching an error-handler result
|
||||
# is unsafe because the input (failed-node state) may differ
|
||||
# across failures even when the cache key matches.
|
||||
if (
|
||||
not spec.is_error_handler
|
||||
and defaults.cache_policy is not None
|
||||
and spec.cache_policy is None
|
||||
):
|
||||
spec.cache_policy = defaults.cache_policy
|
||||
# timeout: all nodes — a stuck handler should be cancelled the
|
||||
# same way a stuck regular node would be.
|
||||
if defaults.timeout is not None and spec.timeout is None:
|
||||
spec.timeout = defaults.timeout
|
||||
|
||||
node_error_handler_map = {
|
||||
node_name: spec.error_handler_node
|
||||
for node_name, spec in self.nodes.items()
|
||||
if not spec.is_error_handler and spec.error_handler_node is not None
|
||||
if spec.error_handler_node is not None
|
||||
}
|
||||
|
||||
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
|
||||
|
||||
@@ -8,11 +8,11 @@ from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph._internal._config import DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
@@ -36,26 +36,21 @@ def empty_checkpoint() -> Checkpoint:
|
||||
|
||||
def delta_channels_to_snapshot(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
counters_since_delta_snapshot: Mapping[str, tuple[int, int]],
|
||||
counts: Mapping[str, int],
|
||||
) -> set[str]:
|
||||
"""Return the set of DeltaChannel names that should snapshot now.
|
||||
|
||||
A channel snapshots when EITHER its accumulated update count reaches
|
||||
`snapshot_frequency` OR the total supersteps since its last snapshot
|
||||
reaches `DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT`. This is a pure
|
||||
A channel snapshots when its accumulated update count (since the last
|
||||
snapshot) reaches or exceeds `snapshot_frequency`. This is a pure
|
||||
predicate — no mutation.
|
||||
"""
|
||||
result: set[str] = set()
|
||||
for name, ch in channels.items():
|
||||
if not isinstance(ch, DeltaChannel) or not ch.is_available():
|
||||
continue
|
||||
updates, supersteps = counters_since_delta_snapshot.get(name, (0, 0))
|
||||
if (
|
||||
updates >= ch.snapshot_frequency
|
||||
or supersteps >= DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT
|
||||
):
|
||||
result.add(name)
|
||||
return result
|
||||
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(
|
||||
@@ -74,7 +69,7 @@ def create_checkpoint(
|
||||
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, counters)`; defaults to empty
|
||||
`delta_channels_to_snapshot(channels, counts)`; defaults to empty
|
||||
(no snapshots) when not provided.
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
@@ -236,3 +231,17 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
|
||||
updated_channels=checkpoint.get("updated_channels", None),
|
||||
)
|
||||
|
||||
|
||||
def read_delta_updates_since_snapshot(
|
||||
metadata: CheckpointMetadata | None,
|
||||
) -> dict[str, int]:
|
||||
"""Read the per-channel update counter from checkpoint metadata.
|
||||
|
||||
Returns an empty dict for missing/None metadata; the dict is
|
||||
`total=False` on `CheckpointMetadata`, so absence means "no prior
|
||||
delta-channel activity tracked."
|
||||
"""
|
||||
if not metadata:
|
||||
return {}
|
||||
return dict(metadata.get("delta_updates_since_snapshot", {}) or {})
|
||||
|
||||
@@ -203,12 +203,6 @@ class PregelLoop:
|
||||
# `__enter__`; stays `None` only when no checkpointer.
|
||||
_delta_write_futs: list[Any] | None = None
|
||||
|
||||
# Same pattern as `_delta_write_futs` but for error-handler writes.
|
||||
# When `put_writes` persists an ERROR_SOURCE_NODE marker, the future is
|
||||
# appended here. `schedule_error_handler` / `aschedule_error_handler`
|
||||
# drain this list so the write is durable before the handler starts.
|
||||
_error_handler_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
|
||||
@@ -480,13 +474,6 @@ class PregelLoop:
|
||||
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
|
||||
):
|
||||
self._delta_write_futs.append(fut)
|
||||
# ERROR_SOURCE_NODE is only appended by commit() when the task
|
||||
# has an error handler (_should_route_to_error_handler), so this
|
||||
# check naturally limits future collection to those tasks.
|
||||
if self._error_handler_write_futs is not None and any(
|
||||
c == ERROR_SOURCE_NODE for c, _ in writes
|
||||
):
|
||||
self._error_handler_write_futs.append(fut)
|
||||
# output writes
|
||||
if hasattr(self, "tasks"):
|
||||
self.output_writes(task_id, writes)
|
||||
@@ -566,7 +553,7 @@ class PregelLoop:
|
||||
self.tasks[pushed.id] = pushed
|
||||
# match any pending writes to the new task
|
||||
if not self.is_replaying:
|
||||
self._reapply_writes_to_succeeded_nodes({pushed.id: pushed})
|
||||
self._match_writes({pushed.id: pushed})
|
||||
# return the new task, to be started if not run before
|
||||
return pushed
|
||||
|
||||
@@ -644,8 +631,7 @@ class PregelLoop:
|
||||
|
||||
# if there are pending writes from a previous loop, apply them
|
||||
if not self.is_replaying and self.checkpoint_pending_writes:
|
||||
self._reapply_writes_to_succeeded_nodes(self.tasks)
|
||||
self._resume_error_handlers_if_applicable()
|
||||
self._match_writes(self.tasks)
|
||||
|
||||
# before execution, check if we should interrupt
|
||||
if self.interrupt_before and should_interrupt(
|
||||
@@ -712,88 +698,13 @@ class PregelLoop:
|
||||
|
||||
# private
|
||||
|
||||
def _reapply_writes_to_succeeded_nodes(
|
||||
self, tasks: Mapping[str, PregelExecutableTask]
|
||||
) -> None:
|
||||
"""Restore successful channel writes from checkpoint to in-memory tasks.
|
||||
|
||||
Skips control signals (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME)
|
||||
so that failed/interrupted tasks remain with empty writes and will be
|
||||
re-executed (or routed to error handlers) by the runner.
|
||||
"""
|
||||
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
|
||||
for tid, k, v in self.checkpoint_pending_writes:
|
||||
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
|
||||
continue
|
||||
if task := tasks.get(tid):
|
||||
task.writes.append((k, v))
|
||||
|
||||
def _resume_error_handlers_if_applicable(self) -> None:
|
||||
"""On resume, schedule error handlers for tasks that failed in a prior run.
|
||||
|
||||
Called right after ``_reapply_writes_to_succeeded_nodes`` during ``tick()``.
|
||||
At that point, ``_reapply_writes_to_succeeded_nodes`` has already skipped
|
||||
ERROR / ERROR_SOURCE_NODE writes, so a previously-failed task still has
|
||||
empty ``writes``. Without intervention the runner (which executes only
|
||||
tasks where ``not t.writes``) would re-run the original node.
|
||||
|
||||
This method prevents that re-execution for nodes that have an error
|
||||
handler:
|
||||
|
||||
1. Scan ``checkpoint_pending_writes`` for ERROR_SOURCE_NODE markers
|
||||
persisted by a prior ``commit()``. Each marker means "this task
|
||||
already failed and was routed to an error handler".
|
||||
2. For each such task, write ``(ERROR, error)`` into ``task.writes``
|
||||
so the task is no longer empty — the runner will skip it.
|
||||
3. Prepare a fresh error-handler task and add it to ``self.tasks``.
|
||||
Because the handler task starts with empty ``writes``, the runner
|
||||
will pick it up and execute it.
|
||||
"""
|
||||
# Phase 1: collect task-ids that have ERROR_SOURCE_NODE + ERROR pairs.
|
||||
failed: dict[str, BaseException] = {}
|
||||
for tid, chan, val in self.checkpoint_pending_writes:
|
||||
if chan == ERROR_SOURCE_NODE:
|
||||
error = next(
|
||||
(
|
||||
v
|
||||
for t, c, v in self.checkpoint_pending_writes
|
||||
if t == tid and c == ERROR
|
||||
),
|
||||
None,
|
||||
)
|
||||
if error is not None:
|
||||
failed[tid] = error
|
||||
# Phase 2: mark originals as done, schedule handler tasks.
|
||||
for task_id, error in failed.items():
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
continue
|
||||
handler_node = self.nodes[task.name].error_handler_node
|
||||
if not handler_node:
|
||||
continue
|
||||
# Non-empty writes → runner's `not t.writes` filter skips this task.
|
||||
task.writes.append((ERROR, error))
|
||||
# The handler task starts with empty writes → runner will execute it.
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
task,
|
||||
handler_node_name=handler_node,
|
||||
failed_error=error,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=self.checkpoint_pending_writes,
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=task.config,
|
||||
step=self.step,
|
||||
stop=self.stop,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
if handler_task is not None:
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
|
||||
def _pending_interrupts(self) -> set[str]:
|
||||
"""Return the set of interrupt ids that are pending without corresponding resume values."""
|
||||
# mapping of task ids to interrupt ids
|
||||
@@ -1067,41 +978,35 @@ class PregelLoop:
|
||||
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
|
||||
# checkpoint already saved
|
||||
return
|
||||
# Per-delta-channel counter bookkeeping.
|
||||
#
|
||||
# Each delta channel tracks a (updates, supersteps) tuple:
|
||||
# - `updates` increments only when the channel is written this step.
|
||||
# - `supersteps` increments every superstep regardless.
|
||||
# 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 counters. In exit mode,
|
||||
# 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.
|
||||
# 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_counters = dict(
|
||||
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
|
||||
prev_counts = dict(
|
||||
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
)
|
||||
new_counters: dict[str, tuple[int, int]] = {}
|
||||
updated = self.updated_channels or set()
|
||||
for ch_name, ch in self.channels.items():
|
||||
if not isinstance(ch, DeltaChannel):
|
||||
continue
|
||||
u, s = prev_counters.get(ch_name, (0, 0))
|
||||
s += 1
|
||||
if ch_name in updated:
|
||||
u += 1
|
||||
new_counters[ch_name] = (u, s)
|
||||
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_counters = dict(
|
||||
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
|
||||
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 (
|
||||
@@ -1109,7 +1014,7 @@ class PregelLoop:
|
||||
)
|
||||
# create new checkpoint
|
||||
channels_to_snapshot = (
|
||||
delta_channels_to_snapshot(self.channels, new_counters)
|
||||
delta_channels_to_snapshot(self.channels, new_counts)
|
||||
if do_checkpoint
|
||||
else set()
|
||||
)
|
||||
@@ -1125,12 +1030,11 @@ class PregelLoop:
|
||||
channels_to_snapshot=channels_to_snapshot,
|
||||
)
|
||||
for k in channels_to_snapshot:
|
||||
new_counters[k] = (0, 0)
|
||||
non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
|
||||
if non_zero:
|
||||
self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
|
||||
elif "counters_since_delta_snapshot" in self.checkpoint_metadata:
|
||||
del self.checkpoint_metadata["counters_since_delta_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:
|
||||
del self.checkpoint_metadata["delta_updates_since_snapshot"]
|
||||
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
|
||||
if TASKS in self.checkpoint["channel_values"] and any(
|
||||
isinstance(channel, UntrackedValue) for channel in self.channels.values()
|
||||
@@ -1205,10 +1109,8 @@ class PregelLoop:
|
||||
):
|
||||
return
|
||||
|
||||
counters = dict(
|
||||
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
|
||||
)
|
||||
channels_to_snapshot = delta_channels_to_snapshot(self.channels, counters)
|
||||
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)
|
||||
@@ -1543,10 +1445,12 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
|
||||
if self._error_handler_write_futs:
|
||||
futs, self._error_handler_write_futs = self._error_handler_write_futs, []
|
||||
concurrent.futures.wait(futs)
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
@@ -1569,7 +1473,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in self.match_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
@@ -1651,7 +1555,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self._error_handler_write_futs = []
|
||||
self._exit_delta_writes = (
|
||||
[] if self.durability == "exit" and self.checkpointer is not None else None
|
||||
)
|
||||
@@ -1797,10 +1700,12 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
# ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
|
||||
if self._error_handler_write_futs:
|
||||
futs, self._error_handler_write_futs = self._error_handler_write_futs, []
|
||||
await asyncio.gather(*futs)
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
@@ -1823,7 +1728,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in await self.amatch_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
@@ -1908,7 +1813,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self._error_handler_write_futs = []
|
||||
self._exit_delta_writes = (
|
||||
[] if self.durability == "exit" and self.checkpointer is not None else None
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import (
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import BaseMessage, ToolMessage
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -303,35 +303,6 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
|
||||
super().__init__(stream, subgraphs, parent_ns=parent_ns)
|
||||
self._streamed_run_ids: set[UUID] = set()
|
||||
|
||||
def _find_and_emit_messages(self, meta: Meta, response: Any) -> None:
|
||||
"""Like the v1 handler, but skip ToolMessage from node outputs.
|
||||
|
||||
Tool results belong on the tools channel / state in v3; v2-flagged streams
|
||||
must not replay finalized ToolMessages as chat tokens (see MessagesTransformer).
|
||||
Legacy v1-only `stream_mode="messages"` still emits ToolMessages (see subgraph
|
||||
streaming tests).
|
||||
"""
|
||||
if isinstance(response, BaseMessage) and not isinstance(response, ToolMessage):
|
||||
self._emit(meta, response, dedupe=True)
|
||||
elif isinstance(response, Sequence):
|
||||
for value in response:
|
||||
if isinstance(value, BaseMessage) and not isinstance(
|
||||
value, ToolMessage
|
||||
):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
else:
|
||||
for value in _state_values(response):
|
||||
if isinstance(value, BaseMessage) and not isinstance(
|
||||
value, ToolMessage
|
||||
):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
elif isinstance(value, Sequence):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage) and not isinstance(
|
||||
item, ToolMessage
|
||||
):
|
||||
self._emit(meta, item, dedupe=True)
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
|
||||
@@ -31,7 +31,6 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CALL,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
INTERRUPT,
|
||||
NO_WRITES,
|
||||
RESUME,
|
||||
@@ -598,7 +597,7 @@ class PregelRunner:
|
||||
if self._should_route_to_error_handler(task) and not isinstance(
|
||||
exception, GraphBubbleUp
|
||||
):
|
||||
task.writes.append((ERROR_SOURCE_NODE, task.name))
|
||||
# Mark early in commit path; loop-side routing may happen later.
|
||||
self._handled_exception_ids.add(id(exception))
|
||||
self.put_writes()(task.id, task.writes) # type: ignore[misc]
|
||||
else:
|
||||
|
||||
@@ -62,14 +62,6 @@ class StreamMux:
|
||||
`extensions`, `_native` keys are recorded in `native_keys`, and
|
||||
any StreamChannel instances are bound and (if named) wired.
|
||||
|
||||
Transformers with `StreamTransformer.before_builtins = True` are
|
||||
registered ahead of the rest, preserving relative order within
|
||||
each lane. This lets content-mutating transformers (PII
|
||||
redaction, content filters, etc.) run before built-ins like
|
||||
`MessagesTransformer` that eagerly snapshot text fields into
|
||||
their projections. See `StreamTransformer.before_builtins` for
|
||||
the contract and foot-guns.
|
||||
|
||||
Args:
|
||||
transformers: Already-built transformer instances. Registered
|
||||
only on this mux — they are NOT cloned into child
|
||||
@@ -120,31 +112,14 @@ class StreamMux:
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# Factories run first (they propagate to child mini-muxes via
|
||||
# `_make_child`), then any pre-built `transformers=` instances
|
||||
# are registered as root-only — they aren't cloned for child
|
||||
# scopes. Within each group, transformers with
|
||||
# `before_builtins = True` are registered ahead of the rest so
|
||||
# they observe (and may mutate) events before built-ins like
|
||||
# `MessagesTransformer`. The order *within* each lane matches
|
||||
# the supplied sequence.
|
||||
pre: list[StreamTransformer] = []
|
||||
rest: list[StreamTransformer] = []
|
||||
# Factories run first (they propagate to child mini-muxes
|
||||
# via `_make_child`), then any pre-built `transformers=`
|
||||
# instances are registered as root-only — they aren't cloned
|
||||
# for child scopes.
|
||||
if factories is not None:
|
||||
for factory in factories:
|
||||
transformer = factory(scope)
|
||||
(
|
||||
pre if getattr(transformer, "before_builtins", False) else rest
|
||||
).append(transformer)
|
||||
for transformer in (*pre, *rest):
|
||||
self._register(transformer)
|
||||
pre.clear()
|
||||
rest.clear()
|
||||
self._register(factory(scope))
|
||||
for transformer in transformers or ():
|
||||
(pre if getattr(transformer, "before_builtins", False) else rest).append(
|
||||
transformer
|
||||
)
|
||||
for transformer in (*pre, *rest):
|
||||
self._register(transformer)
|
||||
|
||||
def transformer_by_key(self, key: str) -> StreamTransformer | None:
|
||||
|
||||
@@ -91,28 +91,11 @@ class StreamTransformer(ABC):
|
||||
which modes a `stream_events(version="v3")` run requests from the graph.
|
||||
Empty tuple means the transformer consumes only synthetic
|
||||
events (or is purely passive).
|
||||
before_builtins: Opt-in for transformers that must run *before*
|
||||
built-in transformers like `MessagesTransformer` and
|
||||
`ToolCallTransformer`. The mux partitions factories by this
|
||||
flag at registration time: `before_builtins = True`
|
||||
transformers are registered first, then everything else in
|
||||
the order supplied. Within each lane, registration order is
|
||||
preserved. This is the supported hook for content-mutating
|
||||
transformers (PII redaction, profanity filters, etc.) whose
|
||||
mutations must land before built-ins eagerly snapshot text
|
||||
fields into their projections. **Foot-gun:** transformers
|
||||
in this lane see `tasks` events before `LifecycleTransformer`
|
||||
and `SubgraphTransformer` consume them — mutating
|
||||
`event["params"]["namespace"]` or the data dict's
|
||||
`id` / `result` / `error` / `interrupts` fields will desync
|
||||
their bookkeeping. Observe freely; mutate only fields no
|
||||
built-in reads (e.g. `delta.text` on `messages` events).
|
||||
"""
|
||||
|
||||
requires_async: ClassVar[bool] = False
|
||||
supports_sync: ClassVar[bool] = False
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ()
|
||||
before_builtins: ClassVar[bool] = False
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
"""Initialize the transformer with its mux's scope.
|
||||
|
||||
@@ -8,7 +8,7 @@ from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage
|
||||
from langchain_protocol.protocol import MessagesData
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
@@ -203,7 +203,6 @@ class MessagesTransformer(StreamTransformer):
|
||||
# Correlate protocol events back to a ChatModelStream by run_id
|
||||
# (attached to the event's metadata by StreamMessagesHandler).
|
||||
self._by_run: dict[str, ChatModelStream] = {}
|
||||
self._ignored_runs: set[str] = set()
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
# Cached as a list once for cheap equality with the protocol
|
||||
@@ -277,10 +276,8 @@ class MessagesTransformer(StreamTransformer):
|
||||
self._route_protocol_event(
|
||||
cast("MessagesData", payload), run_id=run_id, node=node
|
||||
)
|
||||
elif (
|
||||
isinstance(payload, BaseMessage)
|
||||
and not isinstance(payload, AIMessageChunk)
|
||||
and not isinstance(payload, ToolMessage)
|
||||
elif isinstance(payload, BaseMessage) and not isinstance(
|
||||
payload, AIMessageChunk
|
||||
):
|
||||
self._route_whole_message(payload, node=node)
|
||||
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
|
||||
@@ -298,11 +295,6 @@ class MessagesTransformer(StreamTransformer):
|
||||
) -> None:
|
||||
event_type = event.get("event")
|
||||
if event_type == "message-start":
|
||||
# Tool results are exposed on the tools projection and state
|
||||
# snapshots; run.messages is the chat-token projection.
|
||||
if event.get("role") == "tool":
|
||||
self._ignored_runs.add(run_id)
|
||||
return
|
||||
message_id = event.get("message_id")
|
||||
stream = self._make_stream(
|
||||
namespace=[],
|
||||
@@ -312,9 +304,6 @@ class MessagesTransformer(StreamTransformer):
|
||||
self._by_run[run_id] = stream
|
||||
self._log.push(stream)
|
||||
stream.dispatch(event)
|
||||
elif run_id in self._ignored_runs:
|
||||
if event_type == "message-finish":
|
||||
self._ignored_runs.discard(run_id)
|
||||
elif run_id in self._by_run:
|
||||
stream = self._by_run[run_id]
|
||||
stream.dispatch(event)
|
||||
@@ -330,14 +319,12 @@ class MessagesTransformer(StreamTransformer):
|
||||
def finalize(self) -> None:
|
||||
"""Clear any routing state — streams close themselves via `message-finish`."""
|
||||
self._by_run.clear()
|
||||
self._ignored_runs.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Propagate run error to any streams still open when the graph fails."""
|
||||
for stream in list(self._by_run.values()):
|
||||
stream.fail(err)
|
||||
self._by_run.clear()
|
||||
self._ignored_runs.clear()
|
||||
|
||||
|
||||
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0a7"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -24,10 +24,10 @@ classifiers = [
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
dependencies = [
|
||||
"langchain-core>=1.4.0,<2",
|
||||
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
||||
"langchain-core>=1.4.0a2,<2",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.1.0,<1.2.0",
|
||||
"langgraph-prebuilt>=1.1.0a2,<1.2.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
]
|
||||
|
||||
@@ -160,8 +160,8 @@ async def test_exit_resumed_run_sub_freq() -> None:
|
||||
|
||||
|
||||
async def test_exit_count_parity_sync_vs_exit() -> None:
|
||||
"""Sync and exit durability produce the same update count in
|
||||
counters_since_delta_snapshot after an equivalent run."""
|
||||
"""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)
|
||||
@@ -175,13 +175,9 @@ async def test_exit_count_parity_sync_vs_exit() -> None:
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
updates, supersteps = counters.get("messages", (0, 0))
|
||||
assert updates == 2, (
|
||||
f"durability={durability}: expected updates=2, got {updates}"
|
||||
)
|
||||
assert supersteps >= 2, (
|
||||
f"durability={durability}: expected supersteps>=2, got {supersteps}"
|
||||
counts = head.metadata.get("delta_updates_since_snapshot", {})
|
||||
assert counts.get("messages") == 2, (
|
||||
f"durability={durability}: expected count=2, got {counts}"
|
||||
)
|
||||
|
||||
|
||||
@@ -200,9 +196,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters1 = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
updates1 = counters1.get("messages", (0, 0))[0]
|
||||
assert updates1 == 2
|
||||
count1 = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
|
||||
assert count1 == 2
|
||||
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content="m2", id="h2")]},
|
||||
@@ -211,9 +206,8 @@ async def test_exit_snapshot_fires_at_frequency() -> None:
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters2 = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
updates2 = counters2.get("messages", (0, 0))[0]
|
||||
assert updates2 == 0, f"Expected reset to 0 after snapshot, got {updates2}"
|
||||
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)
|
||||
|
||||
|
||||
@@ -290,7 +284,7 @@ async def test_exit_multi_run_replay_chain() -> None:
|
||||
|
||||
async def test_exit_metadata_round_trip() -> None:
|
||||
"""K=5 consecutive exit runs with snapshot_frequency=5. Verify metadata
|
||||
counters_since_delta_snapshot increments correctly across runs."""
|
||||
delta_updates_since_snapshot increments correctly across runs."""
|
||||
freq = 5
|
||||
saver = InMemorySaver()
|
||||
graph = _build_graph(saver, freq=freq)
|
||||
@@ -304,16 +298,15 @@ async def test_exit_metadata_round_trip() -> None:
|
||||
)
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
updates = counters.get("messages", (0, 0))[0]
|
||||
count = head.metadata.get("delta_updates_since_snapshot", {}).get("messages", 0)
|
||||
cumulative = i * 2
|
||||
if cumulative >= freq:
|
||||
assert updates == 0 or updates == cumulative % freq or updates < freq, (
|
||||
f"After run {i}: updates={updates} should have reset or be partial"
|
||||
assert count == 0 or count == cumulative % freq or count < freq, (
|
||||
f"After run {i}: count={count} should have reset or be partial"
|
||||
)
|
||||
else:
|
||||
assert updates == cumulative, (
|
||||
f"After run {i}: expected {cumulative}, got {updates}"
|
||||
assert count == cumulative, (
|
||||
f"After run {i}: expected {cumulative}, got {count}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Tests for the supersteps-since-last-snapshot bound on DeltaChannel.
|
||||
|
||||
Validates that a delta channel which stops receiving writes is still
|
||||
force-snapshotted after DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT supersteps,
|
||||
preventing unbounded ancestor walks.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
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 END, START, StateGraph
|
||||
from langgraph.pregel._checkpoint import delta_channels_to_snapshot
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _simple_reducer(current: list, updates: list) -> list:
|
||||
"""Flatten updates into current list (each update is itself a list)."""
|
||||
result = list(current)
|
||||
for u in updates:
|
||||
if isinstance(u, list):
|
||||
result.extend(u)
|
||||
else:
|
||||
result.append(u)
|
||||
return result
|
||||
|
||||
|
||||
def _build_two_channel_graph(
|
||||
checkpointer: InMemorySaver,
|
||||
*,
|
||||
freq_a: int = 10_000,
|
||||
freq_b: int = 10_000,
|
||||
n_loops: int = 1,
|
||||
) -> Any:
|
||||
"""Graph with two delta channels A and B.
|
||||
|
||||
The node only writes to channel A; B is never written by the node.
|
||||
`n_loops` controls how many supersteps the graph runs (via chained nodes).
|
||||
"""
|
||||
ch_a = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_a)
|
||||
ch_b = DeltaChannel(_simple_reducer, list, snapshot_frequency=freq_b)
|
||||
State = TypedDict( # noqa: UP013
|
||||
"State",
|
||||
{"a": Annotated[list, ch_a], "b": Annotated[list, ch_b]},
|
||||
) # type: ignore[call-overload]
|
||||
|
||||
builder = StateGraph(State)
|
||||
|
||||
for i in range(n_loops):
|
||||
name = f"step_{i}"
|
||||
|
||||
def node_fn(state: dict, _i: int = i) -> dict:
|
||||
return {"a": [f"a-val-{_i}"]}
|
||||
|
||||
builder.add_node(name, node_fn)
|
||||
if i == 0:
|
||||
builder.add_edge(START, name)
|
||||
else:
|
||||
builder.add_edge(f"step_{i - 1}", name)
|
||||
if i == n_loops - 1:
|
||||
builder.add_edge(name, END)
|
||||
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
|
||||
|
||||
async def test_forced_snapshot_single_run() -> None:
|
||||
"""A single invoke with enough supersteps triggers snapshot on the
|
||||
unwritten channel B via the supersteps bound."""
|
||||
max_ss = 3
|
||||
with patch(
|
||||
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
|
||||
max_ss,
|
||||
):
|
||||
saver = InMemorySaver()
|
||||
graph = _build_two_channel_graph(saver, n_loops=4)
|
||||
config = {"configurable": {"thread_id": "single-run-ss"}}
|
||||
|
||||
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
assert isinstance(head.checkpoint["channel_values"].get("b"), _DeltaSnapshot), (
|
||||
"Channel B should have been force-snapshotted via supersteps bound"
|
||||
)
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert state.values["b"] == ["seed-b"]
|
||||
assert "seed-a" in state.values["a"]
|
||||
|
||||
|
||||
async def test_forced_snapshot_accumulates_across_runs() -> None:
|
||||
"""Supersteps counter for an unwritten channel persists across separate
|
||||
invoke() calls. After enough runs, the channel is force-snapshotted."""
|
||||
max_ss = 5
|
||||
with patch(
|
||||
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
|
||||
max_ss,
|
||||
):
|
||||
saver = InMemorySaver()
|
||||
graph = _build_two_channel_graph(saver, n_loops=1)
|
||||
config = {"configurable": {"thread_id": "multi-run-ss"}}
|
||||
|
||||
graph.invoke({"a": ["init-a"], "b": ["init-b"]}, config)
|
||||
|
||||
for i in range(1, 6):
|
||||
graph.invoke({"a": [f"run-{i}"]}, config)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
b_counters = counters.get("b", (0, 0))
|
||||
|
||||
if b_counters == (0, 0):
|
||||
assert isinstance(
|
||||
head.checkpoint["channel_values"].get("b"), _DeltaSnapshot
|
||||
), f"Run {i}: counter reset but no snapshot blob for B"
|
||||
break
|
||||
else:
|
||||
pytest.fail("Channel B was never force-snapshotted after multiple runs")
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert state.values["b"] == ["init-b"]
|
||||
assert "init-a" in state.values["a"]
|
||||
|
||||
|
||||
async def test_predicate_fires_on_supersteps_overflow() -> None:
|
||||
"""Unit test: delta_channels_to_snapshot fires when supersteps >= MAX
|
||||
even when updates == 0."""
|
||||
ch = DeltaChannel(_simple_reducer, list, snapshot_frequency=10_000)
|
||||
ch.key = "x"
|
||||
ch_instance = ch.from_checkpoint(None)
|
||||
|
||||
channels = {"x": ch_instance}
|
||||
counters: dict[str, tuple[int, int]] = {"x": (0, 5000)}
|
||||
|
||||
result = delta_channels_to_snapshot(channels, counters)
|
||||
assert "x" in result
|
||||
|
||||
counters_below: dict[str, tuple[int, int]] = {"x": (0, 4999)}
|
||||
result2 = delta_channels_to_snapshot(channels, counters_below)
|
||||
assert "x" not in result2
|
||||
|
||||
|
||||
async def test_counter_reset_after_supersteps_snapshot() -> None:
|
||||
"""After the supersteps bound triggers a snapshot, the counters for
|
||||
that channel reset. Verify by using a bound higher than one run's
|
||||
supersteps so we can see the counter in an intermediate state."""
|
||||
max_ss = 15
|
||||
with patch(
|
||||
"langgraph.pregel._checkpoint.DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT",
|
||||
max_ss,
|
||||
):
|
||||
saver = InMemorySaver()
|
||||
graph = _build_two_channel_graph(saver, n_loops=4)
|
||||
config = {"configurable": {"thread_id": "counter-reset"}}
|
||||
|
||||
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
|
||||
|
||||
head = saver.get_tuple(config)
|
||||
assert head is not None
|
||||
counters = head.metadata.get("counters_since_delta_snapshot", {})
|
||||
b_counters = counters.get("b", (0, 0))
|
||||
run1_supersteps = b_counters[1]
|
||||
assert run1_supersteps > 0, "Should have some supersteps"
|
||||
assert b_counters[0] == 1, "B written once (input step)"
|
||||
|
||||
graph.invoke({"a": ["more-a"]}, config)
|
||||
head2 = saver.get_tuple(config)
|
||||
assert head2 is not None
|
||||
counters2 = head2.metadata.get("counters_since_delta_snapshot", {})
|
||||
b_counters2 = counters2.get("b", (0, 0))
|
||||
run2_supersteps = b_counters2[1]
|
||||
assert run2_supersteps > run1_supersteps, "Supersteps should accumulate"
|
||||
assert b_counters2[0] == 1, "B written once total (only original input)"
|
||||
|
||||
graph.invoke({"a": ["even-more"]}, config)
|
||||
head3 = saver.get_tuple(config)
|
||||
assert head3 is not None
|
||||
assert isinstance(
|
||||
head3.checkpoint["channel_values"].get("b"), _DeltaSnapshot
|
||||
), "B should have snapshotted at supersteps >= max_ss"
|
||||
counters3 = head3.metadata.get("counters_since_delta_snapshot", {})
|
||||
b_counters3 = counters3.get("b", (0, 0))
|
||||
assert b_counters3[1] < max_ss, (
|
||||
f"After snapshot, supersteps should have reset, got {b_counters3}"
|
||||
)
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert state.values["b"] == ["seed-b"]
|
||||
@@ -15,7 +15,7 @@ from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallback
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnableParallel
|
||||
from langchain_core.runnables import RunnableLambda, RunnableParallel
|
||||
from langgraph.checkpoint.memory import InMemorySaver, MemorySaver
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from typing_extensions import TypedDict
|
||||
@@ -2280,525 +2280,3 @@ def test_node_without_error_handler_still_fails_run():
|
||||
|
||||
with pytest.raises(ValueError, match="no handler"):
|
||||
graph.invoke({"foo": ""})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_node_defaults()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_catches_all_nodes():
|
||||
class State(TypedDict):
|
||||
route: str
|
||||
foo: Annotated[list[str], operator.add]
|
||||
|
||||
def route_node(state: State) -> Command:
|
||||
return Command(goto=state["route"])
|
||||
|
||||
def fail_a(state: State) -> State:
|
||||
raise RuntimeError("a failed")
|
||||
|
||||
def fail_b(state: State) -> State:
|
||||
raise RuntimeError("b failed")
|
||||
|
||||
captured: dict[str, list[str]] = {"nodes": []}
|
||||
|
||||
def default_handler(state: State, error: NodeError) -> State:
|
||||
captured["nodes"].append(error.node)
|
||||
return {"foo": [f"handled_{error.node}"]}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=default_handler)
|
||||
.add_node("route_node", route_node)
|
||||
.add_node("fail_a", fail_a)
|
||||
.add_node("fail_b", fail_b)
|
||||
.add_edge(START, "route_node")
|
||||
.add_conditional_edges(
|
||||
"route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
|
||||
)
|
||||
.compile()
|
||||
)
|
||||
|
||||
result_a = graph.invoke({"route": "fail_a", "foo": []})
|
||||
result_b = graph.invoke({"route": "fail_b", "foo": []})
|
||||
assert result_a["foo"] == ["handled_fail_a"]
|
||||
assert result_b["foo"] == ["handled_fail_b"]
|
||||
assert "fail_a" in captured["nodes"]
|
||||
assert "fail_b" in captured["nodes"]
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_overridden_by_node_handler():
|
||||
class State(TypedDict):
|
||||
route: str
|
||||
foo: Annotated[list[str], operator.add]
|
||||
|
||||
def route_node(state: State) -> Command:
|
||||
return Command(goto=state["route"])
|
||||
|
||||
def fail_a(state: State) -> State:
|
||||
raise RuntimeError("a failed")
|
||||
|
||||
def fail_b(state: State) -> State:
|
||||
raise RuntimeError("b failed")
|
||||
|
||||
captured: dict[str, list[str]] = {"handler": []}
|
||||
|
||||
def node_handler(state: State, error: NodeError) -> State:
|
||||
captured["handler"].append(f"node:{error.node}")
|
||||
return {"foo": [f"node_handled_{error.node}"]}
|
||||
|
||||
def default_handler(state: State, error: NodeError) -> State:
|
||||
captured["handler"].append(f"default:{error.node}")
|
||||
return {"foo": [f"default_handled_{error.node}"]}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=default_handler)
|
||||
.add_node("route_node", route_node)
|
||||
.add_node("fail_a", fail_a, error_handler=node_handler)
|
||||
.add_node("fail_b", fail_b)
|
||||
.add_edge(START, "route_node")
|
||||
.add_conditional_edges(
|
||||
"route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
|
||||
)
|
||||
.compile()
|
||||
)
|
||||
|
||||
result_a = graph.invoke({"route": "fail_a", "foo": []})
|
||||
assert result_a["foo"] == ["node_handled_fail_a"]
|
||||
assert "node:fail_a" in captured["handler"]
|
||||
assert "default:fail_a" not in captured["handler"]
|
||||
|
||||
result_b = graph.invoke({"route": "fail_b", "foo": []})
|
||||
assert result_b["foo"] == ["default_handled_fail_b"]
|
||||
assert "default:fail_b" in captured["handler"]
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_skips_per_node_handler_nodes():
|
||||
"""If a per-node error handler itself raises, the default handler must NOT
|
||||
catch it -- the run should fail."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def always_failing(state: State) -> State:
|
||||
raise RuntimeError("node boom")
|
||||
|
||||
def broken_handler(state: State, error: NodeError) -> State:
|
||||
raise RuntimeError("handler boom")
|
||||
|
||||
def default_handler(state: State, error: NodeError) -> State:
|
||||
return {"foo": "default recovered"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=default_handler)
|
||||
.add_node("always_failing", always_failing, error_handler=broken_handler)
|
||||
.add_edge(START, "always_failing")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="handler boom"):
|
||||
graph.invoke({"foo": ""})
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_failure_fails_run():
|
||||
"""When the default handler itself raises, the run fails (no infinite
|
||||
recursion, no double-routing)."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def always_failing(state: State) -> State:
|
||||
raise RuntimeError("node boom")
|
||||
|
||||
def broken_default_handler(state: State, error: NodeError) -> State:
|
||||
raise RuntimeError("default handler boom")
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=broken_default_handler)
|
||||
.add_node("always_failing", always_failing)
|
||||
.add_edge(START, "always_failing")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="default handler boom"):
|
||||
graph.invoke({"foo": ""})
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_receives_runnable_config():
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def always_failing(state: State) -> State:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def default_handler(
|
||||
state: State, error: NodeError, config: RunnableConfig
|
||||
) -> State:
|
||||
captured["thread_id"] = config["configurable"].get("thread_id")
|
||||
return {"foo": "handled"}
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=default_handler)
|
||||
.add_node("always_failing", always_failing)
|
||||
.add_edge(START, "always_failing")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
thread_id = str(uuid4())
|
||||
result = graph.invoke(
|
||||
{"foo": ""}, config={"configurable": {"thread_id": thread_id}}
|
||||
)
|
||||
assert result["foo"] == "handled"
|
||||
assert captured["thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_set_node_defaults_error_handler_collides_with_user_node():
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def default_handler(state: State, error: NodeError) -> State:
|
||||
return {"foo": "handled"}
|
||||
|
||||
builder = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=default_handler)
|
||||
.add_node("__default_error_handler__", lambda s: s)
|
||||
.add_edge(START, "__default_error_handler__")
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="__default_error_handler__"):
|
||||
builder.compile()
|
||||
|
||||
|
||||
def test_set_node_defaults_retry_policy():
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempts = 0
|
||||
|
||||
def flaky_node(state: State) -> State:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 3:
|
||||
raise ValueError("not yet")
|
||||
return {"foo": "ok"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=3, initial_interval=0.01, jitter=False, retry_on=ValueError
|
||||
)
|
||||
)
|
||||
.add_node("flaky", flaky_node)
|
||||
.add_edge(START, "flaky")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with patch("time.sleep"):
|
||||
result = graph.invoke({"foo": ""})
|
||||
|
||||
assert result["foo"] == "ok"
|
||||
assert attempts == 3
|
||||
|
||||
|
||||
def test_set_node_defaults_retry_policy_per_node_wins():
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempts = 0
|
||||
|
||||
def flaky_node(state: State) -> State:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 2:
|
||||
raise ValueError("not yet")
|
||||
return {"foo": "ok"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=1, initial_interval=0.01, jitter=False, retry_on=ValueError
|
||||
)
|
||||
)
|
||||
.add_node(
|
||||
"flaky",
|
||||
flaky_node,
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=3,
|
||||
initial_interval=0.01,
|
||||
jitter=False,
|
||||
retry_on=ValueError,
|
||||
),
|
||||
)
|
||||
.add_edge(START, "flaky")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with patch("time.sleep"):
|
||||
result = graph.invoke({"foo": ""})
|
||||
|
||||
assert result["foo"] == "ok"
|
||||
assert attempts == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_node_defaults_timeout():
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
async def slow_node(state: State) -> State:
|
||||
await asyncio.sleep(10)
|
||||
return {"foo": "should-not-happen"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.05))
|
||||
.add_node("slow", slow_node)
|
||||
.add_edge(START, "slow")
|
||||
.compile()
|
||||
)
|
||||
|
||||
from langgraph.errors import NodeTimeoutError
|
||||
|
||||
with pytest.raises(NodeTimeoutError):
|
||||
await graph.ainvoke({"foo": ""})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_node_defaults_timeout_per_node_wins():
|
||||
"""Per-node timeout overrides the default; a generous per-node timeout
|
||||
allows a node to complete even when the builder default is very short."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
async def quick_node(state: State) -> State:
|
||||
await asyncio.sleep(0.05)
|
||||
return {"foo": "done"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.01))
|
||||
.add_node("quick", quick_node, timeout=TimeoutPolicy(run_timeout=5.0))
|
||||
.add_edge(START, "quick")
|
||||
.compile()
|
||||
)
|
||||
|
||||
result = await graph.ainvoke({"foo": ""})
|
||||
assert result["foo"] == "done"
|
||||
|
||||
|
||||
def test_set_node_defaults_chaining():
|
||||
"""set_node_defaults() is chainable and can be called in any order relative to add_node."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def always_failing(state: State) -> State:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def handler(state: State, error: NodeError) -> State:
|
||||
return {"foo": "handled"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("a", always_failing)
|
||||
.add_edge(START, "a")
|
||||
.set_node_defaults(
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=1, initial_interval=0.01, jitter=False
|
||||
),
|
||||
error_handler=handler,
|
||||
)
|
||||
.compile()
|
||||
)
|
||||
|
||||
result = graph.invoke({"foo": ""})
|
||||
assert result["foo"] == "handled"
|
||||
|
||||
|
||||
def test_set_node_defaults_combined_retry_and_error_handler():
|
||||
"""Retries are exhausted first, then the error handler runs."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempts = 0
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def always_failing(state: State) -> State:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise ValueError("Always fails")
|
||||
|
||||
def handler(state: State, error: NodeError) -> State:
|
||||
captured["error"] = str(error.error)
|
||||
return {"foo": "handled"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=2,
|
||||
initial_interval=0.01,
|
||||
jitter=False,
|
||||
retry_on=ValueError,
|
||||
),
|
||||
error_handler=handler,
|
||||
)
|
||||
.add_node("fail", always_failing)
|
||||
.add_edge(START, "fail")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with patch("time.sleep"):
|
||||
result = graph.invoke({"foo": ""})
|
||||
|
||||
assert result["foo"] == "handled"
|
||||
assert attempts == 2
|
||||
assert captured["error"] == "Always fails"
|
||||
|
||||
|
||||
def test_error_handler_resumes_after_crash():
|
||||
"""If the error handler crashes, resuming should re-schedule the handler
|
||||
(not re-execute the original failed node)."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
call_count = {"node": 0, "handler": 0}
|
||||
captured_errors: list[NodeError] = []
|
||||
|
||||
def failing_node(state: State) -> State:
|
||||
call_count["node"] += 1
|
||||
raise RuntimeError("boom")
|
||||
|
||||
handler_should_fail = [True]
|
||||
|
||||
def handler(state: State, error: NodeError) -> State:
|
||||
call_count["handler"] += 1
|
||||
captured_errors.append(error)
|
||||
if handler_should_fail[0]:
|
||||
raise RuntimeError("handler crash")
|
||||
return {"foo": "recovered"}
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.set_node_defaults(error_handler=handler)
|
||||
.add_node("fail", failing_node)
|
||||
.add_edge(START, "fail")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "t1"}}
|
||||
|
||||
# First invoke: node fails -> handler runs -> handler crashes -> run fails
|
||||
with pytest.raises(RuntimeError, match="handler crash"):
|
||||
graph.invoke({"foo": ""}, config)
|
||||
|
||||
assert call_count["node"] == 1
|
||||
assert call_count["handler"] == 1
|
||||
assert captured_errors[0].node == "fail"
|
||||
assert isinstance(captured_errors[0].error, RuntimeError)
|
||||
assert str(captured_errors[0].error) == "boom"
|
||||
|
||||
# Resume: handler should run again, NOT the original node
|
||||
handler_should_fail[0] = False
|
||||
result = graph.invoke(None, config)
|
||||
|
||||
assert result["foo"] == "recovered"
|
||||
assert call_count["node"] == 1 # NOT re-executed
|
||||
assert call_count["handler"] == 2 # ran again on resume
|
||||
# on resume the error was round-tripped through the checkpointer, so it
|
||||
# may be deserialized as a string representation rather than the original
|
||||
# exception type — verify the node name and that the error content matches.
|
||||
assert captured_errors[1].node == "fail"
|
||||
assert "boom" in str(captured_errors[1].error)
|
||||
|
||||
|
||||
def test_error_handler_resumes_after_crash_multiple_nodes():
|
||||
"""When multiple nodes fail in the same superstep and all have error handlers:
|
||||
- error handlers start running while other nodes may still be in-flight
|
||||
- resuming re-schedules each handler (not re-executes the original nodes)
|
||||
"""
|
||||
|
||||
class State(TypedDict):
|
||||
results: Annotated[list[str], operator.add]
|
||||
|
||||
call_count = {"a": 0, "b": 0, "handler_a": 0, "handler_b": 0}
|
||||
handler_a_started = threading.Event()
|
||||
|
||||
def node_a(state: State) -> State:
|
||||
call_count["a"] += 1
|
||||
raise RuntimeError("a failed")
|
||||
|
||||
def node_b(state: State) -> State:
|
||||
call_count["b"] += 1
|
||||
# Block until handler_a has started — proves the error handler runs
|
||||
# concurrently with in-flight nodes in the same superstep.
|
||||
assert handler_a_started.wait(timeout=5), "handler_a never started"
|
||||
raise RuntimeError("b failed")
|
||||
|
||||
handler_should_fail = [True]
|
||||
|
||||
def handler_a(state: State, error: NodeError) -> State:
|
||||
call_count["handler_a"] += 1
|
||||
assert error.node == "a"
|
||||
assert "a failed" in str(error.error)
|
||||
handler_a_started.set()
|
||||
if handler_should_fail[0]:
|
||||
raise RuntimeError("handler_a crash")
|
||||
return {"results": [f"recovered_a:{error.node}"]}
|
||||
|
||||
def handler_b(state: State, error: NodeError) -> State:
|
||||
call_count["handler_b"] += 1
|
||||
assert error.node == "b"
|
||||
assert "b failed" in str(error.error)
|
||||
if handler_should_fail[0]:
|
||||
raise RuntimeError("handler_b crash")
|
||||
return {"results": [f"recovered_b:{error.node}"]}
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("a", node_a, error_handler=handler_a)
|
||||
.add_node("b", node_b, error_handler=handler_b)
|
||||
.add_edge(START, "a")
|
||||
.add_edge(START, "b")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "t1"}}
|
||||
|
||||
# First invoke: node_a fails immediately -> handler_a starts (sets event) ->
|
||||
# node_b unblocks and fails -> handler_b starts -> both handlers crash
|
||||
with pytest.raises(RuntimeError):
|
||||
graph.invoke({"results": []}, config)
|
||||
|
||||
assert call_count["a"] == 1
|
||||
assert call_count["b"] == 1
|
||||
assert call_count["handler_a"] == 1
|
||||
assert call_count["handler_b"] == 1
|
||||
|
||||
# Resume: both handlers should run again, NOT the original nodes
|
||||
handler_should_fail[0] = False
|
||||
handler_a_started.clear()
|
||||
result = graph.invoke(None, config)
|
||||
|
||||
assert call_count["a"] == 1 # NOT re-executed
|
||||
assert call_count["b"] == 1 # NOT re-executed
|
||||
assert call_count["handler_a"] == 2 # ran again on resume
|
||||
assert call_count["handler_b"] == 2 # ran again on resume
|
||||
assert "recovered_a:a" in result["results"]
|
||||
assert "recovered_b:b" in result["results"]
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
"""Tests for `StreamTransformer.before_builtins` lane ordering.
|
||||
|
||||
`before_builtins = True` transformers are registered ahead of the
|
||||
rest, preserving relative order within each lane. This lets
|
||||
content-mutating transformers run before built-ins like
|
||||
`MessagesTransformer` that eagerly snapshot text fields into their
|
||||
projections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
LifecycleTransformer,
|
||||
MessagesTransformer,
|
||||
TasksTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _messages_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
class _Tap(StreamTransformer):
|
||||
"""Records the order it observed each event."""
|
||||
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ()
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = (), *, label: str = "tap") -> None:
|
||||
super().__init__(scope)
|
||||
self.label = label
|
||||
self.log: list[str] = []
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {f"tap_{self.label}": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
self.log.append(self.label)
|
||||
return True
|
||||
|
||||
|
||||
class _PreTap(_Tap):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
|
||||
|
||||
class _TextRedactor(StreamTransformer):
|
||||
"""Mutates `text-delta` events in place to a fixed redacted string."""
|
||||
|
||||
before_builtins: ClassVar[bool] = True
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ("messages",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"redactor": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
if event.get("method") != "messages":
|
||||
return True
|
||||
payload, _meta = event["params"]["data"]
|
||||
if isinstance(payload, dict) and payload.get("event") == "content-block-delta":
|
||||
delta = payload.get("delta") or {}
|
||||
if delta.get("type") == "text-delta":
|
||||
delta["text"] = "[REDACTED]"
|
||||
return True
|
||||
|
||||
|
||||
def test_before_builtins_factories_run_before_others() -> None:
|
||||
"""A `before_builtins=True` factory is registered ahead of the rest."""
|
||||
|
||||
seen: list[type[StreamTransformer]] = []
|
||||
|
||||
class _PostTap(_Tap):
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope, label="post")
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
seen.append(_PostTap)
|
||||
return True
|
||||
|
||||
class _EagerTap(_Tap):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope, label="eager")
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
seen.append(_EagerTap)
|
||||
return True
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[_PostTap, _EagerTap],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
# `_EagerTap` was supplied second but should be registered first.
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_EagerTap) < types_in_order.index(_PostTap)
|
||||
|
||||
mux.push(
|
||||
_messages_event([], ({"event": "message-start", "role": "ai", "id": "m1"}, {}))
|
||||
)
|
||||
# And it ran first when the event was dispatched.
|
||||
assert seen == [_EagerTap, _PostTap]
|
||||
|
||||
|
||||
def test_within_lane_order_preserved() -> None:
|
||||
"""Within each lane, the supplied order is the registration order."""
|
||||
|
||||
class _A(_PreTap):
|
||||
pass
|
||||
|
||||
class _B(_PreTap):
|
||||
pass
|
||||
|
||||
class _C(_Tap):
|
||||
pass
|
||||
|
||||
class _D(_Tap):
|
||||
pass
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
lambda scope: _C(scope, label="c"),
|
||||
lambda scope: _A(scope, label="a"),
|
||||
lambda scope: _D(scope, label="d"),
|
||||
lambda scope: _B(scope, label="b"),
|
||||
],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
order = [t.label for t in mux._transformers] # type: ignore[attr-defined]
|
||||
# Pre lane (a, b) ahead of default lane (c, d). Within each, supplied order kept.
|
||||
assert order == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_redactor_runs_before_messages_transformer() -> None:
|
||||
"""Content mutated by a pre-lane transformer reaches `MessagesTransformer`."""
|
||||
|
||||
# Order supplied: built-ins first (as in pregel/main.py), then the
|
||||
# opt-in pre-lane redactor. Partitioning should still register the
|
||||
# redactor first.
|
||||
mux = StreamMux(
|
||||
factories=[MessagesTransformer, _TextRedactor],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_TextRedactor) < types_in_order.index(
|
||||
MessagesTransformer
|
||||
)
|
||||
|
||||
|
||||
def test_lifecycle_unaffected_by_pre_lane_observer() -> None:
|
||||
"""An observer-only pre-lane transformer doesn't break lifecycle bookkeeping."""
|
||||
|
||||
class _NoopPreObserver(StreamTransformer):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ("tasks",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
self.seen: list[tuple[str, ...]] = []
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"noop_observer": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
if event.get("method") == "tasks":
|
||||
self.seen.append(tuple(event["params"]["namespace"]))
|
||||
return True
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[LifecycleTransformer, TasksTransformer, _NoopPreObserver],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_NoopPreObserver) < types_in_order.index(
|
||||
LifecycleTransformer
|
||||
)
|
||||
|
||||
observer = next(t for t in mux._transformers if isinstance(t, _NoopPreObserver))
|
||||
lifecycle = next(
|
||||
t for t in mux._transformers if isinstance(t, LifecycleTransformer)
|
||||
)
|
||||
|
||||
# Push a synthetic `tasks` event that lifecycle would normally track.
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": ["child:abc"],
|
||||
"timestamp": TS,
|
||||
"data": {"name": "child"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Pre-lane observer saw the event, AND lifecycle's bookkeeping still
|
||||
# registered the new namespace (the observer didn't mutate anything).
|
||||
assert observer.seen == [("child:abc",)]
|
||||
assert ("child:abc",) in lifecycle._seen # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_default_is_false() -> None:
|
||||
"""`StreamTransformer.before_builtins` defaults to False."""
|
||||
|
||||
assert StreamTransformer.before_builtins is False
|
||||
assert MessagesTransformer.before_builtins is False
|
||||
assert LifecycleTransformer.before_builtins is False
|
||||
|
||||
|
||||
def test_pre_lane_mutation_lands_in_messages_projection() -> None:
|
||||
"""End-to-end: text mutated by a pre-lane transformer is what
|
||||
`MessagesTransformer` snapshots into its `ChatModelStream` projection.
|
||||
|
||||
Without `before_builtins`, the redactor would run after
|
||||
MessagesTransformer's eager extraction and the projection would
|
||||
contain the raw, un-redacted text.
|
||||
"""
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[MessagesTransformer, _TextRedactor],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
messages_transformer = next(
|
||||
t for t in mux._transformers if isinstance(t, MessagesTransformer)
|
||||
)
|
||||
# Unblock both the mux's main log and the messages projection log so
|
||||
# synthetic pushes are accepted without a real consumer attached.
|
||||
mux._events._subscribed = True
|
||||
messages_transformer._log._subscribed = True
|
||||
|
||||
meta = {"langgraph_node": "model", "run_id": "run-1"}
|
||||
|
||||
# message-start → MessagesTransformer creates a ChatModelStream.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
({"event": "message-start", "role": "ai", "id": "msg-1"}, meta),
|
||||
)
|
||||
)
|
||||
|
||||
# content-block-delta carrying the secret. The redactor (pre-lane)
|
||||
# mutates `delta.text` BEFORE MessagesTransformer snapshots it.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
(
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text-delta", "text": "secret@example.com"},
|
||||
},
|
||||
meta,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Capture the still-open stream before message-finish removes it from
|
||||
# MessagesTransformer's `_by_run` dict.
|
||||
chat_stream = messages_transformer._by_run["run-1"] # type: ignore[attr-defined]
|
||||
|
||||
# message-finish → closes the stream.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
({"event": "message-finish"}, meta),
|
||||
)
|
||||
)
|
||||
|
||||
# The redactor mutated `delta.text` to "[REDACTED]" before
|
||||
# MessagesTransformer snapshotted the string into the text
|
||||
# accumulator. Without `before_builtins`, the accumulator would hold
|
||||
# the raw "secret@example.com".
|
||||
assert chat_stream._text_acc == "[REDACTED]", ( # type: ignore[attr-defined]
|
||||
f"expected redacted text in projection, got {chat_stream._text_acc!r}"
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
@@ -213,23 +213,6 @@ class TestProtocolEventRouting:
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_tool_role_protocol_events_are_ignored(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in [
|
||||
{"event": "message-start", "role": "tool", "message_id": "tool-msg-1"},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "[]"},
|
||||
},
|
||||
{"event": "message-finish", "reason": "stop"},
|
||||
]:
|
||||
t.process(_proto_event(evt, run_id="tool-run"))
|
||||
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
assert t._ignored_runs == set()
|
||||
|
||||
def test_concurrent_streams_routed_by_run_id(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
life_a = _lifecycle(text="aaaa", message_id="run-a")
|
||||
@@ -290,29 +273,6 @@ class TestWholeMessageFallback:
|
||||
assert stream.done
|
||||
assert stream.output.text == "the full answer"
|
||||
|
||||
def test_whole_tool_message_is_ignored(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (
|
||||
ToolMessage(
|
||||
content="[]",
|
||||
id="tool-msg-1",
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
{"langgraph_node": "tools"},
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_whole_message_has_full_lifecycle(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("full"))
|
||||
@@ -889,23 +849,6 @@ class TestStreamMessagesHandlerV2Unit:
|
||||
|
||||
assert emitted == []
|
||||
|
||||
def test_on_chain_end_does_not_emit_tool_messages(self) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.pregel._messages import StreamMessagesHandlerV2
|
||||
|
||||
emitted: list[Any] = []
|
||||
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
|
||||
run_id = uuid4()
|
||||
handler.metadata[run_id] = ((), {"langgraph_node": "tools"})
|
||||
|
||||
handler.on_chain_end(
|
||||
{"messages": [ToolMessage(content="[]", tool_call_id="call_1")]},
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
assert emitted == []
|
||||
|
||||
def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None:
|
||||
"""A streamed v2 message should not be emitted again from the final
|
||||
AIMessage fallback when its final id does not match `message-start`."""
|
||||
|
||||
Generated
+23
-23
@@ -871,11 +871,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1350,7 +1350,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.4.0a2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -1363,9 +1363,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1382,7 +1382,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0a7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1454,7 +1454,7 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
|
||||
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -1563,7 +1563,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1611,7 +1611,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a4"
|
||||
source = { editable = "../checkpoint-postgres" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -1658,7 +1658,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -1757,7 +1757,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.1.0"
|
||||
version = "1.1.0a2"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1849,14 +1849,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -1867,7 +1867,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1880,9 +1880,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2085,14 +2085,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.2.1"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3743,11 +3743,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -5,42 +5,12 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
from langgraph.prebuilt._tool_call_stream import ToolCallStream
|
||||
|
||||
|
||||
def _is_serialized_tool_message(value: Any) -> bool:
|
||||
"""Detect a serialized LangChain `ToolMessage` payload.
|
||||
|
||||
Example:
|
||||
{
|
||||
"lc": 1,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ToolMessage"],
|
||||
"kwargs": {"content": "raw tool result", "tool_call_id": "call_1"},
|
||||
}
|
||||
"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("type") == "constructor"
|
||||
and isinstance(value.get("id"), list)
|
||||
and value["id"][-1] == "ToolMessage"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_tool_output(output: Any) -> Any:
|
||||
if isinstance(output, ToolMessage):
|
||||
return output.content
|
||||
if _is_serialized_tool_message(output):
|
||||
kwargs = output.get("kwargs")
|
||||
if isinstance(kwargs, dict):
|
||||
return kwargs.get("content")
|
||||
return output
|
||||
|
||||
|
||||
class ToolCallTransformer(StreamTransformer):
|
||||
"""Project `tools` channel events into `ToolCallStream` handles.
|
||||
|
||||
@@ -139,7 +109,7 @@ class ToolCallTransformer(StreamTransformer):
|
||||
elif event_type == "tool-finished":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
stream._finish(_normalize_tool_output(data.get("output")))
|
||||
stream._finish(data.get("output"))
|
||||
elif event_type == "tool-error":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.1.0"
|
||||
version = "1.1.0a2"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
@@ -128,42 +128,6 @@ class TestToolCallTransformerUnit:
|
||||
assert stream.error is None
|
||||
assert "tc1" not in transformer._active
|
||||
|
||||
def test_finish_unwraps_tool_message_output(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-finished",
|
||||
"tc1",
|
||||
output=ToolMessage(content="done", tool_call_id="tc1"),
|
||||
)
|
||||
)
|
||||
assert stream.completed is True
|
||||
assert stream.output == "done"
|
||||
|
||||
def test_finish_unwraps_serialized_tool_message_output(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-finished",
|
||||
"tc1",
|
||||
output={
|
||||
"lc": 1,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ToolMessage"],
|
||||
"kwargs": {
|
||||
"content": "serialized done",
|
||||
"tool_call_id": "tc1",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert stream.completed is True
|
||||
assert stream.output == "serialized done"
|
||||
|
||||
def test_error_closes_stream(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="boom"))
|
||||
|
||||
Generated
+20
-20
@@ -214,11 +214,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -253,7 +253,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.4.0a2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -266,9 +266,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -285,7 +285,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0a7"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -298,7 +298,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
|
||||
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "." },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -369,7 +369,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -417,7 +417,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a4"
|
||||
source = { editable = "../checkpoint-postgres" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -464,7 +464,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
version = "3.1.0a1"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -507,7 +507,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.1.0"
|
||||
version = "1.1.0a2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -618,14 +618,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -636,7 +636,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -649,9 +649,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1490,11 +1490,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.15"
|
||||
__version__ = "0.3.14"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any, Literal, cast, overload
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
AssistantSelectField,
|
||||
@@ -85,9 +84,7 @@ class AssistantsClient:
|
||||
```
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def get_graph(
|
||||
@@ -145,9 +142,7 @@ class AssistantsClient:
|
||||
query_params.update(params)
|
||||
|
||||
return await self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
)
|
||||
|
||||
async def get_schemas(
|
||||
@@ -268,9 +263,7 @@ class AssistantsClient:
|
||||
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def get_subgraphs(
|
||||
@@ -300,13 +293,13 @@ class AssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return await self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -443,7 +436,7 @@ class AssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return await self.http.patch(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
f"/assistants/{assistant_id}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -486,7 +479,7 @@ class AssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
await self.http.delete(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
f"/assistants/{assistant_id}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -693,7 +686,7 @@ class AssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return await self.http.post(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -733,7 +726,7 @@ class AssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return await self.http.post(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -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") # ty: ignore[invalid-argument-type]
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: 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") # ty: ignore[invalid-argument-type]
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: 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) # ty: ignore[invalid-argument-type]
|
||||
httpx.Timeout(timeout) # type: ignore[arg-type]
|
||||
if timeout is not None
|
||||
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
Config,
|
||||
@@ -18,7 +18,6 @@ from langgraph_sdk.schema import (
|
||||
CronSortBy,
|
||||
Durability,
|
||||
Input,
|
||||
Json,
|
||||
OnCompletionBehavior,
|
||||
QueryParamTypes,
|
||||
Run,
|
||||
@@ -166,7 +165,7 @@ class CronClient:
|
||||
payload["multitask_strategy"] = multitask_strategy
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -315,9 +314,7 @@ class CronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
@@ -404,7 +401,7 @@ class CronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.patch(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
f"/runs/crons/{cron_id}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -416,7 +413,6 @@ class CronClient:
|
||||
assistant_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
metadata: Json = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: CronSortBy | None = None,
|
||||
@@ -431,8 +427,6 @@ class CronClient:
|
||||
assistant_id: The assistant ID or graph name to search for.
|
||||
thread_id: the thread ID to search for.
|
||||
enabled: The enabled status to search for.
|
||||
metadata: Metadata to filter by. Exact match filter for each KV pair.
|
||||
!!! version-added "Added in Agent Server version 0.9.0"
|
||||
limit: The maximum number of results to return.
|
||||
offset: The number of results to skip.
|
||||
headers: Optional custom headers to include with the request.
|
||||
@@ -487,8 +481,6 @@ class CronClient:
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
if sort_by:
|
||||
payload["sort_by"] = sort_by
|
||||
if sort_order:
|
||||
@@ -505,7 +497,6 @@ class CronClient:
|
||||
*,
|
||||
assistant_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
metadata: Json = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> int:
|
||||
@@ -514,8 +505,6 @@ class CronClient:
|
||||
Args:
|
||||
assistant_id: Assistant ID to filter by.
|
||||
thread_id: Thread ID to filter by.
|
||||
metadata: Metadata to filter by. Exact match filter for each KV pair.
|
||||
!!! version-added "Added in Agent Server version 0.9.0"
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -527,8 +516,6 @@ class CronClient:
|
||||
payload["assistant_id"] = assistant_id
|
||||
if thread_id:
|
||||
payload["thread_id"] = thread_id
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return await self.http.post(
|
||||
"/runs/crons/count", json=payload, headers=headers, params=params
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ import httpx
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk.schema import (
|
||||
@@ -50,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 # ty: ignore[invalid-yield]
|
||||
yield v2
|
||||
|
||||
|
||||
class RunsClient:
|
||||
@@ -338,7 +337,7 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -597,7 +596,7 @@ class RunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -822,9 +821,7 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
)
|
||||
|
||||
def on_response(res: httpx.Response):
|
||||
@@ -898,9 +895,7 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
)
|
||||
|
||||
async def get(
|
||||
@@ -935,9 +930,7 @@ class RunsClient:
|
||||
"""
|
||||
|
||||
return await self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
@@ -985,14 +978,14 @@ class RunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
"POST",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1088,7 +1081,7 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1143,7 +1136,7 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1184,7 +1177,5 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
await self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
Json,
|
||||
@@ -91,7 +90,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
f"/threads/{thread_id}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -255,7 +254,7 @@ class ThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return await self.http.patch(
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
f"/threads/{thread_id}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -288,9 +287,7 @@ class ThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -433,10 +430,7 @@ class ThreadsClient:
|
||||
|
||||
"""
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
)
|
||||
|
||||
async def prune(
|
||||
@@ -592,7 +586,7 @@ class ThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -602,7 +596,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -611,7 +605,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
f"/threads/{thread_id}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -676,10 +670,7 @@ class ThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
)
|
||||
|
||||
async def get_history(
|
||||
@@ -728,7 +719,7 @@ class ThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
f"/threads/{thread_id}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -772,7 +763,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
f"/threads/{thread_id}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -144,9 +144,8 @@ 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
|
||||
key = getattr(tz, "key", None)
|
||||
if isinstance(key, str):
|
||||
return key
|
||||
if hasattr(tz, "key"):
|
||||
return tz.key # type: ignore[union-attr]
|
||||
# Fall back to tzname for fixed-offset timezones like datetime.timezone.utc
|
||||
name = tz.tzname(None)
|
||||
if name is not None:
|
||||
@@ -198,39 +197,6 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
def _quote_path_param(value: Any) -> str:
|
||||
"""Encode a value for safe interpolation into a request path segment.
|
||||
|
||||
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
|
||||
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
|
||||
encoded because some URL-handling stacks (including ``httpx``) collapse
|
||||
them client-side as relative-path traversal before transmission. The value
|
||||
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
|
||||
directly without changing call sites.
|
||||
|
||||
A properly formed identifier (for example, a standard UUID, which contains
|
||||
no dots or reserved characters) round-trips through this function
|
||||
unchanged.
|
||||
|
||||
Raises:
|
||||
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
|
||||
Coercing those would produce misleading paths (e.g. `/threads/None`),
|
||||
so surface the caller bug instead.
|
||||
"""
|
||||
if value is None:
|
||||
raise TypeError("path parameter must not be None")
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
|
||||
quoted = quote(str(value), safe="")
|
||||
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
|
||||
# that some HTTP stacks (including ``httpx``) collapse client-side before
|
||||
# transmission. Encode the dots so the segment becomes opaque to that
|
||||
# logic. Mixed values like "agent.v1" are unaffected.
|
||||
if quoted and all(c == "." for c in quoted):
|
||||
quoted = "%2E" * len(quoted)
|
||||
return quoted
|
||||
|
||||
|
||||
_registered_transports: list[httpx.ASGITransport] = []
|
||||
|
||||
|
||||
@@ -243,7 +209,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 # ty: ignore[unresolved-import]
|
||||
from langgraph_api import asgi_transport # type: ignore[unresolved-import]
|
||||
|
||||
return asgi_transport.ASGITransport
|
||||
except ImportError:
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Literal, cast, overload
|
||||
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
@@ -84,9 +83,7 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def get_graph(
|
||||
@@ -139,9 +136,7 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
)
|
||||
|
||||
def get_schemas(
|
||||
@@ -274,9 +269,7 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
)
|
||||
|
||||
def get_subgraphs(
|
||||
@@ -304,13 +297,13 @@ class SyncAssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return self.http.get(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -445,7 +438,7 @@ class SyncAssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self.http.patch(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
f"/assistants/{assistant_id}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -488,7 +481,7 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
self.http.delete(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
f"/assistants/{assistant_id}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -692,7 +685,7 @@ class SyncAssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return self.http.post(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -731,7 +724,7 @@ class SyncAssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return self.http.post(
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -77,7 +77,7 @@ def get_sync_client(
|
||||
base_url=url,
|
||||
transport=transport,
|
||||
timeout=(
|
||||
httpx.Timeout(timeout) # ty: ignore[invalid-argument-type]
|
||||
httpx.Timeout(timeout) # type: ignore[arg-type]
|
||||
if timeout is not None
|
||||
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
@@ -18,7 +18,6 @@ from langgraph_sdk.schema import (
|
||||
CronSortBy,
|
||||
Durability,
|
||||
Input,
|
||||
Json,
|
||||
OnCompletionBehavior,
|
||||
QueryParamTypes,
|
||||
Run,
|
||||
@@ -156,7 +155,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -304,9 +303,7 @@ class SyncCronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -393,7 +390,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.patch(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
f"/runs/crons/{cron_id}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -405,7 +402,6 @@ class SyncCronClient:
|
||||
assistant_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
metadata: Json = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: CronSortBy | None = None,
|
||||
@@ -420,8 +416,6 @@ class SyncCronClient:
|
||||
assistant_id: The assistant ID or graph name to search for.
|
||||
thread_id: the thread ID to search for.
|
||||
enabled: Whether the cron job is enabled.
|
||||
metadata: Metadata to filter by. Exact match filter for each KV pair.
|
||||
!!! version-added "Added in Agent Server version 0.9.0"
|
||||
limit: The maximum number of results to return.
|
||||
offset: The number of results to skip.
|
||||
headers: Optional custom headers to include with the request.
|
||||
@@ -474,8 +468,6 @@ class SyncCronClient:
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
if sort_by:
|
||||
payload["sort_by"] = sort_by
|
||||
if sort_order:
|
||||
@@ -492,7 +484,6 @@ class SyncCronClient:
|
||||
*,
|
||||
assistant_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
metadata: Json = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> int:
|
||||
@@ -501,8 +492,6 @@ class SyncCronClient:
|
||||
Args:
|
||||
assistant_id: Assistant ID to filter by.
|
||||
thread_id: Thread ID to filter by.
|
||||
metadata: Metadata to filter by. Exact match filter for each KV pair.
|
||||
!!! version-added "Added in Agent Server version 0.9.0"
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -514,8 +503,6 @@ class SyncCronClient:
|
||||
payload["assistant_id"] = assistant_id
|
||||
if thread_id:
|
||||
payload["thread_id"] = thread_id
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return self.http.post(
|
||||
"/runs/crons/count", json=payload, headers=headers, params=params
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
@@ -50,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 # ty: ignore[invalid-yield]
|
||||
yield v2
|
||||
|
||||
|
||||
class SyncRunsClient:
|
||||
@@ -333,7 +332,7 @@ class SyncRunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -592,7 +591,7 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -826,9 +825,7 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
endpoint = (
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
)
|
||||
return self.http.request_reconnect(
|
||||
endpoint,
|
||||
@@ -882,9 +879,7 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
)
|
||||
|
||||
def get(
|
||||
@@ -917,9 +912,7 @@ class SyncRunsClient:
|
||||
"""
|
||||
|
||||
return self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def cancel(
|
||||
@@ -967,14 +960,14 @@ class SyncRunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
"POST",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1070,7 +1063,7 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1124,7 +1117,7 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1165,7 +1158,5 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
@@ -89,7 +88,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
f"/threads/{thread_id}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -251,7 +250,7 @@ class SyncThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return self.http.patch(
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
f"/threads/{thread_id}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -283,9 +282,7 @@ class SyncThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
|
||||
def search(
|
||||
self,
|
||||
@@ -424,10 +421,7 @@ class SyncThreadsClient:
|
||||
|
||||
"""
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
)
|
||||
|
||||
def prune(
|
||||
@@ -582,7 +576,7 @@ class SyncThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -592,7 +586,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -601,7 +595,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
f"/threads/{thread_id}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -663,10 +657,7 @@ class SyncThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
)
|
||||
|
||||
def get_history(
|
||||
@@ -716,7 +707,7 @@ class SyncThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
f"/threads/{thread_id}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -761,7 +752,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
f"/threads/{thread_id}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -16,10 +16,10 @@ T = TypeVar("T")
|
||||
CacheStatus = Literal["miss", "fresh", "stale", "expired"]
|
||||
|
||||
try:
|
||||
from langgraph_api.cache import ( # ty: ignore[unresolved-import]
|
||||
from langgraph_api.cache import ( # type: ignore[unresolved-import]
|
||||
cache_get as _cache_get,
|
||||
)
|
||||
from langgraph_api.cache import ( # ty: ignore[unresolved-import]
|
||||
from langgraph_api.cache import ( # type: ignore[unresolved-import]
|
||||
cache_set as _cache_set,
|
||||
)
|
||||
except ImportError:
|
||||
@@ -28,8 +28,8 @@ except ImportError:
|
||||
|
||||
|
||||
try:
|
||||
from langgraph_api.cache import SWRResult # ty: ignore[unresolved-import]
|
||||
from langgraph_api.cache import swr as _api_swr # ty: ignore[unresolved-import]
|
||||
from langgraph_api.cache import SWRResult # type: ignore[unresolved-import]
|
||||
from langgraph_api.cache import swr as _api_swr # type: ignore[unresolved-import]
|
||||
|
||||
except ImportError:
|
||||
_api_swr = None
|
||||
@@ -40,10 +40,7 @@ except ImportError:
|
||||
value: T
|
||||
status: CacheStatus
|
||||
|
||||
async def mutate(
|
||||
self,
|
||||
value: T = ..., # ty: ignore[invalid-parameter-default]
|
||||
) -> T: # ty: ignore[empty-body]
|
||||
async def mutate(self, value: T = ...) -> T: # type: ignore[assignment]
|
||||
"""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) # ty: ignore[invalid-argument-type]
|
||||
httpx.HTTPStatusError.__init__(self, message, request=req, response=response) # type: ignore[arg-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) # ty: ignore[invalid-assignment]
|
||||
context: ContextT = field(default=None) # type: ignore[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 = [self.buffer, *lines[1:]]
|
||||
lines = cast(list[BytesLike], [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: list[BytesLike] = [self.buffer]
|
||||
lines = [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, # ty: ignore[invalid-argument-type]
|
||||
data=orjson.loads(self._data) if self._data else None, # type: 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.33",
|
||||
"ty==0.0.23",
|
||||
"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) # ty: ignore[invalid-argument-type]
|
||||
yield StreamPart(event="end", data=None) # type: ignore[arg-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) # ty: ignore[invalid-argument-type]
|
||||
yield StreamPart(event="end", data=None) # type: ignore[arg-type]
|
||||
|
||||
parts: list[StreamPartV2] = list(_wrap_stream_v2_sync(mock_stream()))
|
||||
assert len(parts) == 2
|
||||
|
||||
@@ -485,165 +485,3 @@ def test_sync_update_with_enabled_parameter(enabled_value):
|
||||
)
|
||||
|
||||
assert result == cron
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_search_with_metadata():
|
||||
"""Test that CronClient.search forwards metadata in the request body."""
|
||||
cron = _cron_response()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/runs/crons/search"
|
||||
|
||||
body = json.loads(request.content)
|
||||
assert body["metadata"] == {"owner": "alice"}
|
||||
assert body["limit"] == 10
|
||||
assert body["offset"] == 0
|
||||
|
||||
return httpx.Response(200, json=[cron])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
cron_client = CronClient(http_client)
|
||||
result = await cron_client.search(metadata={"owner": "alice"})
|
||||
|
||||
assert result == [cron]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_search_omits_empty_metadata():
|
||||
"""Test that CronClient.search does not send metadata when not provided."""
|
||||
cron = _cron_response()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
assert "metadata" not in body
|
||||
return httpx.Response(200, json=[cron])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
cron_client = CronClient(http_client)
|
||||
await cron_client.search()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_count_with_metadata():
|
||||
"""Test that CronClient.count forwards metadata in the request body."""
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/runs/crons/count"
|
||||
|
||||
body = json.loads(request.content)
|
||||
assert body["metadata"] == {"team": "infra"}
|
||||
|
||||
return httpx.Response(200, json=2)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
cron_client = CronClient(http_client)
|
||||
result = await cron_client.count(metadata={"team": "infra"})
|
||||
|
||||
assert result == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_count_omits_empty_metadata():
|
||||
"""Test that CronClient.count does not send metadata when not provided."""
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
assert "metadata" not in body
|
||||
return httpx.Response(200, json=0)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
cron_client = CronClient(http_client)
|
||||
await cron_client.count()
|
||||
|
||||
|
||||
def test_sync_search_with_metadata():
|
||||
"""Test that SyncCronClient.search forwards metadata in the request body."""
|
||||
cron = _cron_response()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/runs/crons/search"
|
||||
|
||||
body = json.loads(request.content)
|
||||
assert body["metadata"] == {"owner": "alice"}
|
||||
|
||||
return httpx.Response(200, json=[cron])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
http_client = SyncHttpClient(client)
|
||||
cron_client = SyncCronClient(http_client)
|
||||
result = cron_client.search(metadata={"owner": "alice"})
|
||||
|
||||
assert result == [cron]
|
||||
|
||||
|
||||
def test_sync_search_omits_empty_metadata():
|
||||
"""Test that SyncCronClient.search does not send metadata when not provided."""
|
||||
cron = _cron_response()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
assert "metadata" not in body
|
||||
return httpx.Response(200, json=[cron])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
http_client = SyncHttpClient(client)
|
||||
cron_client = SyncCronClient(http_client)
|
||||
cron_client.search()
|
||||
|
||||
|
||||
def test_sync_count_with_metadata():
|
||||
"""Test that SyncCronClient.count forwards metadata in the request body."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/runs/crons/count"
|
||||
|
||||
body = json.loads(request.content)
|
||||
assert body["metadata"] == {"team": "infra"}
|
||||
|
||||
return httpx.Response(200, json=2)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
http_client = SyncHttpClient(client)
|
||||
cron_client = SyncCronClient(http_client)
|
||||
result = cron_client.count(metadata={"team": "infra"})
|
||||
|
||||
assert result == 2
|
||||
|
||||
|
||||
def test_sync_count_omits_empty_metadata():
|
||||
"""Test that SyncCronClient.count does not send metadata when not provided."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
assert "metadata" not in body
|
||||
return httpx.Response(200, json=0)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
http_client = SyncHttpClient(client)
|
||||
cron_client = SyncCronClient(http_client)
|
||||
cron_client.count()
|
||||
|
||||
@@ -67,6 +67,6 @@ class TestHandlerValidation:
|
||||
|
||||
with pytest.raises(TypeError, match="must accept exactly 2 parameters"):
|
||||
|
||||
@encryption.encrypt.blob # ty: ignore[invalid-argument-type]
|
||||
@encryption.encrypt.blob # type: ignore[arg-type]
|
||||
async def wrong_params(ctx):
|
||||
return ctx
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
"""Regression tests for path-segment encoding of caller-supplied identifiers.
|
||||
|
||||
Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths
|
||||
are encoded so the resulting request addresses the resource the SDK method
|
||||
indicates, even if the identifier contains characters with special meaning in
|
||||
URL paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.client import (
|
||||
AssistantsClient,
|
||||
CronClient,
|
||||
HttpClient,
|
||||
RunsClient,
|
||||
SyncAssistantsClient,
|
||||
SyncCronClient,
|
||||
SyncHttpClient,
|
||||
SyncRunsClient,
|
||||
SyncThreadsClient,
|
||||
ThreadsClient,
|
||||
)
|
||||
|
||||
|
||||
class TestQuotePathParam:
|
||||
"""Unit tests for the encoding helper itself."""
|
||||
|
||||
def test_uuid_round_trips_unchanged(self) -> None:
|
||||
uuid_value = "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert _quote_path_param(uuid_value) == uuid_value
|
||||
|
||||
def test_simple_opaque_id_round_trips_unchanged(self) -> None:
|
||||
assert _quote_path_param("thread_123") == "thread_123"
|
||||
assert _quote_path_param("asst_abc") == "asst_abc"
|
||||
|
||||
def test_slash_is_encoded(self) -> None:
|
||||
assert _quote_path_param("foo/bar") == "foo%2Fbar"
|
||||
|
||||
def test_bare_dot_segments_are_encoded(self) -> None:
|
||||
# All-dot strings are encoded to make them opaque to HTTP stacks that
|
||||
# collapse "./.." path segments client-side.
|
||||
assert _quote_path_param(".") == "%2E"
|
||||
assert _quote_path_param("..") == "%2E%2E"
|
||||
assert _quote_path_param("...") == "%2E%2E%2E"
|
||||
# Mixed values that happen to contain dots are not affected.
|
||||
assert _quote_path_param("agent.v1") == "agent.v1"
|
||||
# Subsequent ``/`` characters are encoded regardless.
|
||||
assert _quote_path_param("../bar") == "..%2Fbar"
|
||||
|
||||
def test_full_pivot_payload_is_encoded(self) -> None:
|
||||
# A caller-supplied identifier that, if interpolated raw, would route
|
||||
# the request to a different resource type.
|
||||
payload = "../assistants/abc-123"
|
||||
encoded = _quote_path_param(payload)
|
||||
assert encoded == "..%2Fassistants%2Fabc-123"
|
||||
assert "/" not in encoded
|
||||
|
||||
def test_non_string_values_are_coerced_to_str(self) -> None:
|
||||
import uuid
|
||||
|
||||
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert _quote_path_param(uid) == str(uid)
|
||||
assert _quote_path_param(42) == "42"
|
||||
|
||||
def test_none_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be None"):
|
||||
_quote_path_param(None)
|
||||
|
||||
def test_bytes_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(b"bytes")
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(bytearray(b"bytes"))
|
||||
|
||||
|
||||
def _wire_path(request: httpx.Request) -> str:
|
||||
"""Return the path as it goes on the wire (preserves percent-encoding)."""
|
||||
return request.url.raw_path.decode("ascii")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAsyncPathEncoding:
|
||||
"""Async-client tests that verify the encoded path actually lands on the wire.
|
||||
|
||||
Note: ``request.url.path`` is the percent-decoded display form. The bytes
|
||||
that actually go on the wire are in ``request.url.raw_path``; that is what
|
||||
the server's router sees and what these tests inspect.
|
||||
"""
|
||||
|
||||
async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# The identifier is encoded so the wire path stays inside `/threads/...`.
|
||||
# The encoded segment must not contain literal slashes that could let
|
||||
# the server re-route to a different resource type.
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert "%2F" in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.update("../assistants/abc-123", metadata={"x": 1})
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "PATCH"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.delete("../runs/crons/some-cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_assistants_get_with_pivot_payload_stays_on_assistants(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = RunsClient(HttpClient(client))
|
||||
# Both identifier values supplied as path-traversal payloads.
|
||||
await runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
# The path should match `/threads/{quoted_thread}/runs/{quoted_run}`
|
||||
# exactly. Neither segment should contain literal slashes.
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
# Expected shape: ['', 'threads', '<encoded ..>', 'runs', '<encoded ..>']
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
# Encoded thread_id and run_id are between literal slashes.
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = CronClient(HttpClient(client))
|
||||
await crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/threads/{tid}/state/...`, not pivot to
|
||||
# `/threads/tid-1/runs`.
|
||||
assert wire.startswith("/threads/tid-1/state/")
|
||||
# Strip query string before checking the checkpoint segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/threads/tid-1/state/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fruns"
|
||||
|
||||
async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get_subgraphs("aid-1", namespace="../foo")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/assistants/{aid}/subgraphs/...`.
|
||||
assert wire.startswith("/assistants/aid-1/subgraphs/")
|
||||
# Strip query string before checking the namespace segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/assistants/aid-1/subgraphs/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Ffoo"
|
||||
|
||||
async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None:
|
||||
"""The all-dot encoding branch must survive httpx's relative-path collapse."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("..")
|
||||
|
||||
assert len(captured) == 1
|
||||
# The all-dot identifier is fully percent-encoded so httpx does NOT
|
||||
# collapse it client-side as a relative-path traversal.
|
||||
assert captured[0].endswith("/threads/%2E%2E")
|
||||
|
||||
async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get(".")
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].endswith("/threads/%2E")
|
||||
|
||||
async def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
"""Legitimate UUID identifiers round-trip without encoding artifacts."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
|
||||
|
||||
class TestSyncPathEncoding:
|
||||
"""Sync-client tests that mirror the async coverage on a representative subset."""
|
||||
|
||||
def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = SyncAssistantsClient(SyncHttpClient(client))
|
||||
assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = SyncRunsClient(SyncHttpClient(client))
|
||||
runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = SyncCronClient(SyncHttpClient(client))
|
||||
crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
Generated
+36
-36
@@ -227,11 +227,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -266,7 +266,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.4.0a2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -279,9 +279,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -298,7 +298,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0a7"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -311,7 +311,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
|
||||
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "." },
|
||||
@@ -382,7 +382,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -430,7 +430,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.1.0"
|
||||
version = "1.1.0a2"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -533,14 +533,14 @@ dev = [
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.33" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -551,7 +551,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.0"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -564,9 +564,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1275,26 +1275,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.33"
|
||||
version = "0.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1320,11 +1320,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user