Compare commits

..
Author SHA1 Message Date
Dariel Dato-onandClaude Opus 5.5 eec1521b69 test(langgraph): skip async @task RemoteGraph retry case below Python 3.11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:25:15 -05:00
Dariel Dato-onandClaude Opus 5.5 f637d52b93 fix(langgraph): keep RemoteGraph interrupts and errors intact on retry
- Raise when a remote pauses with an empty interrupt list (interrupt_before/after).
- Raise remote errors that arrive after an interrupt instead of skipping them.
- Forward each resume map answer to a remote at most once per run. A retry of the
  node or any ancestor re-raises the pending interrupts instead of resending it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:03 -05:00
Dariel Dato-onandClaude Opus 5.5 9a0b966936 fix(langgraph): send each RemoteGraph only its own resume map entries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:38 -05:00
Dariel Dato-onandopen-swe[bot] cf9c171755 fix(langgraph): only resume RemoteGraph threads that are interrupted
CONFIG_KEY_RESUMING is also set on retries and the resume map stays in the
config for the whole run, so check the remote thread before forwarding a
resume. Interrupted threads without a matching resume value re-raise their
pending interrupts instead of receiving the parent state as new input.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-08 21:46:30 +00:00
Dariel Dato-onandopen-swe[bot] 6be0ddf9ad fix(langgraph): forward parent resume to RemoteGraph subgraph nodes
When a RemoteGraph runs as a subgraph node and the parent is resumed with
Command(resume=...), forward the resume value to the remote thread as a
command instead of re-sending the parent state as new input. Also surface
every interrupt from the remote run so they can be resumed by id.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-08 21:17:38 +00:00
115 changed files with 2170 additions and 8440 deletions

No files matched your search

-33
View File
@@ -24,39 +24,6 @@ TEST=path/to/test.py make test
Other pytest arguments can also be supplied inside the `TEST` variable.
## Direction: LangGraph is a runtime
LangGraph is consolidating as the low-level runtime for agents and workflows:
graph execution, checkpointing, streaming, and interrupt/resume. Critical
production systems and agent harnesses run on it, so correctness and stability
come first. It is moving away from being a friendly SDK for building agents
directly; agent development belongs one layer up, on a harness — Deep Agents
(github.com/langchain-ai/deepagents, a separate repo) is the recommended one.
Optimize contributions for runtime correctness, reliability, performance, and
simplicity — not for the shortest agent example.
- One way to do each thing. A change never adds a way; removals are always
deliberate (see below). The number of ways only goes down. No aliases,
overloads, extra accepted types, implicit defaults, or customization hooks
for convenience: ergonomics alone never justify new public surface.
- The mandate applies to everything that implements runtime behavior, in
whichever repo it lives: the graph engine (`langgraph`, `langgraph-core`),
the checkpointers (`checkpoint*`), and the server (`langgraph-api`). The
client SDKs and CLI stay ergonomic but are thin, faithful clients, never a
second implementation of runtime behavior.
- Agent-level packages (`prebuilt`, `langgraph-supervisor`, `langgraph-swarm`,
`langgraph-cua`) are maintenance-only: fix bugs, don't add agent features.
New examples and docs that build agents should use Deep Agents.
- Remove deliberately, not opportunistically. Removing or changing public
behavior must be the stated purpose of the PR and go through the deprecation
process — replacement in place, removal version recorded, migration note.
Never drop a default or overload as a side effect of an unrelated change.
- Behavior changes land in Python and JS together (the implementations live in
separate repos; note in the PR when the other one needs a matching change).
- If a task asks for something this section rules out, say so and propose the
runtime-level or harness-level alternative instead of quietly implementing it.
## Libraries
The repository contains several Python and JavaScript/TypeScript libraries.
@@ -14,8 +14,8 @@ async def memory_checkpointer():
@pytest.mark.asyncio
async def test_validate_memory():
"""InMemorySaver passes the tests of every capability it implements."""
async def test_validate_memory_base():
"""InMemorySaver passes all base capability tests."""
report = await validate(memory_checkpointer)
report.print_report()
assert report.passed_all(), f"Capability tests failed: {report.to_dict()}"
assert report.passed_all_base(), f"Base tests failed: {report.to_dict()}"
+3 -3
View File
@@ -36,11 +36,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
@@ -27,15 +27,10 @@ from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import (
_DELTA_PAGE_SIZE,
PRUNE_DELETE_THREADS_SQL,
PRUNE_STRATEGIES,
BasePostgresSaver,
_build_delta_stage1_sql,
_build_delta_stage2_sql,
_DeltaStage2Row,
_execute,
_keep_latest,
_refuse_delta_threads,
)
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
@@ -406,73 +401,6 @@ class PostgresSaver(BasePostgresSaver):
(str(thread_id),),
)
def prune(
self,
thread_ids: Sequence[str],
*,
strategy: str = "keep_latest",
) -> None:
"""Prune checkpoints for the given threads.
`"delete"` removes every checkpoint, write and blob of the threads, in
one transaction.
`"keep_latest"` keeps the latest checkpoint of each namespace (the one
`get_tuple` loads, plus its parent when it predates v4 and has pending
sends), with its pending writes and the blobs it references, and
deletes the rest. A kept checkpoint whose parent it deletes is left with
no parent. A subgraph call's namespace is deleted with the root
checkpoint the call started from, except a `checkpointer=True`
subgraph's, which keeps its latest checkpoint. Each thread is pruned in
its own transaction, on one snapshot of it, so a run appending to the
thread meanwhile is safe; forking or replaying from a checkpoint being
deleted is not. A thread that fails because another transaction changed
it leaves the threads before it pruned, and retrying is safe.
`"keep_latest"` refuses threads that use a `DeltaChannel`, since it
rebuilds the latest value from older checkpoints: if any requested
thread does, nothing is pruned. A thread that starts using one before
its turn in the batch is refused then, after the threads before it.
Args:
thread_ids: The thread IDs to prune.
strategy: `"keep_latest"` or `"delete"`.
Raises:
TypeError: If `thread_ids` is a single string.
ValueError: If `strategy` is unknown.
NotImplementedError: If `strategy` is `"keep_latest"` and a thread
uses a `DeltaChannel`.
psycopg.errors.SerializationFailure: If another transaction, such
as a second prune, changes a thread's rows while `"keep_latest"`
prunes it.
psycopg.errors.DeadlockDetected: If a second prune of a thread runs
at the same time.
"""
if isinstance(thread_ids, str):
raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
if strategy not in PRUNE_STRATEGIES:
raise ValueError(f"Unknown prune strategy: {strategy!r}")
thread_ids = [str(t) for t in thread_ids]
if not thread_ids:
return
if strategy == "delete":
with self._cursor() as cur, cur.connection.transaction():
for sql in PRUNE_DELETE_THREADS_SQL:
cur.execute(sql, (thread_ids,))
return
with self._cursor() as cur:
_execute(cur, _refuse_delta_threads(thread_ids))
for thread_id in thread_ids:
with self._cursor() as cur:
if self.pipe:
# The transaction has to start with its isolation level, and
# in pipeline mode it would take in what ran since the last
# sync.
self.pipe.sync()
with cur.connection.transaction():
_execute(cur, _keep_latest(thread_id))
@contextmanager
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
"""Create a database cursor as a context manager.
@@ -523,8 +451,7 @@ class PostgresSaver(BasePostgresSaver):
* Stage 1 (paged): dynamic SELECT over `checkpoints` with three
columns per channel: its version, an `EXISTS` probe for a stored
blob at that version, and its inline value. Pages newest-first by
`checkpoint_id`, starting at the target; page size is
`_DELTA_PAGE_SIZE`.
`checkpoint_id` with a cursor; page size is `_DELTA_PAGE_SIZE`.
Stops paging when every channel has found its seed or a page comes
back short.
@@ -541,7 +468,7 @@ class PostgresSaver(BasePostgresSaver):
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if not checkpoint_id:
if checkpoint_id is None:
target = self.get_tuple(config)
if target is None:
return {ch: {"writes": []} for ch in channels}
@@ -549,7 +476,7 @@ class PostgresSaver(BasePostgresSaver):
# Stage 1: paged K-JSONB-lookup scan, walking the parent chain in
# Python after each page. Stops as soon as every channel has its seed.
stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
parent_of: dict[str, str | None] = {}
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
@@ -559,7 +486,7 @@ class PostgresSaver(BasePostgresSaver):
seed_inline_by_ch: dict[str, Any] = {}
walk_cursor_by_ch: dict[str, str | None] = {}
seeded: set[str] = set()
cursor: str | None = checkpoint_id
cursor: str | None = None
with self._cursor() as cur:
while True:
@@ -568,7 +495,7 @@ class PostgresSaver(BasePostgresSaver):
# ver_i, blob channel, blob version, inline_i
stage1_params.extend([ch, ch, ch, ch])
stage1_params.extend(
[thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
)
cur.execute(stage1_sql, stage1_params)
page = cur.fetchall()
@@ -600,7 +527,6 @@ class PostgresSaver(BasePostgresSaver):
if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
break
cursor = oldest
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
# Stage 2: per-channel UNION ALL — one writes branch per channel
# with non-empty chain, plus one blob branch per seeded channel.
@@ -27,15 +27,10 @@ from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import (
_DELTA_PAGE_SIZE,
PRUNE_DELETE_THREADS_SQL,
PRUNE_STRATEGIES,
BasePostgresSaver,
_aexecute,
_build_delta_stage1_sql,
_build_delta_stage2_sql,
_DeltaStage2Row,
_keep_latest,
_refuse_delta_threads,
)
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
@@ -365,55 +360,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
(str(thread_id),),
)
async def aprune(
self,
thread_ids: Sequence[str],
*,
strategy: str = "keep_latest",
) -> None:
"""Prune checkpoints for the given threads.
See `PostgresSaver.prune` for what each strategy keeps.
Args:
thread_ids: The thread IDs to prune.
strategy: `"keep_latest"` or `"delete"`.
Raises:
TypeError: If `thread_ids` is a single string.
ValueError: If `strategy` is unknown.
NotImplementedError: If `strategy` is `"keep_latest"` and a thread
uses a `DeltaChannel`.
psycopg.errors.SerializationFailure: If another transaction, such
as a second prune, changes a thread's rows while `"keep_latest"`
prunes it.
psycopg.errors.DeadlockDetected: If a second prune of a thread runs
at the same time.
"""
if isinstance(thread_ids, str):
raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
if strategy not in PRUNE_STRATEGIES:
raise ValueError(f"Unknown prune strategy: {strategy!r}")
thread_ids = [str(t) for t in thread_ids]
if not thread_ids:
return
if strategy == "delete":
async with self._cursor() as cur, cur.connection.transaction():
for sql in PRUNE_DELETE_THREADS_SQL:
await cur.execute(sql, (thread_ids,))
return
async with self._cursor() as cur:
await _aexecute(cur, _refuse_delta_threads(thread_ids))
for thread_id in thread_ids:
async with self._cursor() as cur:
if self.pipe:
# The transaction has to start with its isolation level, and
# in pipeline mode it would take in what ran since the last
# sync.
await self.pipe.sync()
async with cur.connection.transaction():
await _aexecute(cur, _keep_latest(thread_id))
@asynccontextmanager
async def _cursor(
self, *, pipeline: bool = False
@@ -471,13 +417,13 @@ class AsyncPostgresSaver(BasePostgresSaver):
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if not checkpoint_id:
if checkpoint_id is None:
target = await self.aget_tuple(config)
if target is None:
return {ch: {"writes": []} for ch in channels}
checkpoint_id = target.config["configurable"]["checkpoint_id"]
stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
parent_of: dict[str, str | None] = {}
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
@@ -487,7 +433,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
seed_inline_by_ch: dict[str, Any] = {}
walk_cursor_by_ch: dict[str, str | None] = {}
seeded: set[str] = set()
cursor: str | None = checkpoint_id
cursor: str | None = None
async with self._cursor() as cur:
while True:
@@ -496,7 +442,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
# ver_i, blob channel, blob version, inline_i
stage1_params.extend([ch, ch, ch, ch])
stage1_params.extend(
[thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
)
await cur.execute(stage1_sql, stage1_params)
page = await cur.fetchall()
@@ -526,7 +472,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
break
cursor = oldest
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
@@ -742,33 +687,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
self.adelete_thread(thread_id), self.loop
).result()
def prune(
self,
thread_ids: Sequence[str],
*,
strategy: str = "keep_latest",
) -> None:
"""Prune checkpoints for the given threads.
See `PostgresSaver.prune` for what each strategy keeps.
Args:
thread_ids: The thread IDs to prune.
strategy: `"keep_latest"` or `"delete"`.
"""
try:
if asyncio.get_running_loop() is self.loop:
raise asyncio.InvalidStateError(
"Synchronous calls to AsyncPostgresSaver are only allowed from a "
"different thread. From the main thread, use the async interface. "
"For example, use `await checkpointer.aprune(...)`."
)
except RuntimeError:
pass
return asyncio.run_coroutine_threadsafe(
self.aprune(thread_ids, strategy=strategy), self.loop
).result()
def get_delta_channel_history(
self, *, config: RunnableConfig, channels: Sequence[str]
) -> Mapping[str, DeltaChannelHistory]:
@@ -2,7 +2,7 @@ from __future__ import annotations
import random
import warnings
from collections.abc import Generator, Mapping, Sequence
from collections.abc import Mapping, Sequence
from importlib.metadata import version as get_version
from typing import Any, TypedDict, cast
@@ -17,8 +17,6 @@ from langgraph.checkpoint.base import (
writes_sort_key,
)
from langgraph.checkpoint.serde.types import TASKS
from psycopg import AsyncCursor, Cursor
from psycopg.rows import DictRow
from psycopg.types.json import Jsonb
# Page size for stage-1 paged scan in `get_delta_channel_history`. Internal
@@ -162,218 +160,6 @@ INSERT_CHECKPOINT_WRITES_SQL = """
"""
PRUNE_STRATEGIES = ("keep_latest", "delete")
PRUNE_DELETE_THREADS_SQL = (
"DELETE FROM checkpoints WHERE thread_id = ANY(%s)",
"DELETE FROM checkpoint_blobs WHERE thread_id = ANY(%s)",
"DELETE FROM checkpoint_writes WHERE thread_id = ANY(%s)",
)
# The prune statements below never join two sets of a thread's rows: a join is
# planned from per-thread row estimates, which are worst for the few huge
# threads worth pruning, and can go quadratic. They look rows up by primary key
# instead (`LATERAL ... LIMIT 1`, `= ANY`).
# A `DeltaChannel` leaves update counters in checkpoint metadata, or a
# snapshot: an inline `true` marker with a blob behind it, which a plain `True`
# value never has. LangGraph.js inlines no values, but marks the threads its
# DeltaChannel graphs start with `delta_writes_versioned`.
PRUNE_DELTA_THREADS_SQL = """
SELECT thread_id FROM checkpoints
WHERE thread_id = ANY(%s) AND (
metadata ? 'counters_since_delta_snapshot'
OR metadata ? 'delta_writes_versioned'
)
UNION
SELECT c.thread_id
FROM checkpoints c
CROSS JOIN LATERAL jsonb_each(c.checkpoint -> 'channel_values') AS v
CROSS JOIN LATERAL (
SELECT 1 FROM checkpoint_blobs b
WHERE b.thread_id = c.thread_id
AND b.checkpoint_ns = c.checkpoint_ns
AND b.channel = v.key
AND b.version = c.checkpoint -> 'channel_versions' ->> v.key
LIMIT 1
) AS snapshot
WHERE c.thread_id = ANY(%s) AND v.value = 'true'::jsonb
"""
# Per namespace: the latest checkpoint, the parent it reads its pending sends
# from when it predates v4, and the root graph's checkpoint its subgraph call
# started from, with whether that one is saved.
PRUNE_HEADS_SQL = f"""
SELECT
h.checkpoint_ns,
h.checkpoint_id AS head,
CASE WHEN (c.checkpoint ->> 'v')::int < 4 AND EXISTS (
SELECT 1 FROM checkpoint_writes w
WHERE w.thread_id = %s
AND w.checkpoint_ns = h.checkpoint_ns
AND w.checkpoint_id = c.parent_checkpoint_id
AND w.channel = '{TASKS}'
) THEN c.parent_checkpoint_id END AS sends_parent,
c.metadata -> 'parents' ->> '' AS root,
EXISTS (
SELECT 1 FROM checkpoints r
WHERE r.thread_id = %s AND r.checkpoint_ns = ''
AND r.checkpoint_id = c.metadata -> 'parents' ->> ''
) AS root_saved
FROM (
SELECT DISTINCT ON (checkpoint_ns) checkpoint_ns, checkpoint_id
FROM checkpoints
WHERE thread_id = %s
ORDER BY checkpoint_ns DESC, checkpoint_id DESC
) AS h
CROSS JOIN LATERAL (
SELECT checkpoint, parent_checkpoint_id, metadata FROM checkpoints
WHERE thread_id = %s
AND checkpoint_ns = h.checkpoint_ns
AND checkpoint_id = h.checkpoint_id
) AS c
"""
PRUNE_SNAPSHOT_SQL = "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
PRUNE_UNKEPT_SQL = """
SELECT checkpoint_id FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
"""
# A run saves a superstep's writes in the background, where they can commit
# before their checkpoint does, so they go by the checkpoints deleted.
PRUNE_WRITES_SQL = """
DELETE FROM checkpoint_writes
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = ANY(%s)
"""
PRUNE_CHECKPOINTS_SQL = """
WITH deleted AS (
DELETE FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
RETURNING checkpoint -> 'channel_versions' AS versions
)
SELECT DISTINCT v.key AS channel, v.value AS version
FROM deleted
CROSS JOIN LATERAL jsonb_each_text(deleted.versions) AS v
"""
PRUNE_PARENTS_SQL = """
UPDATE checkpoints SET parent_checkpoint_id = NULL
WHERE thread_id = %s AND checkpoint_ns = %s
AND checkpoint_id = ANY(%s) AND parent_checkpoint_id <> ALL(%s)
"""
PRUNE_REFERENCED_BLOBS_SQL = """
SELECT DISTINCT v.key AS channel, v.value AS version
FROM checkpoints c
CROSS JOIN LATERAL jsonb_each_text(c.checkpoint -> 'channel_versions') AS v
WHERE c.thread_id = %s AND c.checkpoint_ns = %s
"""
PRUNE_BLOBS_SQL = """
DELETE FROM checkpoint_blobs
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s AND version = ANY(%s)
"""
PRUNE_SUBGRAPH_CALLS_SQL = (
"DELETE FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
"DELETE FROM checkpoint_writes WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
"DELETE FROM checkpoint_blobs WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
)
def _garbage_blobs(
freed: Sequence[Mapping[str, Any]], referenced: Sequence[Mapping[str, Any]]
) -> dict[str, list[str]]:
"""Blobs a deleted checkpoint used and no kept one does, by channel."""
kept = {(r["channel"], r["version"]) for r in referenced}
garbage: dict[str, list[str]] = {}
for r in freed:
if (r["channel"], r["version"]) not in kept:
garbage.setdefault(r["channel"], []).append(r["version"])
return garbage
_Statements = Generator[tuple[str, Sequence[Any], bool], list[DictRow], None]
def _refuse_delta_threads(thread_ids: Sequence[str]) -> _Statements:
rows = yield PRUNE_DELTA_THREADS_SQL, (thread_ids, thread_ids), True
if delta := sorted(r["thread_id"] for r in rows):
raise NotImplementedError(
f"keep_latest can't prune threads that use a DeltaChannel: {', '.join(delta)}"
)
def _keep_latest(thread_id: str) -> _Statements:
"""The statements of one thread's keep_latest transaction, for `_execute`
and `_aexecute` to run. Each comes with whether its rows are sent back.
They all read one snapshot, so what a run saves meanwhile is neither seen
nor deleted, whatever its checkpoint ID.
"""
yield PRUNE_SNAPSHOT_SQL, (), False
heads = yield PRUNE_HEADS_SQL, (thread_id,) * 4, True
# Catches a thread that started using a DeltaChannel after the batch check.
yield from _refuse_delta_threads([thread_id])
kept = {
h["checkpoint_ns"]: [i for i in (h["head"], h["sends_parent"]) if i]
for h in heads
}
# A subgraph call's namespace has its task ID after a ":", unlike a
# `checkpointer=True` subgraph's, whose state outlives each call. A call
# goes with the root checkpoint it started from, but only once that one is
# saved: runs save in the background, so a call can commit first.
calls = {
h["checkpoint_ns"]
for h in heads
if ":" in h["checkpoint_ns"]
and h["root_saved"]
and h["root"] not in kept.get("", ())
}
if calls:
for sql in PRUNE_SUBGRAPH_CALLS_SQL:
yield sql, (thread_id, list(calls)), False
for ns, ids in kept.items():
if ns in calls:
continue
unkept = yield PRUNE_UNKEPT_SQL, (thread_id, ns, ids), True
if unkept:
deleted = [r["checkpoint_id"] for r in unkept]
yield PRUNE_WRITES_SQL, (thread_id, ns, deleted), False
freed = yield PRUNE_CHECKPOINTS_SQL, (thread_id, ns, ids), True
yield PRUNE_PARENTS_SQL, (thread_id, ns, ids, ids), False
if freed:
referenced = yield PRUNE_REFERENCED_BLOBS_SQL, (thread_id, ns), True
for channel, versions in _garbage_blobs(freed, referenced).items():
yield PRUNE_BLOBS_SQL, (thread_id, ns, channel, versions), False
# Unprepared, so Postgres plans each statement knowing its arrays: a cached
# generic plan checks a row against an `= ANY(...)` array one element at a time.
def _execute(cur: Cursor[DictRow], statements: _Statements) -> None:
try:
sql, params, fetch = next(statements)
while True:
cur.execute(sql, params, prepare=False)
sql, params, fetch = statements.send(cur.fetchall() if fetch else [])
except StopIteration:
pass
async def _aexecute(cur: AsyncCursor[DictRow], statements: _Statements) -> None:
try:
sql, params, fetch = next(statements)
while True:
await cur.execute(sql, params, prepare=False)
rows = await cur.fetchall() if fetch else []
sql, params, fetch = statements.send(rows)
except StopIteration:
pass
class _DeltaStage2Row(TypedDict, total=False):
"""One row from `_build_delta_stage2_sql` (a UNION ALL of writes and blobs)."""
@@ -392,13 +178,10 @@ class _DeltaStage2Row(TypedDict, total=False):
# `_build_delta_stage2_sql` document their shapes.
def _build_delta_stage1_sql(
channels: Sequence[str], *, paged: bool, include_cursor: bool = False
) -> str:
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
"""Build stage 1 SQL with K parallel version lookups + seed probes.
For channels=["messages", "files"] (with `paged=True, include_cursor=True`)
the result is::
For channels=["messages", "files"] (with `paged=True`) the result is::
SELECT checkpoint_id, parent_checkpoint_id,
checkpoint -> 'channel_versions' ->> %s AS ver_0,
@@ -414,7 +197,7 @@ def _build_delta_stage1_sql(
checkpoint -> 'channel_values' -> %s AS inline_1
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s
AND checkpoint_id <= %s
AND (%s::text IS NULL OR checkpoint_id < %s)
ORDER BY checkpoint_id DESC
LIMIT %s
@@ -455,16 +238,9 @@ def _build_delta_stage1_sql(
and uses safe identifiers).
Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns,
cursor, page_size]` when `paged=True` — four per channel: the version
lookup, the blob's channel, the version the blob must match, and the inline
lookup.
Pages run newest-first from the target down. The first page passes the
target as the cursor with `include_cursor=True`, so it opens with the
target's own row, whose parent starts the walk; each later page continues
below the oldest row read. A checkpoint's ancestors have smaller ids (uuid6
is time-ordered, which `get_tuple` also relies on to find the latest
checkpoint), so no row newer than the target is part of its chain.
cursor, cursor, page_size]` when `paged=True` — four per channel: the
version lookup, the blob's channel, the version the blob must match, and the
inline lookup.
When `paged=False`, the WHERE has no cursor predicate and there's no
LIMIT/ORDER BY — kept as a non-public helper for tests/diagnostics.
@@ -488,7 +264,7 @@ def _build_delta_stage1_sql(
)
if paged:
sql += (
f" AND checkpoint_id {'<=' if include_cursor else '<'} %s"
" AND (%s::text IS NULL OR checkpoint_id < %s)"
" ORDER BY checkpoint_id DESC LIMIT %s"
)
return sql
@@ -637,8 +413,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
materialized at this point),
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
a later page; the cursor stays put), or
(d) the target's own row isn't in `parent_of` (the target doesn't
exist, so the walk never starts).
(d) the target's own row isn't in `parent_of` yet (the walk has
not started; no cursor is set, so a later page retries).
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
`walk_cursor_by_ch`, and `seeded` in place.
@@ -646,9 +422,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
for i, ch in enumerate(channels):
if ch in seeded:
continue
# The first page opens with the target's row, so it's missing only
# when the target doesn't exist; a `None` cursor would read as
# "target is a root".
# Pages start at the thread head, so the target may not have
# loaded yet; a `None` cursor would read as "target is a root".
if ch not in walk_cursor_by_ch:
if target_id not in parent_of:
continue
@@ -36,7 +36,6 @@ from langgraph.store.base import (
ensure_embeddings,
get_text_at_path,
tokenize_path,
validate_op_namespace,
)
from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.rows import DictRow, dict_row
@@ -1387,7 +1386,6 @@ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
tot = 0
for idx, op in enumerate(ops):
validate_op_namespace(op)
grouped_ops[type(op)].append((idx, op))
tot += 1
return grouped_ops, tot
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-postgres"
version = "3.2.0"
version = "3.1.2"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
@@ -13,10 +13,8 @@ import pytest
from langchain_core.embeddings import Embeddings
from langgraph.store.base import (
GetOp,
InvalidNamespaceError,
Item,
ListNamespacesOp,
MatchCondition,
PutOp,
SearchOp,
)
@@ -873,65 +871,3 @@ async def test_omit_expired_search_pagination(store: AsyncPostgresStore) -> None
page2 = await store.asearch(ns, limit=2, offset=2)
assert [i.key for i in page1] == ["a", "b"]
assert [i.key for i in page2] == ["c"]
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
async def test_abatch_rejects_invalid_namespace_labels(
store: AsyncPostgresStore, namespace: tuple
) -> None:
await store.aput(("foo", "bar"), "key", {"original": True})
for op in (
GetOp(namespace, "key"),
GetOp(namespace, "key", refresh_ttl=True),
PutOp(namespace, "key", {"changed": True}),
PutOp(namespace, "key", None),
SearchOp(namespace),
ListNamespacesOp((MatchCondition("prefix", namespace),)),
ListNamespacesOp((MatchCondition("suffix", namespace),)),
):
with pytest.raises(InvalidNamespaceError):
await store.abatch([op])
item = await store.aget(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
async def test_invalid_namespace_only_fails_its_own_call(
store: AsyncPostgresStore,
) -> None:
"""Concurrent calls share one `abatch`, which fails every op if it raises.
Labels are checked before an op is queued, so one caller's bad label cannot
fail another caller's request.
"""
await store.aput(("foo", "bar"), "key", {"original": True})
valid, invalid = await asyncio.gather(
store.aget(("foo", "bar"), "key"),
store.aget(("foo.bar",), "key"),
return_exceptions=True,
)
assert isinstance(valid, Item) and valid.value == {"original": True}
assert isinstance(invalid, InvalidNamespaceError)
async def test_sync_methods_reject_invalid_namespace_labels(
store: AsyncPostgresStore,
) -> None:
"""The sync wrappers run off the event loop thread and must validate too."""
await store.aput(("foo", "bar"), "key", {"original": True})
for call in (
lambda: store.get(("foo.bar",), "key"),
lambda: store.search(("foo.bar",)),
lambda: store.delete(("foo.bar",), "key"),
lambda: store.list_namespaces(prefix=("foo.bar",)),
lambda: store.batch([GetOp(("foo.bar",), "key")]),
):
with pytest.raises(InvalidNamespaceError):
await asyncio.to_thread(call)
item = await store.aget(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import uuid4
@@ -15,7 +14,7 @@ from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE, BasePostgresSaver
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE
from tests.conftest import DEFAULT_URI
CHANNEL = "items"
@@ -24,8 +23,8 @@ SEED_STEP = 1
SEED_VALUE = [10, 20]
TARGET_STEP = 4
# The real page size is the control; the rest split the walk from the target
# to its seed across pages.
# The real page size is the control; the rest leave the target off the first
# page (three checkpoints are newer than it).
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
@@ -93,7 +92,7 @@ def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
@pytest.mark.parametrize("page_size", PAGE_SIZES)
async def test_async_walk_continues_across_pages(
async def test_async_target_older_than_the_first_page(
page_size: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
@@ -107,7 +106,7 @@ async def test_async_walk_continues_across_pages(
@pytest.mark.parametrize("page_size", PAGE_SIZES)
def test_sync_walk_continues_across_pages(
def test_sync_target_older_than_the_first_page(
page_size: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
@@ -120,84 +119,6 @@ def test_sync_walk_continues_across_pages(
_assert_history(result[CHANNEL], page_size)
def _record_rows_read(monkeypatch: pytest.MonkeyPatch) -> list[str]:
read: list[str] = []
ingest = BasePostgresSaver._ingest_stage1_page
def record(rows: Sequence[Mapping[str, Any]], *args: Any) -> str | None:
read.extend(row["checkpoint_id"] for row in rows)
return ingest(rows, *args)
monkeypatch.setattr(BasePostgresSaver, "_ingest_stage1_page", staticmethod(record))
return read
def _ids_from_target_down(configs: list[dict]) -> list[str]:
return [c["configurable"]["checkpoint_id"] for c in configs[TARGET_STEP::-1]]
async def test_async_walk_reads_nothing_newer_than_the_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
read = _record_rows_read(monkeypatch)
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
configs = await _abuild_chain(saver)
result = await saver.aget_delta_channel_history(
config=configs[TARGET_STEP], channels=[CHANNEL]
)
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
assert read == _ids_from_target_down(configs)
def test_sync_walk_reads_nothing_newer_than_the_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
read = _record_rows_read(monkeypatch)
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
saver.setup()
configs = _build_chain(saver)
result = saver.get_delta_channel_history(
config=configs[TARGET_STEP], channels=[CHANNEL]
)
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
assert read == _ids_from_target_down(configs)
def _empty_checkpoint_id(config: dict) -> dict:
return {"configurable": {**config["configurable"], "checkpoint_id": ""}}
async def test_async_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
configs = await _abuild_chain(saver)
latest = await saver.aget_delta_channel_history(
config=configs[-1], channels=[CHANNEL]
)
result = await saver.aget_delta_channel_history(
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
)
assert latest[CHANNEL]["writes"]
assert result == latest
def test_sync_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
saver.setup()
configs = _build_chain(saver)
latest = saver.get_delta_channel_history(config=configs[-1], channels=[CHANNEL])
result = saver.get_delta_channel_history(
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
)
assert latest[CHANNEL]["writes"]
assert result == latest
async def test_root_target_has_no_history_and_still_terminates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -1,540 +0,0 @@
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
from uuid import uuid4
import pytest
from langgraph.checkpoint.base import empty_checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.serde.types import TASKS, _DeltaSnapshot
from psycopg import AsyncConnection
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.postgres.base import (
PRUNE_DELTA_THREADS_SQL,
PRUNE_HEADS_SQL,
PRUNE_REFERENCED_BLOBS_SQL,
)
from tests.conftest import DEFAULT_POSTGRES_URI, DEFAULT_URI
from tests.test_async import _saver as _async_saver
from tests.test_sync import _saver as _sync_saver
SAVERS = ["base", "pool", "pipe", "aio_base", "aio_pool", "aio_pipe"]
@asynccontextmanager
async def _saver(name: str):
if name.startswith("aio_"):
async with _async_saver(name.removeprefix("aio_")) as saver:
yield saver
else:
with _sync_saver(name) as saver:
yield saver
async def _call(saver: Any, method: str, *args: Any, **kwargs: Any) -> Any:
if isinstance(saver, AsyncPostgresSaver):
return await getattr(saver, f"a{method}")(*args, **kwargs)
return getattr(saver, method)(*args, **kwargs)
async def _rows(saver: Any, thread_id: str) -> dict[str, list[tuple]]:
queries = {
"checkpoints": "SELECT checkpoint_ns, checkpoint_id FROM checkpoints"
" WHERE thread_id = %s ORDER BY 1, 2",
"writes": "SELECT checkpoint_ns, checkpoint_id, task_id, idx"
" FROM checkpoint_writes WHERE thread_id = %s ORDER BY 1, 2, 3, 4",
"blobs": "SELECT checkpoint_ns, channel, version FROM checkpoint_blobs"
" WHERE thread_id = %s ORDER BY 1, 2, 3",
}
rows = {}
for table, sql in queries.items():
if isinstance(saver, AsyncPostgresSaver):
async with saver._cursor() as cur:
await cur.execute(sql, (thread_id,))
rows[table] = [tuple(r.values()) for r in await cur.fetchall()]
else:
with saver._cursor() as cur:
cur.execute(sql, (thread_id,))
rows[table] = [tuple(r.values()) for r in cur.fetchall()]
return rows
async def _put(
saver: Any,
thread_id: str,
parent: dict | None,
step: int,
values: dict[str, Any],
versions: dict[str, str],
*,
ns: str = "",
metadata: dict[str, Any] | None = None,
v: int = 4,
checkpoint_id: str | None = None,
) -> dict:
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ns}}
if parent is not None:
config["configurable"]["checkpoint_id"] = parent["configurable"][
"checkpoint_id"
]
checkpoint = empty_checkpoint()
checkpoint.update(
v=v,
id=checkpoint_id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=versions,
)
new_versions = {k: versions[k] for k in values}
return await _call(
saver,
"put",
config,
checkpoint,
{"source": "loop", "step": step, "parents": {}, **(metadata or {})},
new_versions,
)
async def _three_checkpoints(
saver: Any,
thread_id: str,
ns: str = "",
metadata: dict[str, Any] | None = None,
) -> dict:
parent = None
steps = [
({"a": [1], "s": ["shared"]}, {"a": "1", "s": "1"}),
({"a": [1, 2]}, {"a": "2", "s": "1"}),
({"a": [1, 2, 3]}, {"a": "3", "s": "1"}),
]
for step, (values, versions) in enumerate(steps):
parent = await _put(
saver, thread_id, parent, step, values, versions, ns=ns, metadata=metadata
)
await _call(saver, "put_writes", parent, [("a", step)], str(uuid4()))
return parent
@asynccontextmanager
async def _second_saver(saver: AsyncPostgresSaver):
assert isinstance(saver.conn, AsyncConnection)
uri = DEFAULT_POSTGRES_URI + saver.conn.info.dbname
async with AsyncPostgresSaver.from_conn_string(uri) as other:
yield other
def _run_once_after(
saver: AsyncPostgresSaver,
sql: str,
action: Callable[[], Awaitable[Any]],
*,
nth: int = 1,
) -> None:
seen = 0
cursor = saver._cursor
class Hooked:
def __init__(self, cur: Any) -> None:
self.cur = cur
def __getattr__(self, name: str) -> Any:
return getattr(self.cur, name)
async def execute(self, query: Any, params: Any = None, **kwargs: Any) -> None:
nonlocal seen
await self.cur.execute(query, params, **kwargs)
if query is sql:
seen += 1
if seen == nth:
await action()
@asynccontextmanager
async def hooked(**kwargs: Any):
async with cursor(**kwargs) as cur:
yield Hooked(cur)
saver._cursor = hooked
async def _delta_thread(saver: Any, thread_id: str, evidence: str) -> None:
if evidence == "counters":
first = await _put(saver, thread_id, None, 0, {"m": [1]}, {"m": "1"})
await _put(
saver,
thread_id,
first,
1,
{},
{"m": "2"},
metadata={"counters_since_delta_snapshot": {"m": [1, 1]}},
)
elif evidence == "snapshot":
first = await _put(
saver, thread_id, None, 0, {"m": _DeltaSnapshot([1])}, {"m": "1"}
)
await _put(saver, thread_id, first, 1, {}, {"m": "2"})
else:
marker = {"delta_writes_versioned": True}
first = await _put(
saver, thread_id, None, 0, {"m": [1]}, {"m": "1"}, metadata=marker
)
await _put(
saver, thread_id, first, 1, {"m": [1, 2]}, {"m": "2"}, metadata=marker
)
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_keeps_each_namespace_head_with_its_writes_and_blobs(
name: str,
) -> None:
async with _saver(name) as saver:
thread_id, other_id = str(uuid4()), str(uuid4())
heads = {
ns: await _three_checkpoints(saver, thread_id, ns) for ns in ("", "sub:1")
}
await _three_checkpoints(saver, other_id)
before = {ns: await _call(saver, "get_tuple", h) for ns, h in heads.items()}
other_before = await _rows(saver, other_id)
await _call(saver, "prune", [thread_id])
rows = await _rows(saver, thread_id)
assert rows["checkpoints"] == [
(ns, heads[ns]["configurable"]["checkpoint_id"]) for ns in ("", "sub:1")
]
assert [(ns, cid) for ns, cid, *_ in rows["writes"]] == rows["checkpoints"]
assert rows["blobs"] == [
(ns, ch, ver)
for ns in ("", "sub:1")
for ch, ver in (("a", "3"), ("s", "1"))
]
for ns, head in heads.items():
assert await _call(saver, "get_tuple", head) == before[ns]._replace(
parent_config=None
)
assert await _rows(saver, other_id) == other_before
@pytest.mark.parametrize("evidence", ["counters", "snapshot", "versioned"])
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_refuses_a_batch_with_a_delta_channel_thread(
name: str, evidence: str
) -> None:
async with _saver(name) as saver:
ordinary_id, delta_id = str(uuid4()), str(uuid4())
await _three_checkpoints(saver, ordinary_id)
await _delta_thread(saver, delta_id, evidence)
before = [await _rows(saver, t) for t in (ordinary_id, delta_id)]
with pytest.raises(NotImplementedError, match=delta_id):
await _call(saver, "prune", [ordinary_id, delta_id])
assert [await _rows(saver, t) for t in (ordinary_id, delta_id)] == before
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_prunes_a_thread_storing_a_plain_true(name: str) -> None:
async with _saver(name) as saver:
thread_id = str(uuid4())
first = await _put(
saver, thread_id, None, 0, {"flag": True, "a": [1]}, {"flag": "1", "a": "1"}
)
head = await _put(
saver, thread_id, first, 1, {"flag": True}, {"flag": "2", "a": "1"}
)
await _call(saver, "prune", [thread_id])
assert (await _rows(saver, thread_id))["checkpoints"] == [
("", head["configurable"]["checkpoint_id"])
]
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_keeps_the_parent_holding_a_pre_v4_heads_sends(
name: str,
) -> None:
async with _saver(name) as saver:
thread_id = str(uuid4())
first = await _put(saver, thread_id, None, 0, {}, {}, v=1)
await _call(saver, "put_writes", first, [(TASKS, "send-1")], str(uuid4()))
second = await _put(saver, thread_id, first, 1, {}, {}, v=1)
head = await _put(saver, thread_id, second, 2, {}, {}, v=1)
await _call(saver, "put_writes", second, [(TASKS, "send-2")], str(uuid4()))
await _call(saver, "prune", [thread_id])
assert (await _rows(saver, thread_id))["checkpoints"] == [
("", second["configurable"]["checkpoint_id"]),
("", head["configurable"]["checkpoint_id"]),
]
loaded = await _call(saver, "get_tuple", head)
assert loaded.checkpoint["channel_values"][TASKS] == ["send-2"]
assert loaded.parent_config == second
assert (await _call(saver, "get_tuple", second)).parent_config is None
@pytest.mark.parametrize("name", SAVERS)
async def test_delete_removes_every_row_of_the_threads(name: str) -> None:
async with _saver(name) as saver:
ordinary_id, delta_id, other_id = str(uuid4()), str(uuid4()), str(uuid4())
await _three_checkpoints(saver, ordinary_id)
await _delta_thread(saver, delta_id, "counters")
await _three_checkpoints(saver, other_id)
other_before = await _rows(saver, other_id)
await _call(saver, "prune", [ordinary_id, delta_id], strategy="delete")
empty = {"checkpoints": [], "writes": [], "blobs": []}
assert await _rows(saver, ordinary_id) == empty
assert await _rows(saver, delta_id) == empty
assert await _rows(saver, other_id) == other_before
@pytest.mark.parametrize("name", SAVERS)
async def test_unknown_strategy_changes_nothing(name: str) -> None:
async with _saver(name) as saver:
thread_id = str(uuid4())
await _three_checkpoints(saver, thread_id)
before = await _rows(saver, thread_id)
with pytest.raises(ValueError, match="keep_all"):
await _call(saver, "prune", [thread_id], strategy="keep_all")
assert await _rows(saver, thread_id) == before
async def test_async_saver_prune_runs_from_another_thread_only() -> None:
async with _saver("aio_base") as saver:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
with pytest.raises(asyncio.InvalidStateError):
saver.prune([thread_id])
await asyncio.to_thread(saver.prune, [thread_id])
assert (await _rows(saver, thread_id))["checkpoints"] == [
("", head["configurable"]["checkpoint_id"])
]
async def test_prune_conformance() -> None:
@checkpointer_test(name="AsyncPostgresSaver")
async def postgres_saver():
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
yield saver
report = await validate(postgres_saver, capabilities={"prune"})
prune = report.results["prune"]
if not prune.passed:
details = "\n".join(prune.failures)
pytest.fail(f"Capability prune failed (passed={prune.passed}):\n{details}")
@pytest.mark.parametrize("strategy", ["keep_latest", "delete"])
@pytest.mark.parametrize("name", ["base", "aio_base"])
async def test_prune_rejects_a_single_thread_id_string(
name: str, strategy: str
) -> None:
async with _saver(name) as saver:
for thread_id in ("ab", "a", "b"):
await _three_checkpoints(saver, thread_id)
before = [await _rows(saver, t) for t in ("ab", "a", "b")]
with pytest.raises(TypeError, match="not a str"):
await _call(saver, "prune", "ab", strategy=strategy)
assert [await _rows(saver, t) for t in ("ab", "a", "b")] == before
@pytest.mark.parametrize(
"after", [PRUNE_HEADS_SQL, PRUNE_REFERENCED_BLOBS_SQL], ids=["heads", "blobs"]
)
async def test_keep_latest_keeps_a_checkpoint_saved_while_it_runs(after: str) -> None:
async with _saver("aio_base") as saver, _second_saver(saver) as other:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
saved: list[dict] = []
async def save_a_child() -> None:
saved.append(
await _put(
other, thread_id, head, 3, {"a": [1, 2, 3, 4]}, {"a": "4", "s": "1"}
)
)
_run_once_after(saver, after, save_a_child)
await saver.aprune([thread_id])
loaded = await saver.aget_tuple(saved[0])
assert loaded is not None, "the checkpoint saved during the prune was deleted"
assert loaded.checkpoint["channel_values"] == {
"a": [1, 2, 3, 4],
"s": ["shared"],
}
assert loaded.parent_config == head
async def test_keep_latest_refuses_a_thread_that_turns_delta_after_the_batch_check() -> (
None
):
async with _saver("aio_base") as saver, _second_saver(saver) as other:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
async def save_a_delta_checkpoint() -> None:
await _put(
other,
thread_id,
head,
3,
{},
{"a": "4", "s": "1"},
metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
)
_run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint)
with pytest.raises(NotImplementedError, match=thread_id):
await saver.aprune([thread_id])
assert len((await _rows(saver, thread_id))["checkpoints"]) == 4
async def test_keep_latest_keeps_the_history_of_a_delta_checkpoint_saved_after_the_thread_check() -> (
None
):
async with _saver("aio_base") as saver, _second_saver(saver) as other:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
saved: list[dict] = []
async def save_a_delta_checkpoint() -> None:
saved.append(
await _put(
other,
thread_id,
head,
3,
{},
{"a": "4", "s": "1"},
metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
)
)
_run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint, nth=2)
await saver.aprune([thread_id])
assert (await _rows(saver, thread_id))["checkpoints"] == [
("", head["configurable"]["checkpoint_id"]),
("", saved[0]["configurable"]["checkpoint_id"]),
]
history = await saver.aget_delta_channel_history(
config=saved[0], channels=["a"]
)
assert history["a"]["seed"] == [1, 2, 3]
assert [value for _, _, value in history["a"]["writes"]] == [2]
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_deletes_a_subgraph_call_with_the_checkpoint_it_started_from(
name: str,
) -> None:
async with _saver(name) as saver:
thread_id = str(uuid4())
older = await _put(saver, thread_id, None, 0, {}, {})
head = await _put(saver, thread_id, older, 1, {}, {})
unsaved = str(uuid6(clock_seq=2))
started_from = {
"finished:1": older["configurable"]["checkpoint_id"],
"interrupted:2": head["configurable"]["checkpoint_id"],
"running:3": unsaved,
"checkpointer_true": older["configurable"]["checkpoint_id"],
}
for ns, root in started_from.items():
await _three_checkpoints(
saver, thread_id, ns, metadata={"parents": {"": root}}
)
await _call(saver, "prune", [thread_id])
rows = await _rows(saver, thread_id)
assert {ns for ns, *_ in rows["checkpoints"]} == {
"",
"interrupted:2",
"running:3",
"checkpointer_true",
}
assert {ns for ns, *_ in rows["writes"]} == {
"interrupted:2",
"running:3",
"checkpointer_true",
}
assert {ns for ns, *_ in rows["blobs"]} == {
"interrupted:2",
"running:3",
"checkpointer_true",
}
async def test_keep_latest_keeps_a_checkpoint_saved_meanwhile_whatever_its_id() -> None:
async with _saver("aio_base") as saver, _second_saver(saver) as other:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
lower_id = "0" + head["configurable"]["checkpoint_id"][1:]
saved: list[dict] = []
async def save_a_child_with_a_lower_id() -> None:
child = await _put(
other,
thread_id,
head,
3,
{"a": [1, 2, 3, 4]},
{"a": "4", "s": "1"},
checkpoint_id=lower_id,
)
await _call(other, "put_writes", child, [("a", 4)], str(uuid4()))
saved.append(child)
_run_once_after(saver, PRUNE_HEADS_SQL, save_a_child_with_a_lower_id)
await saver.aprune([thread_id])
loaded = await saver.aget_tuple(saved[0])
assert loaded is not None, "the checkpoint saved during the prune was deleted"
assert loaded.checkpoint["channel_values"] == {
"a": [1, 2, 3, 4],
"s": ["shared"],
}
assert [value for _, _, value in loaded.pending_writes or []] == [4]
@pytest.mark.parametrize("name", SAVERS)
async def test_keep_latest_keeps_writes_saved_before_their_checkpoint(
name: str,
) -> None:
async with _saver(name) as saver:
thread_id = str(uuid4())
head = await _three_checkpoints(saver, thread_id)
next_id = str(uuid6(clock_seq=3))
next_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
"checkpoint_id": next_id,
}
}
await _call(saver, "put_writes", next_config, [("a", 4)], str(uuid4()))
await _call(saver, "prune", [thread_id])
await _put(
saver, thread_id, head, 3, {}, {"a": "3", "s": "1"}, checkpoint_id=next_id
)
loaded = await _call(saver, "get_tuple", next_config)
assert [value for _, _, value in loaded.pending_writes] == [4]
@@ -11,7 +11,6 @@ import pytest
from langchain_core.embeddings import Embeddings
from langgraph.store.base import (
GetOp,
InvalidNamespaceError,
Item,
ListNamespacesOp,
MatchCondition,
@@ -1165,51 +1164,3 @@ def test_namespace_labels_with_trailing_newline(store) -> None:
assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
("users", "alice"),
}
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
@pytest.mark.parametrize(
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
)
def test_batch_rejects_invalid_namespace_labels(
store, namespace: tuple, kind: str
) -> None:
"""Ops passed straight to `batch` must not reach another namespace.
Namespaces are stored dot-joined, so `("foo.bar",)` flattens to the same
text as `("foo", "bar")`. `BaseStore` methods validate labels themselves,
but `batch` takes ops as given.
"""
op = {
"get": GetOp(namespace, "key"),
"put": PutOp(namespace, "key", {"changed": True}),
"delete": PutOp(namespace, "key", None),
"search": SearchOp(namespace),
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
}[kind]
store.put(("foo", "bar"), "key", {"original": True})
with pytest.raises(InvalidNamespaceError):
store.batch([PutOp(("valid",), "key", {}), op])
item = store.get(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
# The whole batch is rejected before any SQL runs.
assert store.get(("valid",), "key") is None
def test_batch_allows_empty_search_prefix_and_listing_wildcards(
store,
) -> None:
store.put(("foo", "bar"), "key", {"v": 1})
found, listed = store.batch(
[
SearchOp(()),
ListNamespacesOp((MatchCondition("prefix", ("foo", "*")),)),
]
)
assert [item.namespace for item in found] == [("foo", "bar")]
assert listed == [("foo", "bar")]
+4 -4
View File
@@ -40,11 +40,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -353,7 +353,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.2.0"
version = "3.1.2"
source = { editable = "." }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -539,7 +539,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
thread_id = str(config["configurable"]["thread_id"])
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if not checkpoint_id:
if checkpoint_id is None:
target = self.get_tuple(config)
if target is None:
return {ch: {"writes": []} for ch in channels}
@@ -653,7 +653,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
thread_id = str(config["configurable"]["thread_id"])
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if not checkpoint_id:
if checkpoint_id is None:
target = await self.aget_tuple(config)
if target is None:
return {ch: {"writes": []} for ch in channels}
@@ -28,7 +28,6 @@ from langgraph.store.base import (
ensure_embeddings,
get_text_at_path,
tokenize_path,
validate_op_namespace,
)
_AIO_ERROR_MSG = (
@@ -258,7 +257,6 @@ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
tot = 0
for idx, op in enumerate(ops):
validate_op_namespace(op)
grouped_ops[type(op)].append((idx, op))
tot += 1
return grouped_ops, tot
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-sqlite"
version = "3.1.2"
version = "3.1.1"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
@@ -9,10 +9,8 @@ from typing import cast
import pytest
from langgraph.store.base import (
GetOp,
InvalidNamespaceError,
Item,
ListNamespacesOp,
MatchCondition,
PutOp,
SearchOp,
)
@@ -747,65 +745,3 @@ async def test_async_namespace_segment_boundary(store: AsyncSqliteStore) -> None
assert set(await store.alist_namespaces(suffix=["alice"], limit=100)) == {
("uid", "users", "alice"),
}
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
async def test_abatch_rejects_invalid_namespace_labels(
store: AsyncSqliteStore, namespace: tuple
) -> None:
await store.aput(("foo", "bar"), "key", {"original": True})
for op in (
GetOp(namespace, "key"),
GetOp(namespace, "key", refresh_ttl=True),
PutOp(namespace, "key", {"changed": True}),
PutOp(namespace, "key", None),
SearchOp(namespace),
ListNamespacesOp((MatchCondition("prefix", namespace),)),
ListNamespacesOp((MatchCondition("suffix", namespace),)),
):
with pytest.raises(InvalidNamespaceError):
await store.abatch([op])
item = await store.aget(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
async def test_invalid_namespace_only_fails_its_own_call(
store: AsyncSqliteStore,
) -> None:
"""Concurrent calls share one `abatch`, which fails every op if it raises.
Labels are checked before an op is queued, so one caller's bad label cannot
fail another caller's request.
"""
await store.aput(("foo", "bar"), "key", {"original": True})
valid, invalid = await asyncio.gather(
store.aget(("foo", "bar"), "key"),
store.aget(("foo.bar",), "key"),
return_exceptions=True,
)
assert isinstance(valid, Item) and valid.value == {"original": True}
assert isinstance(invalid, InvalidNamespaceError)
async def test_sync_methods_reject_invalid_namespace_labels(
store: AsyncSqliteStore,
) -> None:
"""The sync wrappers run off the event loop thread and must validate too."""
await store.aput(("foo", "bar"), "key", {"original": True})
for call in (
lambda: store.get(("foo.bar",), "key"),
lambda: store.search(("foo.bar",)),
lambda: store.delete(("foo.bar",), "key"),
lambda: store.list_namespaces(prefix=("foo.bar",)),
lambda: store.batch([GetOp(("foo.bar",), "key")]),
):
with pytest.raises(InvalidNamespaceError):
await asyncio.to_thread(call)
item = await store.aget(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
@@ -65,39 +65,6 @@ async def test_async_walk_reaches_parent_whatever_the_id_order(
assert got[CHANNEL] == EXPECTED
EMPTY_CHECKPOINT_ID: dict[str, Any] = {
"configurable": {**CONFIG["configurable"], "checkpoint_id": ""}
}
def test_sync_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
with SqliteSaver.from_conn_string(":memory:") as saver:
root = saver.put(CONFIG, _checkpoint("a-older", {CHANNEL: "seed"}), {}, {})
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
saver.put(root, _checkpoint("z-newer", {}), {}, {})
got = saver.get_delta_channel_history(
config=EMPTY_CHECKPOINT_ID, channels=[CHANNEL]
)
assert got[CHANNEL] == EXPECTED
async def test_async_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
root = await saver.aput(
CONFIG, _checkpoint("a-older", {CHANNEL: "seed"}), {}, {}
)
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
await saver.aput(root, _checkpoint("z-newer", {}), {}, {})
got = await saver.aget_delta_channel_history(
config=EMPTY_CHECKPOINT_ID, channels=[CHANNEL]
)
assert got[CHANNEL] == EXPECTED
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
steps = 40
with SqliteSaver.from_conn_string(":memory:") as saver:
@@ -14,7 +14,6 @@ import pytest
from langchain_core.embeddings import Embeddings
from langgraph.store.base import (
GetOp,
InvalidNamespaceError,
Item,
ListNamespacesOp,
MatchCondition,
@@ -1364,17 +1363,6 @@ def test_search_empty_prefix_is_unconstrained(store: SqliteStore) -> None:
}
@pytest.mark.parametrize("batched", [False, True])
def test_search_rejects_nul_namespace_prefix(store: SqliteStore, batched: bool) -> None:
store.put(("a",), "key", {"v": 1})
with pytest.raises(InvalidNamespaceError, match="NUL"):
if batched:
store.batch([SearchOp(("a\x00",))])
else:
store.search(("a\x00",))
def test_namespace_labels_with_trailing_newline(store: SqliteStore) -> None:
"""Labels may contain newlines, and must not match a differently-named label.
@@ -1447,51 +1435,3 @@ def test_list_namespaces_metacharacter_labels(store: SqliteStore) -> None:
assert set(store.list_namespaces(prefix=[label, "child"], limit=100)) == {
(label, "child"),
}
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
@pytest.mark.parametrize(
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
)
def test_batch_rejects_invalid_namespace_labels(
store: SqliteStore, namespace: tuple, kind: str
) -> None:
"""Ops passed straight to `batch` must not reach another namespace.
Namespaces are stored dot-joined, so `("foo.bar",)` flattens to the same
text as `("foo", "bar")`. `BaseStore` methods validate labels themselves,
but `batch` takes ops as given.
"""
op = {
"get": GetOp(namespace, "key"),
"put": PutOp(namespace, "key", {"changed": True}),
"delete": PutOp(namespace, "key", None),
"search": SearchOp(namespace),
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
}[kind]
store.put(("foo", "bar"), "key", {"original": True})
with pytest.raises(InvalidNamespaceError):
store.batch([PutOp(("valid",), "key", {}), op])
item = store.get(("foo", "bar"), "key")
assert item is not None and item.value == {"original": True}
# The whole batch is rejected before any SQL runs.
assert store.get(("valid",), "key") is None
def test_batch_allows_empty_search_prefix_and_listing_wildcards(
store: SqliteStore,
) -> None:
store.put(("foo", "bar"), "key", {"v": 1})
found, listed = store.batch(
[
SearchOp(()),
ListNamespacesOp((MatchCondition("prefix", ("foo", "*")),)),
]
)
assert [item.namespace for item in found] == [("foo", "bar")]
assert listed == [("foo", "bar")]
+4 -4
View File
@@ -49,11 +49,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -360,7 +360,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.2"
version = "3.1.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -170,8 +170,8 @@ class InMemorySaver(
return {}
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = config["configurable"].get("checkpoint_id", "")
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
checkpoint_id = get_checkpoint_id(config) or max(ns_storage, default="")
chain: list[str] = []
target_entry = ns_storage.get(checkpoint_id)
@@ -771,12 +771,7 @@ class BaseStore(ABC):
Returns:
The retrieved item or `None` if not found.
Raises:
InvalidNamespaceError: If a namespace label is empty, is not a string,
or contains a period (`.`).
"""
_validate_namespace_labels(namespace)
return self.batch(
[GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))]
)[0]
@@ -806,10 +801,6 @@ class BaseStore(ABC):
Returns:
List of items matching the search criteria.
Raises:
InvalidNamespaceError: If a `namespace_prefix` label is empty, is not a
string, or contains a period (`.`).
???+ example "Examples"
Basic filtering:
@@ -849,7 +840,6 @@ class BaseStore(ABC):
Natural language search support depends on your store implementation
and requires proper embedding configuration.
"""
_validate_namespace_labels(namespace_prefix)
return self.batch(
[
SearchOp(
@@ -897,11 +887,6 @@ class BaseStore(ABC):
By default, the expiration timer refreshes on both read operations (get/search)
and write operations (put/update), whenever the item is included in the operation.
Raises:
InvalidNamespaceError: If the namespace is empty, its root label is
`"langgraph"`, or a label is empty, is not a string, or contains a
period (`.`).
Note:
Indexing support depends on your store implementation.
If you do not initialize the store with indexing capabilities,
@@ -955,12 +940,7 @@ class BaseStore(ABC):
Args:
namespace: Hierarchical path for the item.
key: Unique identifier within the namespace.
Raises:
InvalidNamespaceError: If a namespace label is empty, is not a string,
or contains a period (`.`).
"""
_validate_namespace_labels(namespace)
self.batch([PutOp(namespace, str(key), None, ttl=None)])
def list_namespaces(
@@ -989,10 +969,6 @@ class BaseStore(ABC):
A list of namespace tuples that match the criteria. Each tuple represents a
full namespace path up to `max_depth`.
Raises:
InvalidNamespaceError: If a `prefix` or `suffix` label is empty, is not a
string, or contains a period (`.`).
???+ example "Examples":
Setting `max_depth=3`. Given the namespaces:
@@ -1008,8 +984,6 @@ class BaseStore(ABC):
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
```
"""
_validate_namespace_labels(prefix or ())
_validate_namespace_labels(suffix or ())
match_conditions = []
if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
@@ -1039,12 +1013,7 @@ class BaseStore(ABC):
Returns:
The retrieved item or `None` if not found.
Raises:
InvalidNamespaceError: If a namespace label is empty, is not a string,
or contains a period (`.`).
"""
_validate_namespace_labels(namespace)
return (
await self.abatch(
[
@@ -1083,10 +1052,6 @@ class BaseStore(ABC):
Returns:
List of items matching the search criteria.
Raises:
InvalidNamespaceError: If a `namespace_prefix` label is empty, is not a
string, or contains a period (`.`).
???+ example "Examples"
Basic filtering:
@@ -1126,7 +1091,6 @@ class BaseStore(ABC):
Natural language search support depends on your store implementation
and requires proper embedding configuration.
"""
_validate_namespace_labels(namespace_prefix)
return (
await self.abatch(
[
@@ -1176,11 +1140,6 @@ class BaseStore(ABC):
By default, the expiration timer refreshes on both read operations (get/search)
and write operations (put/update), whenever the item is included in the operation.
Raises:
InvalidNamespaceError: If the namespace is empty, its root label is
`"langgraph"`, or a label is empty, is not a string, or contains a
period (`.`).
Note:
Indexing support depends on your store implementation.
If you do not initialize the store with indexing capabilities,
@@ -1242,12 +1201,7 @@ class BaseStore(ABC):
Args:
namespace: Hierarchical path for the item.
key: Unique identifier within the namespace.
Raises:
InvalidNamespaceError: If a namespace label is empty, is not a string,
or contains a period (`.`).
"""
_validate_namespace_labels(namespace)
await self.abatch([PutOp(namespace, str(key), None)])
async def alist_namespaces(
@@ -1276,10 +1230,6 @@ class BaseStore(ABC):
A list of namespace tuples that match the criteria. Each tuple represents a
full namespace path up to `max_depth`.
Raises:
InvalidNamespaceError: If a `prefix` or `suffix` label is empty, is not a
string, or contains a period (`.`).
???+ example "Examples"
Setting `max_depth=3` with existing namespaces:
@@ -1295,8 +1245,6 @@ class BaseStore(ABC):
# Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
```
"""
_validate_namespace_labels(prefix or ())
_validate_namespace_labels(suffix or ())
match_conditions = []
if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
@@ -1315,14 +1263,6 @@ class BaseStore(ABC):
def _validate_namespace(namespace: tuple[str, ...]) -> None:
if not namespace:
raise InvalidNamespaceError("Namespace cannot be empty.")
_validate_namespace_labels(namespace)
if namespace[0] == "langgraph":
raise InvalidNamespaceError(
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
)
def _validate_namespace_labels(namespace: tuple[str, ...]) -> None:
for label in namespace:
if not isinstance(label, str):
raise InvalidNamespaceError(
@@ -1333,35 +1273,14 @@ def _validate_namespace_labels(namespace: tuple[str, ...]) -> None:
raise InvalidNamespaceError(
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')."
)
elif "\x00" in label:
raise InvalidNamespaceError(
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain NUL bytes ('\\x00')."
)
elif not label:
raise InvalidNamespaceError(
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
)
def validate_op_namespace(op: Op) -> None:
"""Validate the namespace labels an op carries before a store executes it.
`BaseStore` methods check labels before batching, but ops passed directly to
`batch`/`abatch` skip those methods. Stores that serialize namespaces as
delimited text should call this for every op they execute, so a label such
as `"foo.bar"` cannot address the namespace `("foo", "bar")`.
Raises:
InvalidNamespaceError: If a label is empty, is not a string, or contains
a period (`.`).
"""
if isinstance(op, (GetOp, PutOp)):
_validate_namespace_labels(op.namespace)
elif isinstance(op, SearchOp):
_validate_namespace_labels(op.namespace_prefix)
elif isinstance(op, ListNamespacesOp):
for condition in op.match_conditions or ():
_validate_namespace_labels(condition.path)
if namespace[0] == "langgraph":
raise InvalidNamespaceError(
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
)
def _ensure_refresh(
@@ -1400,5 +1319,4 @@ __all__ = [
"ensure_embeddings",
"tokenize_path",
"get_text_at_path",
"validate_op_namespace",
]
@@ -25,7 +25,6 @@ from langgraph.store.base import (
_ensure_refresh,
_ensure_ttl,
_validate_namespace,
_validate_namespace_labels,
)
F = TypeVar("F", bound=Callable)
@@ -87,7 +86,6 @@ class AsyncBatchedBaseStore(BaseStore):
*,
refresh_ttl: bool | None = None,
) -> Item | None:
_validate_namespace_labels(namespace)
self._ensure_task()
fut = self._loop.create_future()
self._aqueue.put_nowait(
@@ -113,7 +111,6 @@ class AsyncBatchedBaseStore(BaseStore):
offset: int = 0,
refresh_ttl: bool | None = None,
) -> list[SearchItem]:
_validate_namespace_labels(namespace_prefix)
self._ensure_task()
fut = self._loop.create_future()
self._aqueue.put_nowait(
@@ -158,7 +155,6 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...],
key: str,
) -> None:
_validate_namespace_labels(namespace)
self._ensure_task()
fut = self._loop.create_future()
self._aqueue.put_nowait((fut, PutOp(namespace, key, None)))
@@ -173,8 +169,6 @@ class AsyncBatchedBaseStore(BaseStore):
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
_validate_namespace_labels(prefix or ())
_validate_namespace_labels(suffix or ())
self._ensure_task()
fut = self._loop.create_future()
match_conditions = []
@@ -0,0 +1,37 @@
"""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():
# Imported inside the test: the module-level importorskip above is what
# makes these safe, so they cannot move to the top of the file.
from langgraph.checkpoint.conformance import validate # noqa: PLC0415
from langgraph.checkpoint.conformance.initializer import ( # noqa: PLC0415
checkpointer_test,
)
from langgraph.checkpoint.memory import InMemorySaver # noqa: PLC0415
@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}")
-13
View File
@@ -420,19 +420,6 @@ class TestInMemorySaverDeltaChannel:
assert "seed" not in result
assert result["writes"] == []
def test_get_channel_writes_without_checkpoint_id_reads_the_latest(self) -> None:
saver = InMemorySaver()
thread: RunnableConfig = {
"configurable": {"thread_id": "t1", "checkpoint_ns": ""}
}
parent = saver.put(thread, empty_checkpoint(), {}, {})
saver.put_writes(parent, [("messages", "hi")], "task1")
saver.put(parent, empty_checkpoint(), {}, {})
result = saver.get_delta_channel_history(config=thread, channels=["messages"])
assert result == {"messages": {"writes": [("task1", "messages", "hi")]}}
class TestBaseFallbackGetChannelWrites:
"""Exercises the `BaseCheckpointSaver.get_delta_channel_history` default
-125
View File
@@ -13,14 +13,10 @@ from langgraph.store.base import (
GetOp,
InvalidNamespaceError,
Item,
ListNamespacesOp,
MatchCondition,
Op,
PutOp,
Result,
SearchOp,
get_text_at_path,
validate_op_namespace,
)
from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.memory import InMemoryStore
@@ -532,127 +528,6 @@ async def test_cannot_put_empty_namespace() -> None:
assert (await async_store.aget(("valid", "namespace"), "key")) is None
INVALID_NAMESPACES = [("foo.bar",), ("foo\x00",), ("foo", ""), (123,)]
NAMESPACE_METHODS = ["get", "delete", "search", "prefix", "suffix"]
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
@pytest.mark.parametrize("method", NAMESPACE_METHODS)
def test_rejects_invalid_namespace_labels(
mocker: MockerFixture, namespace: tuple, method: str
) -> None:
store = InMemoryStore()
batch = mocker.spy(InMemoryStore, "batch")
call = {
"get": lambda: store.get(namespace, "key"),
"delete": lambda: store.delete(namespace, "key"),
"search": lambda: store.search(namespace),
"prefix": lambda: store.list_namespaces(prefix=namespace),
"suffix": lambda: store.list_namespaces(suffix=namespace),
}[method]
with pytest.raises(InvalidNamespaceError):
call()
batch.assert_not_called()
@pytest.mark.parametrize("batched", [False, True])
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
@pytest.mark.parametrize("method", NAMESPACE_METHODS)
async def test_async_rejects_invalid_namespace_labels(
mocker: MockerFixture, batched: bool, namespace: tuple, method: str
) -> None:
# The batched store must reject before queueing: a failure inside the
# shared `abatch` would fail every op queued alongside this one.
store = MockAsyncBatchedStore() if batched else InMemoryStore()
# `MockAsyncBatchedStore` dispatches through `InMemoryStore.batch`.
batch = mocker.spy(InMemoryStore, "batch")
abatch = mocker.spy(InMemoryStore, "abatch")
call = {
"get": lambda: store.aget(namespace, "key"),
"delete": lambda: store.adelete(namespace, "key"),
"search": lambda: store.asearch(namespace),
"prefix": lambda: store.alist_namespaces(prefix=namespace),
"suffix": lambda: store.alist_namespaces(suffix=namespace),
}[method]
with pytest.raises(InvalidNamespaceError):
await call()
batch.assert_not_called()
abatch.assert_not_called()
def test_search_and_listing_keep_empty_prefixes_and_wildcards() -> None:
store = InMemoryStore()
store.put(("tenant", "a_%"), "key", {"v": 1})
store.put(("tenant", "b", "child"), "key", {"v": 1})
assert len(store.search(())) == 2
assert [item.namespace for item in store.search(("tenant", "a_%"))] == [
("tenant", "a_%")
]
assert sorted(store.list_namespaces(prefix=("tenant", "*"), suffix=("*",))) == [
("tenant", "a_%"),
("tenant", "b", "child"),
]
@pytest.mark.parametrize("batched", [False, True])
async def test_async_search_and_listing_keep_empty_prefixes_and_wildcards(
batched: bool,
) -> None:
store = MockAsyncBatchedStore() if batched else InMemoryStore()
await store.aput(("tenant", "a_%"), "key", {"v": 1})
await store.aput(("tenant", "b", "child"), "key", {"v": 1})
assert len(await store.asearch(())) == 2
assert [item.namespace for item in await store.asearch(("tenant", "a_%"))] == [
("tenant", "a_%")
]
assert sorted(
await store.alist_namespaces(prefix=("tenant", "*"), suffix=("*",))
) == [("tenant", "a_%"), ("tenant", "b", "child")]
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
@pytest.mark.parametrize(
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
)
def test_validate_op_namespace_rejects_invalid_labels(
namespace: tuple, kind: str
) -> None:
op = {
"get": GetOp(namespace, "key"),
"put": PutOp(namespace, "key", {"v": 1}),
"delete": PutOp(namespace, "key", None),
"search": SearchOp(namespace),
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
}[kind]
with pytest.raises(InvalidNamespaceError):
validate_op_namespace(op)
def test_validate_op_namespace_allows_empty_prefix_and_wildcards() -> None:
for op in (
SearchOp(()),
ListNamespacesOp(),
ListNamespacesOp(
(
MatchCondition("prefix", ("tenant", "*")),
MatchCondition("suffix", ("*",)),
)
),
GetOp(("tenant", "a_%"), "key"),
# Write-only rules belong to `put`, not to op validation.
PutOp(("langgraph", "x"), "key", {"v": 1}),
):
validate_op_namespace(op)
async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
abatch = mocker.spy(InMemoryStore, "batch")
store = MockAsyncBatchedStore()
+3 -3
View File
@@ -51,11 +51,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
-2
View File
@@ -23,7 +23,6 @@ from langgraph_cli.schemas import (
GraphDef,
HttpConfig,
IndexConfig,
PriorityConfig,
SecurityConfig,
SerdeConfig,
StoreConfig,
@@ -114,7 +113,6 @@ def add_descriptions_to_schema(schema, cls):
StoreConfig,
IndexConfig,
AuthConfig,
PriorityConfig,
SecurityConfig,
HttpConfig,
CorsConfig,
+10 -10
View File
@@ -21,8 +21,8 @@
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
},
"dependencies": {
"@langchain/core": "^1.2.13",
"@langchain/langgraph": "^1.4.18"
"@langchain/core": "^1.2.9",
"@langchain/langgraph": "^1.4.13"
},
"resolutions": {
"@langchain/langgraph-checkpoint": "1.0.4",
@@ -30,21 +30,21 @@
"@eslint/eslintrc/js-yaml": "4.3.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.7",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "^10.0.1",
"@tsconfig/recommended": "^1.0.13",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^8.70.1",
"@typescript-eslint/parser": "^8.70.1",
"dotenv": "^18.0.4",
"eslint": "^10.11.0",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.68.0",
"dotenv": "^17.4.2",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-no-instanceof": "^1.0.1",
"eslint-plugin-prettier": "^5.5.6",
"jest": "^30.5.2",
"prettier": "^3.9.9",
"ts-jest": "^29.4.14",
"jest": "^30.5.0",
"prettier": "^3.9.6",
"ts-jest": "^29.4.12",
"typescript": "^7.0.2"
}
}
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -581,17 +581,17 @@ base64-js@^1.5.1:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
brace-expansion@^1.1.7:
version "1.1.21"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.21.tgz#edf4fab5c64d051aea5a8def49aba1c7522279f3"
integrity sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==
version "1.1.12"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^5.0.8:
version "5.0.12"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.39"
__version__ = "0.4.33"
-1
View File
@@ -847,7 +847,6 @@ def dev(
store=config_json.get("store"),
wait_for_client=wait_for_client,
auth=config_json.get("auth"),
priority=config_json.get("priority"),
http=config_json.get("http"),
ui=config_json.get("ui"),
ui_config=config_json.get("ui_config"),
-63
View File
@@ -442,7 +442,6 @@ def validate_config(
"store": config.get("store"),
"auth": config.get("auth"),
"encryption": config.get("encryption"),
"priority": config.get("priority"),
"http": config.get("http"),
# Pass through webhooks config so it can be injected into the image
"webhooks": config.get("webhooks"),
@@ -607,19 +606,6 @@ def validate_config(
f"Invalid encryption.path format: '{encryption_conf['path']}'. "
"Must be in format './path/to/file.py:attribute_name'"
)
# Validate priority config
if priority_conf := config.get("priority"):
if "path" in priority_conf:
if ":" not in priority_conf["path"]:
raise ValueError(
f"Invalid priority.path format: '{priority_conf['path']}'. "
"Must be in format './path/to/file.py:attribute_name'"
)
if _is_node_graph(priority_conf["path"]):
raise ValueError(
"JavaScript priority callbacks are not supported: "
f"'{priority_conf['path']}'. Use a Python file or module instead."
)
if http_conf := config.get("http"):
if "app" in http_conf:
if ":" not in http_conf["app"]:
@@ -663,7 +649,6 @@ _KNOWN_CONFIG_KEYS = {
"store",
"auth",
"encryption",
"priority",
"http",
"webhooks",
"checkpointer",
@@ -1095,49 +1080,6 @@ def _update_encryption_path(
)
def _update_priority_path(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Update priority.path to use Docker container paths."""
priority_conf = config.get("priority")
if not priority_conf or not (path_str := priority_conf.get("path")):
return
module_str, sep, attr_str = path_str.partition(":")
if not sep or not module_str.startswith("."):
return # Already validated or absolute path
resolved = config_path.parent / module_str
if not resolved.exists():
raise FileNotFoundError(
f"Priority file not found: {resolved} (from {path_str})"
)
if not resolved.is_file():
raise IsADirectoryError(f"Priority path must be a file: {resolved}")
# Check faux packages first (they take precedence)
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_path):
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
priority_conf["path"] = new_path
return
# Check real packages
for real_path in local_deps.real_pkgs:
if resolved.is_relative_to(real_path):
new_path = (
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
)
priority_conf["path"] = new_path
return
raise ValueError(
f"Priority file '{resolved}' not covered by dependencies.\n"
"Add its parent directory to the 'dependencies' array in your config.\n"
f"Current dependencies: {config['dependencies']}"
)
def _update_checkpointer_path(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
@@ -1271,9 +1213,6 @@ def _build_runtime_env_vars(config: Config) -> list[str]:
if (encryption_config := config.get("encryption")) is not None:
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
if (priority_config := config.get("priority")) is not None:
env_vars.append(f"ENV LANGGRAPH_PRIORITY='{json.dumps(priority_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
@@ -1441,8 +1380,6 @@ def python_config_to_docker(
_update_auth_path(config_path, config, local_deps)
# Rewrite encryption path, so it points to the correct location in the Docker container
_update_encryption_path(config_path, config, local_deps)
# Rewrite priority path, so it points to the correct location in the Docker container
_update_priority_path(config_path, config, local_deps)
# Rewrite checkpointer path, so it points to the correct location in the Docker container
_update_checkpointer_path(config_path, config, local_deps)
# Rewrite HTTP app path, so it points to the correct location in the Docker container
+175 -306
View File
@@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Generic, Protocol, TypeVar
from typing import Protocol, TypeVar
import click
import click.exceptions
@@ -26,6 +26,7 @@ from langgraph_cli.dependency_tracking import find_tracked_packages
from langgraph_cli.docker import build_docker_image, can_build_locally
from langgraph_cli.exec import CommandRunner, Runner, subp_exec
from langgraph_cli.host_backend import (
MAX_PAGE_SIZE,
ControlPlaneEndpoints,
HostBackendClient,
HostBackendError,
@@ -182,15 +183,12 @@ class Listener:
)
return cls(
identifier,
str(resource.get("compute_id") or ""),
str(resource.get("compute_id", "")),
tuple(str(namespace) for namespace in namespaces)
if isinstance(namespaces, list)
else (),
)
def cells(self) -> tuple[str | None, ...]:
return (self.id, self.compute_id, ", ".join(self.namespaces))
@dataclass(frozen=True, slots=True)
class Unplaced:
@@ -233,6 +231,15 @@ class RequestedPlacement:
def requested(self) -> bool:
return self.listener_id is not None or self.k8s_namespace is not None
def ensure_not_requested(self, deployment_id: str) -> None:
if self.requested:
raise click.UsageError(
"Listener and namespace are fixed when a deployment is created. "
f"Deployment {deployment_id} already exists, so drop --listener-id "
"and --k8s-namespace, or create a new deployment with a different "
"--name."
)
def on(self, listener: Listener) -> Placement:
return OnListener(listener.id, self._namespace(listener))
@@ -278,6 +285,8 @@ def _describe_listeners(listeners: Sequence[Listener]) -> str:
]
if len(listeners) > len(shown):
lines.append(f" ... and {len(listeners) - len(shown)} more")
if len(listeners) == MAX_PAGE_SIZE:
lines.append(f" (only the first {MAX_PAGE_SIZE} listeners were read)")
return "\n".join(lines)
@@ -499,9 +508,16 @@ def _source_of(resource: object) -> str | None:
def find_deployment_by_name(
client: HostBackendClient, name: str
) -> ExistingDeployment | None:
for resource in client.list_deployments(name=name, name_contains=name):
listed = client.list_deployments(name=name, name_contains=name, limit=MAX_PAGE_SIZE)
for resource in listed:
if resource.get("name") == name and resource.get("id"):
return ExistingDeployment(str(resource["id"]), _source_of(resource))
if len(listed) >= MAX_PAGE_SIZE:
raise click.ClickException(
"This workspace has more deployments than the CLI can search, so it "
f"cannot tell whether '{name}' already exists. Pass --deployment-id to "
"update an existing deployment."
)
return None
@@ -576,96 +592,103 @@ def _validate_prebuilt_image(
_get_emitter().info(f"Image is available for {_DEPLOYMENT_PLATFORM}")
Resource = Mapping[str, object]
RowT = TypeVar("RowT")
@dataclass(frozen=True, slots=True)
class Table(Generic[RowT]):
headers: tuple[str, ...]
cells: Callable[[RowT], tuple[str | None, ...]]
def render(self, rows: Sequence[RowT]) -> str:
grid = [
self.headers,
*(tuple(cell or "-" for cell in self.cells(row)) for row in rows),
]
widths = [max(map(len, column)) for column in zip(*grid, strict=True)]
rule = tuple("-" * width for width in widths)
return "\n".join(
" ".join(
cell.ljust(width) for cell, width in zip(line, widths, strict=True)
)
for line in (grid[0], rule, *grid[1:])
)
def _text(resource: Resource, key: str) -> str | None:
value = resource.get(key)
return str(value) if value else None
def _deployment_url(deployment: Resource) -> str | None:
def _extract_deployment_url(deployment: dict[str, object]) -> str:
source_config = deployment.get("source_config")
custom_url = (
source_config.get("custom_url") if isinstance(source_config, Mapping) else None
)
return custom_url if isinstance(custom_url, str) and custom_url else None
if isinstance(source_config, dict):
custom_url = source_config.get("custom_url")
if isinstance(custom_url, str) and custom_url:
return custom_url
return "-"
def _with_replaced_status(revisions: Sequence[Resource]) -> list[Resource]:
live_index = next(
def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
headers = ("Deployment ID", "Deployment Name", "Deployment URL")
rows = [
(
index
for index, revision in enumerate(revisions)
if revision.get("status") == "DEPLOYED"
),
None,
)
return [
{**revision, "status": "REPLACED"}
if revision.get("status") == "DEPLOYED" and index != live_index
else revision
for index, revision in enumerate(revisions)
str(deployment.get("id", "-") or "-"),
str(deployment.get("name", "-") or "-"),
_extract_deployment_url(deployment),
)
for deployment in deployments
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
@dataclass(frozen=True, slots=True)
class DeploymentRow:
id: str
name: str | None
url: str | None
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
@classmethod
def from_resource(cls, deployment: Resource) -> "DeploymentRow":
identifier = _text(deployment, "id")
if identifier is None:
raise HostBackendError(
"The control plane returned a deployment without an id."
def _extract_listener_namespaces(listener: dict[str, object]) -> str:
compute_config = listener.get("compute_config")
namespaces = (
compute_config.get("k8s_namespaces")
if isinstance(compute_config, dict)
else None
)
if isinstance(namespaces, list) and namespaces:
return ", ".join(str(namespace) for namespace in namespaces)
return "-"
def format_listeners_table(listeners: Sequence[dict[str, object]]) -> str:
headers = ("Listener ID", "Compute ID", "Namespaces")
rows = [
(
str(listener.get("id", "-") or "-"),
str(listener.get("compute_id", "-") or "-"),
_extract_listener_namespaces(listener),
)
for listener in listeners
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
def format_revisions_table(revisions: Sequence[dict[str, object]]) -> str:
headers = ("Revision ID", "Status", "Created At")
latest_deployed_seen = False
rows = []
for revision in revisions:
status = str(revision.get("status", "-") or "-")
if status == "DEPLOYED":
if latest_deployed_seen:
status = "REPLACED"
else:
latest_deployed_seen = True
rows.append(
(
str(revision.get("id", "-") or "-"),
status,
str(revision.get("created_at", "-") or "-"),
)
return cls(identifier, _text(deployment, "name"), _deployment_url(deployment))
)
def cells(self) -> tuple[str | None, ...]:
return (self.id, self.name, self.url)
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def as_json(self) -> dict[str, str | None]:
return {"id": self.id, "name": self.name}
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
DEPLOYMENTS_TABLE = Table(
("Deployment ID", "Deployment Name", "Deployment URL"), DeploymentRow.cells
)
LISTENERS_TABLE = Table(("Listener ID", "Compute ID", "Namespaces"), Listener.cells)
REVISIONS_TABLE = Table(
("Revision ID", "Status", "Created At"),
lambda revision: (
_text(revision, "id"),
_text(revision, "status"),
_text(revision, "created_at"),
),
)
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
def format_timestamp(ts) -> str:
@@ -827,6 +850,7 @@ def _find_deployment(
lambda c: c.list_deployments(
agent_id=selector.agent_id,
agent_environment=selector.environment,
limit=MAX_PAGE_SIZE,
),
)
if len(existing) > 1:
@@ -992,15 +1016,20 @@ def _print_deployment_result(
dashboard_label: str,
status_url: str | None = None,
) -> None:
"""Print final deployment status and raise on failure."""
em = _get_emitter()
deployment = client.get_deployment(deployment_id)
url = _deployment_url(deployment) if isinstance(deployment, Mapping) else None
dep_info = client.get_deployment(deployment_id)
custom_url = None
if isinstance(dep_info, dict):
sc = dep_info.get("source_config")
if isinstance(sc, dict):
custom_url = sc.get("custom_url")
if last_status == "DEPLOYED":
em.result(
"succeeded",
deployment_id=deployment_id,
url=url,
url=custom_url,
status_url=status_url,
)
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
@@ -1026,28 +1055,15 @@ def _print_deployment_result(
# ---------------------------------------------------------------------------
def _strip_url_scheme(url: str) -> str:
"""Return a registry reference without a trailing slash or URL scheme."""
reference = url.rstrip("/")
if "://" in reference:
reference = reference.split("//", 1)[1]
return reference
# Artifact Registry's username for access tokens. Control planes whose registry
# needs another one return it as `username`.
_DEFAULT_REGISTRY_USERNAME = "oauth2accesstoken"
@contextmanager
def _docker_config_for_token(registry_host: str, token: str, username: str):
def _docker_config_for_token(registry_host: str, token: str):
"""Create a temporary Docker config with only the push token.
Yields the path to a temporary config directory that can be passed
to `docker --config <path>` so that system credential helpers
(e.g. gcloud) don't interfere with the push token.
"""
auth_b64 = base64.b64encode(f"{username}:{token}".encode()).decode()
auth_b64 = base64.b64encode(f"oauth2accesstoken:{token}".encode()).decode()
config_data = {"auths": {registry_host: {"auth": auth_b64}}}
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "config.json"), "w") as f:
@@ -1185,37 +1201,10 @@ class DockerBuildCommand:
return cls(("docker", "build"), ())
flags: tuple[str, ...] = ("--platform", _DEPLOYMENT_PLATFORM, "--load")
if not verbose:
flags += ("--progress=plain",)
flags += ("--progress=quiet",)
return cls(("docker", "buildx", "build"), flags)
def _active_docker_host(runner: CommandRunner) -> str | None:
"""Return the daemon endpoint of the user's active Docker context.
None when `DOCKER_HOST` is set, which Docker honors with any config
directory. Also None when the lookup fails (for example, `DOCKER_CONTEXT`
names a context that doesn't exist) or the context has no Docker endpoint
(Docker prints `<no value>`); that keeps Docker's own default in place.
"""
if os.environ.get("DOCKER_HOST"):
return None
try:
stdout, _ = runner.run(
subp_exec(
"docker",
"context",
"inspect",
"--format",
"{{.Endpoints.docker.Host}}",
collect=True,
)
)
except (click.exceptions.Exit, OSError):
return None
host = (stdout or "").strip()
return host if "://" in host else None
def _docker_argv(docker_config_dir: str | None) -> tuple[str, ...]:
if docker_config_dir is None:
return ("docker",)
@@ -1249,13 +1238,17 @@ def _push_image(
runner: CommandRunner,
image: str,
*,
docker: tuple[str, ...],
docker_config_dir: str | None,
verbose: bool,
) -> None:
for attempt in range(1, _PUSH_ATTEMPTS + 1):
try:
with Progress(message="Pushing...", elapsed=not verbose):
runner.run(subp_exec(*docker, "push", image, verbose=verbose))
runner.run(
subp_exec(
*_docker_argv(docker_config_dir), "push", image, verbose=verbose
)
)
return
except click.exceptions.Exit:
if attempt == _PUSH_ATTEMPTS:
@@ -1324,27 +1317,21 @@ def _run_local_build(
raise click.ClickException(
"Push token response missing token or registry_url"
)
username = push_data.get("username") or _DEFAULT_REGISTRY_USERNAME
step += 1
if push_data.get("repository_url"):
# The control plane chose the exact repository (an ECR repository
# is a single image name, so nothing may be appended to it).
repository = _strip_url_scheme(push_data["repository_url"])
else:
repo_seed = image_name or name or spec.config.parent.name
repository = (
f"{_strip_url_scheme(registry_url)}/{normalize_name(repo_seed)}"
normalized_registry = registry_url.rstrip("/")
if "://" in normalized_registry:
normalized_registry = normalized_registry.split("//", 1)[1]
repo_seed = image_name or name or spec.config.parent.name
remote_image = str(
ImageReference(
f"{normalized_registry}/{normalize_name(repo_seed)}",
tag,
)
remote_image = str(ImageReference(repository, tag))
registry_host = repository.split("/")[0]
)
registry_host = normalized_registry.split("/")[0]
with _docker_config_for_token(registry_host, deployment_token, username) as cfg:
# A config directory has no current context, so Docker would fall
# back to /var/run/docker.sock; keep using the daemon the build used.
docker_host = _active_docker_host(runner)
host_args = ("--host", docker_host) if docker_host else ()
docker = (*_docker_argv(cfg), *host_args)
with _docker_config_for_token(registry_host, deployment_token) as cfg:
_log_deploy_step(step, f"Logging into {registry_host}")
token_input = (
deployment_token
@@ -1353,10 +1340,10 @@ def _run_local_build(
)
runner.run(
subp_exec(
*docker,
*_docker_argv(cfg),
"login",
"-u",
username,
"oauth2accesstoken",
"--password-stdin",
registry_host,
input=token_input,
@@ -1369,7 +1356,7 @@ def _run_local_build(
runner.run(
subp_exec("docker", "tag", image_to_push, remote_image, verbose=verbose)
)
_push_image(runner, remote_image, docker=docker, verbose=verbose)
_push_image(runner, remote_image, docker_config_dir=cfg, verbose=verbose)
step += 1
resolved_image = _resolve_pushed_image_digest(
@@ -1540,14 +1527,6 @@ def _resolve_or_create(
secrets=ctx.secrets,
)
except HostBackendError as err:
if err.status_code == 400 and err.detail == (
"Scale to zero deployments are not supported in AWS environments"
):
raise HostBackendError(
f"{err.message}\nUse --deployment-type dev instead.",
status_code=err.status_code,
detail=err.detail,
) from None
if _needs_a_listener(err):
raise ListenerRequiredError(
"The image has to come from a registry you manage, so re-run with "
@@ -1668,72 +1647,10 @@ class PublishedImage:
ImageSource = BuildAndPush | PublishedImage
def _resource_spec(image_pull_secrets: tuple[str, ...]) -> Mapping[str, object]:
if not image_pull_secrets:
return _OPERATOR_DEFAULT_RESOURCE_SPEC
return {
"image_pull_secrets": [{"name": name} for name in image_pull_secrets],
}
# Mirrors the control plane's own LocalObjectReference.name validation (a
# Kubernetes DNS subdomain), so a bad secret name is rejected here instead of
# after a build and push.
_K8S_SECRET_NAME_RE = re.compile(
r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"
)
@dataclass(frozen=True, slots=True)
class CreateOnlySettings:
"""Options for an external-image deployment that only apply when
creating one, never when updating an existing deployment."""
placement: RequestedPlacement
image_pull_secrets: tuple[str, ...] = ()
def __post_init__(self) -> None:
for name in self.image_pull_secrets:
if len(name) > 253 or not _K8S_SECRET_NAME_RE.match(name):
raise click.UsageError(
f"--image-pull-secret {name!r} is not a valid Kubernetes "
"secret name (lowercase alphanumeric characters, '-', or "
"'.' only, max 253 characters)."
)
@property
def requested(self) -> bool:
return self.placement.requested or bool(self.image_pull_secrets)
def ensure_creatable(self, push_to: str | None, image_uri: str | None) -> None:
if push_to is None and image_uri is None and self.requested:
raise click.UsageError(
"--listener-id, --k8s-namespace, and --image-pull-secret only "
"apply when creating a deployment with --push-to or --image-uri."
)
def ensure_not_existing(self, deployment_id: str) -> None:
if self.placement.requested:
raise click.UsageError(
"Listener and namespace are fixed when a deployment is "
f"created. Deployment {deployment_id} already exists, so "
"drop --listener-id and --k8s-namespace, or create a new "
"deployment with a different --name."
)
if self.image_pull_secrets:
raise click.UsageError(
"langgraph deploy does not update image pull secrets on an "
f"existing deployment. Deployment {deployment_id} already "
"exists, so drop --image-pull-secret and update resource_spec "
"through the API or the LangSmith UI instead, or create a "
"new deployment with a different --name."
)
@dataclass(frozen=True, slots=True)
class CustomerRegistrySource:
image: ImageSource
create_only: CreateOnlySettings
requested_placement: RequestedPlacement
def run(self, ctx: DeployContext) -> DeployOutcome:
if isinstance(ctx.selector, ById):
@@ -1755,7 +1672,7 @@ class CustomerRegistrySource:
self, ctx: DeployContext, existing: ExistingDeployment, step: int
) -> DeployOutcome:
_ensure_customer_registry_source(existing)
self.create_only.ensure_not_existing(existing.id)
self.requested_placement.ensure_not_requested(existing.id)
image_uri, step = self._publish(ctx, step)
_log_deploy_step(step, f"Updating deployment {existing.id}")
updated = ctx.client.update_deployment(
@@ -1770,7 +1687,7 @@ class CustomerRegistrySource:
)
def _resolve_placement(self, ctx: DeployContext) -> Placement:
requested = self.create_only.placement
requested = self.requested_placement
if requested.listener_id is not None:
return requested.on(_requested_listener(ctx.client, requested.listener_id))
if not (ctx.endpoints.is_cloud or requested.requested):
@@ -1799,9 +1716,7 @@ class CustomerRegistrySource:
else None,
source=_CUSTOMER_REGISTRY_SOURCE,
source_config={
"resource_spec": _resource_spec(
self.create_only.image_pull_secrets
),
"resource_spec": _OPERATOR_DEFAULT_RESOURCE_SPEC,
**placement.source_config(),
},
source_revision_config={"image_uri": image_uri},
@@ -1842,7 +1757,7 @@ class CustomerRegistrySource:
_build_image(runner, ctx.spec, image, verbose=ctx.verbose)
step += 1
_log_deploy_step(step, f"Pushing image {image}")
_push_image(runner, image, docker=_docker_argv(None), verbose=ctx.verbose)
_push_image(runner, image, docker_config_dir=None, verbose=ctx.verbose)
step += 1
digest = _resolve_pushed_image_digest(
runner, remote_image=image, docker_config_dir=None, verbose=ctx.verbose
@@ -1895,13 +1810,19 @@ def _select_source(
tag: str | None,
remote_build_flag: bool | None,
placement: RequestedPlacement,
image_pull_secrets: tuple[str, ...],
selector: DeploymentSelector,
) -> DeploymentSource:
create_only = CreateOnlySettings(placement, image_pull_secrets)
create_only.ensure_creatable(push_to, image_uri)
if create_only.requested and isinstance(selector, ById):
create_only.ensure_not_existing(selector.deployment_id)
if push_to is None and image_uri is None and placement.requested:
raise click.UsageError(
"--listener-id and --k8s-namespace only apply when creating a "
"deployment with --push-to or --image-uri."
)
if placement.requested and isinstance(selector, ById):
raise click.UsageError(
"Listener and namespace are fixed when a deployment is created, so "
"they cannot be set for an existing --deployment-id. Drop them, or "
"create a new deployment with --name."
)
if image_uri is not None:
if push_to is not None:
raise click.UsageError("--image-uri cannot be combined with --push-to.")
@@ -1913,7 +1834,7 @@ def _select_source(
raise click.UsageError("--image-uri cannot be combined with --remote.")
return CustomerRegistrySource(
image=PublishedImage(_validate_image_uri(image_uri)),
create_only=create_only,
requested_placement=placement,
)
if push_to is not None:
if remote_build_flag is True:
@@ -1923,7 +1844,7 @@ def _select_source(
_require_local_docker()
return CustomerRegistrySource(
image=BuildAndPush(reference=reference, prebuilt_image=image),
create_only=create_only,
requested_placement=placement,
)
if image and remote_build_flag is True:
raise click.UsageError("--image cannot be combined with --remote builds.")
@@ -1953,10 +1874,7 @@ def _create_host_backend_client(
host_url: str | None,
api_key: str | None,
env_vars: dict[str, str] | None = None,
*,
no_input: bool | None = None,
) -> HostBackendClient:
effective_no_input = _no_input if no_input is None else no_input
if env_vars is None:
env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
resolved_api_key = api_key
@@ -1971,7 +1889,7 @@ def _create_host_backend_client(
resolved_api_key = val
break
if not resolved_api_key:
if effective_no_input:
if _no_input:
raise click.ClickException(
"No LangSmith API key found. Set LANGSMITH_API_KEY in the "
"environment or .env file."
@@ -1996,8 +1914,6 @@ def _create_host_backend_client(
def _call_host_backend_with_optional_tenant(
client: HostBackendClient,
operation: Callable[[HostBackendClient], _T],
*,
no_input: bool | None = None,
) -> _T:
"""Run *operation*, prompting for a workspace ID on org-scoped 403s.
@@ -2006,7 +1922,6 @@ def _call_host_backend_with_optional_tenant(
in-place so all subsequent calls through the same instance are
tenant-aware.
"""
effective_no_input = _no_input if no_input is None else no_input
prompted_for_tenant = False
while True:
@@ -2018,7 +1933,7 @@ def _call_host_backend_with_optional_tenant(
and err.status_code == 403
and "requires workspace specification" in err.message
):
if effective_no_input:
if _no_input:
raise click.ClickException(
"API key is org-scoped and requires a workspace ID. "
"Set LANGSMITH_TENANT_ID in your .env file or "
@@ -2201,13 +2116,11 @@ def _deploy_base_options(
),
click.option(
"--deployment-type",
type=click.Choice(["serverless", "dedicated", "dev", "prod"]),
default="serverless",
type=click.Choice(["dev", "prod"]),
default="dev",
show_default=True,
help=(
"Deployment type (used when creating a new deployment): "
"serverless (scale to zero) or dedicated (prod); "
"legacy dev and prod are also accepted. "
"Deployment type (used when creating a new deployment). "
"Ignored with --push-to."
),
),
@@ -2269,18 +2182,6 @@ def _deploy_base_options(
"creating a deployment with --push-to or --image-uri."
),
),
click.option(
"--image-pull-secret",
"image_pull_secrets",
multiple=True,
help=(
"Name of a Kubernetes secret (type kubernetes.io/"
"dockerconfigjson) in the deployment's namespace to use for "
"pulling the image from a private registry. Repeat to give "
"more than one. Only used when creating a deployment with "
"--push-to or --image-uri."
),
),
click.option(
"--config",
"-c",
@@ -2394,7 +2295,6 @@ def _deploy_cmd(
image_uri: str | None,
listener_id: str | None,
k8s_namespace: str | None,
image_pull_secrets: tuple[str, ...],
tag: str | None,
base_image: str | None,
install_command: str | None,
@@ -2471,7 +2371,6 @@ def _deploy_cmd(
tag=tag,
remote_build_flag=remote_build_flag,
placement=RequestedPlacement(listener_id, k8s_namespace),
image_pull_secrets=image_pull_secrets,
selector=selector,
)
@@ -2498,9 +2397,7 @@ def _deploy_cmd(
),
verbose=verbose,
selector=selector,
deployment_type={"serverless": "dev_zero", "dedicated": "prod"}.get(
deployment_type, deployment_type
),
deployment_type=deployment_type,
secrets=secrets,
tracked_packages=tracked_packages,
)
@@ -2574,13 +2471,6 @@ def _deploy_cmd(
default="",
help="Only show deployments whose names contain this value.",
)
@click.option(
"--json",
"json_output",
is_flag=True,
default=False,
help="Print the deployments as a JSON array of {id, name} instead of a table.",
)
@deploy.command("list", help="[Beta] List LangSmith Deployments.")
def deploy_list(
api_key: str | None,
@@ -2588,13 +2478,11 @@ def deploy_list(
name_contains: str,
agent_id: str | None,
environment: str | None,
json_output: bool,
) -> None:
if agent_id is not None or environment is not None:
click.secho(
"Note: --agent-id and --agent-environment flags are in private beta",
fg="yellow",
err=True,
)
if agent_id is not None and not agent_id.strip():
raise click.UsageError("--agent-id must not be empty.")
@@ -2603,19 +2491,15 @@ def deploy_list(
filters["agent_id"] = agent_id
if environment is not None:
filters["agent_environment"] = environment
client = _create_host_backend_client(host_url, api_key, no_input=json_output)
resources = _call_host_backend_with_optional_tenant(
client = _create_host_backend_client(host_url, api_key)
deployments = _call_host_backend_with_optional_tenant(
client,
lambda c: c.list_deployments(name_contains=name_contains, **filters),
no_input=json_output,
)
deployments = [DeploymentRow.from_resource(resource) for resource in resources]
if json_output:
click.echo(json_mod.dumps([deployment.as_json() for deployment in deployments]))
elif not deployments:
if not deployments:
click.echo("No deployments found.")
else:
click.echo(DEPLOYMENTS_TABLE.render(deployments))
return
click.echo(format_deployments_table(deployments))
# ---------------------------------------------------------------------------
@@ -2634,13 +2518,6 @@ def deploy_listeners() -> None:
@OPT_HOST_API_KEY
@OPT_HOST_URL
@click.option(
"--json",
"json_output",
is_flag=True,
default=False,
help="Emit the listeners as a JSON array instead of a table.",
)
@deploy_listeners.command(
"list",
help=(
@@ -2649,23 +2526,15 @@ def deploy_listeners() -> None:
"--listener-id <id>` to deploy through it."
),
)
def deploy_listeners_list(
api_key: str | None, host_url: str | None, json_output: bool
) -> None:
client = _create_host_backend_client(host_url, api_key, no_input=json_output)
def deploy_listeners_list(api_key: str | None, host_url: str | None) -> None:
client = _create_host_backend_client(host_url, api_key)
listeners = _call_host_backend_with_optional_tenant(
client, lambda c: c.list_listeners(), no_input=json_output
client, lambda c: c.list_listeners()
)
if json_output:
click.echo(json_mod.dumps(listeners))
elif not listeners:
if not listeners:
click.echo("No listeners found for this workspace.")
else:
click.echo(
LISTENERS_TABLE.render(
[Listener.from_resource(listener) for listener in listeners]
)
)
return
click.echo(format_listeners_table(listeners))
# ---------------------------------------------------------------------------
@@ -2708,7 +2577,7 @@ def deploy_revisions_list(
if not revisions:
click.echo(f"No revisions found for deployment {deployment_id}.")
return
click.echo(REVISIONS_TABLE.render(_with_replaced_status(revisions)))
click.echo(format_revisions_table(revisions))
# ---------------------------------------------------------------------------
+5 -2
View File
@@ -44,8 +44,11 @@ async def subp_exec(
on_stdout: Callable[[str], bool | None] | None = None,
) -> tuple[str | None, str | None]:
if verbose:
# Never echo stdin: it can carry secrets (docker login --password-stdin).
print(f"+ {cmd} {' '.join(map(str, args))}")
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
if input:
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
else:
print(cmd_str)
if wait:
await asyncio.sleep(wait)
+9 -37
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import urlparse
@@ -221,51 +220,27 @@ class HostBackendClient:
*,
name: str | None = None,
name_contains: str | None = None,
limit: int | None = None,
agent_id: str | None = None,
agent_environment: str | None = None,
) -> list[dict[str, object]]:
) -> list[dict[str, Any]]:
given = (
("name", name),
("name_contains", name_contains),
("limit", limit),
("agent_id", agent_id),
("agent_environment", agent_environment),
)
filters = {key: value for key, value in given if value is not None}
return self._every_page("/v2/deployments", filters)
def _every_page(
self, path: str, filters: Mapping[str, object]
) -> list[dict[str, object]]:
resources: list[dict[str, object]] = []
seen_ids: set[object] = set()
while True:
page = _resources(
self._request(
"GET",
path,
params={
**filters,
"limit": MAX_PAGE_SIZE,
"offset": len(resources),
},
)
)
page_ids = {resource.get("id") for resource in page}
if page_ids and page_ids <= seen_ids:
raise HostBackendError(
f"GET {path} returned the same resources again: this control "
"plane ignores 'offset', so the CLI cannot read past the first page."
)
seen_ids |= page_ids
resources.extend(page)
if len(page) < MAX_PAGE_SIZE:
return resources
params = {key: value for key, value in given if value is not None}
return _resources(self._request("GET", "/v2/deployments", params=params))
def get_listener(self, listener_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/listeners/{listener_id}")
def list_listeners(self) -> list[dict[str, object]]:
return self._every_page("/v2/listeners", {})
def list_listeners(self) -> list[dict[str, Any]]:
return _resources(
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
)
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments/{deployment_id}")
@@ -274,12 +249,9 @@ class HostBackendClient:
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
# Tells the control plane this client logs in with the returned
# `username` and pushes to `repository_url`. LangSmith AWS requires it.
return self._request(
"POST",
f"/v2/deployments/{deployment_id}/push-token",
{"supports_registry_username": True},
)
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
-29
View File
@@ -373,27 +373,6 @@ class EncryptionConfig(TypedDict, total=False):
"""
class PriorityConfig(TypedDict, total=False):
"""Configuration for custom run priority logic.
Allows you to decide each thread's priority settings with a Python function
that the server calls at the first run on a thread with no settings, which
can come long after the thread was created."""
path: str
"""Required. Path to an instance of the Priority() class that registers an assign function.
Format: "path/to/file.py:my_priority"
Example:
{
"priority": {
"path": "./priority.py:my_priority"
}
}
"""
class CorsConfig(TypedDict, total=False):
"""Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
@@ -774,13 +753,6 @@ class Config(TypedDict, total=False):
Allows you to implement custom encryption for sensitive data stored in the database.
"""
priority: PriorityConfig | None
"""Optional. Custom run priority config, including the path to your Python priority logic.
Allows you to decide each thread's priority settings at the first run on a thread with no settings,
which can come long after the thread was created.
"""
http: HttpConfig | None
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
and how cross-origin requests are handled.
@@ -812,7 +784,6 @@ __all__ = [
"CheckpointerConfig",
"AuthConfig",
"EncryptionConfig",
"PriorityConfig",
"HttpConfig",
"MiddlewareOrders",
"Distros",
-1
View File
@@ -910,7 +910,6 @@ def python_config_to_docker_uv_lock(
for section, key in [
("auth", "path"),
("encryption", "path"),
("priority", "path"),
("checkpointer", "path"),
("http", "app"),
]:
-45
View File
@@ -196,17 +196,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"anyOf": [
{
@@ -449,17 +438,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"$ref": "#/$defs/UvSource"
},
@@ -688,17 +666,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"anyOf": [
{
@@ -1203,18 +1170,6 @@
},
"required": []
},
"PriorityConfig": {
"title": "PriorityConfig",
"description": "Configuration for custom run priority logic.\n\nAllows you to decide each thread's priority settings with a Python function\nthat the server calls at the first run on a thread with no settings, which\ncan come long after the thread was created.",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Required. Path to an instance of the Priority() class that registers an assign function.\n"
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
-45
View File
@@ -196,17 +196,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"anyOf": [
{
@@ -449,17 +438,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"$ref": "#/$defs/UvSource"
},
@@ -688,17 +666,6 @@
}
]
},
"priority": {
"anyOf": [
{
"$ref": "#/$defs/PriorityConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom run priority config, including the path to your Python priority logic.\n\nAllows you to decide each thread's priority settings at the first run on a thread with no settings,\nwhich can come long after the thread was created.\n"
},
"source": {
"anyOf": [
{
@@ -1203,18 +1170,6 @@
},
"required": []
},
"PriorityConfig": {
"title": "PriorityConfig",
"description": "Configuration for custom run priority logic.\n\nAllows you to decide each thread's priority settings with a Python function\nthat the server calls at the first run on a thread with no settings, which\ncan come long after the thread was created.",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Required. Path to an instance of the Priority() class that registers an assign function.\n"
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
-441
View File
@@ -2,12 +2,10 @@ import json
import pathlib
import re
import shutil
import sys
import tempfile
import textwrap
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock
import click
from click.testing import CliRunner
@@ -16,7 +14,6 @@ import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.host_backend import HostBackendError
from langgraph_cli.util import clean_empty_lines
FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
@@ -373,32 +370,6 @@ def test_dev_command_requires_ssl_certfile_and_keyfile_together(tmp_path) -> Non
assert "Both --ssl-certfile and --ssl-keyfile must be provided" in result.output
def test_dev_command_passes_priority_to_the_server(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "langgraph.json"
config_path.write_text(
json.dumps(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"priority": {"path": "./priority.py:priority"},
}
),
encoding="utf-8",
)
# The test environment doesn't install langgraph-api, so stand in for its server.
server = MagicMock()
monkeypatch.setitem(sys.modules, "langgraph_api.cli", server)
# dev appends the working directory and its dependencies to sys.path.
monkeypatch.setattr(sys, "path", list(sys.path))
result = CliRunner().invoke(cli, ["dev", "--config", str(config_path)])
assert result.exit_code == 0, result.output
assert server.run_server.call_args.kwargs["priority"] == {
"path": "./priority.py:priority"
}
def test_deploy_list_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -482,177 +453,6 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No deployments found."
def test_deploy_list_prints_an_aligned_table(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(self, name_contains: str = ""):
return [
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{"id": "dep-4567", "name": "beta-agent", "source_config": {}},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Deployment ID Deployment Name Deployment URL ",
"------------- --------------- -------------------------",
"dep-123 alpha https://alpha.example.com",
"dep-4567 beta-agent - ",
]
def _deploy_list_json(
monkeypatch, deployments: list[dict], *extra_args: str
) -> click.testing.Result:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(
self,
name_contains: str = "",
agent_id: str | None = None,
agent_environment: str | None = None,
):
return deployments
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
return CliRunner().invoke(
cli,
[
"deploy",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
*extra_args,
],
)
def test_deploy_list_command_json(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{"id": "dep-456", "name": "beta", "source_config": {}},
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == [
{"id": "dep-123", "name": "alpha"},
{"id": "dep-456", "name": "beta"},
]
def test_deploy_list_json_never_prints_deployment_secrets(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": None},
"secrets": [{"name": "OPENAI_API_KEY", "value": "sk-plain-value"}],
}
],
)
assert result.exit_code == 0, result.output
assert "sk-plain-value" not in result.output
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
def test_deploy_list_command_json_no_results(monkeypatch) -> None:
result = _deploy_list_json(monkeypatch, [])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == []
def test_deploy_list_rejects_a_deployment_without_an_id(monkeypatch) -> None:
result = _deploy_list_json(monkeypatch, [{"name": "alpha"}])
assert result.exit_code != 0
assert "deployment without an id" in result.output
def test_deploy_list_json_keeps_the_agent_note_off_stdout(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[{"id": "dep-123", "name": "alpha"}],
"--agent-id",
"customer-support",
"--agent-environment",
"staging",
)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
assert "private beta" in result.stderr
def test_deploy_list_json_never_prompts_for_a_workspace(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(self, name_contains: str = ""):
raise HostBackendError("requires workspace specification", status_code=403)
def set_tenant(self, tenant_id: str) -> None:
raise AssertionError("must not retry with a prompted tenant with --json")
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
prompt_calls = []
monkeypatch.setattr(
click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable"
)
result = CliRunner().invoke(
cli,
[
"deploy",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert prompt_calls == []
assert result.exit_code != 0
assert "LANGSMITH_TENANT_ID" in result.output
def test_deploy_listeners_list_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -735,199 +535,6 @@ def test_deploy_listeners_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No listeners found for this workspace."
def test_deploy_listeners_list_prints_an_aligned_table(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return [
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
},
{
"id": "listener-22",
"compute_id": "multi",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
{"id": "listener-3", "compute_id": "", "compute_config": {}},
{
"id": "listener-4",
"compute_id": None,
"compute_config": {"k8s_namespaces": ["agents"]},
},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"listeners",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Listener ID Compute ID Namespaces ",
"----------- ------------ ----------------------",
"listener-1 prod-cluster agents ",
"listener-22 multi agents, agents-staging",
"listener-3 - - ",
"listener-4 - agents ",
]
def test_deploy_listeners_list_rejects_a_listener_without_an_id(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return [{"compute_id": "prod-cluster"}]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"listeners",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code != 0
assert "listener without an id" in result.output
def test_deploy_listeners_list_command_json(monkeypatch) -> None:
runner = CliRunner()
listeners = [
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
},
{
"id": "listener-2",
"compute_id": "multi-cluster",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
]
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return listeners
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"listeners",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == listeners
def test_deploy_listeners_list_command_json_no_results(monkeypatch) -> None:
runner = CliRunner()
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return []
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = runner.invoke(
cli,
[
"deploy",
"listeners",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == []
def test_deploy_listeners_list_command_json_org_scoped_403(monkeypatch) -> None:
"""With --json, an org-scoped 403 must raise cleanly instead of
prompting for a workspace ID, which would write to stdout and hang
waiting for input that a scripted invocation never provides."""
runner = CliRunner()
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
raise HostBackendError("requires workspace specification", status_code=403)
def set_tenant(self, tenant_id: str) -> None:
raise AssertionError("must not retry with a prompted tenant with --json")
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
prompt_calls = []
monkeypatch.setattr(
click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable"
)
result = runner.invoke(
cli,
[
"deploy",
"listeners",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert prompt_calls == [], "must not prompt interactively with --json"
assert result.exit_code != 0
assert "LANGSMITH_TENANT_ID" in result.output
def test_deploy_revisions_list_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -1015,54 +622,6 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No revisions found for deployment dep-123."
def test_deploy_revisions_list_marks_older_deployed_revisions_as_replaced(
monkeypatch,
) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_revisions(self, deployment_id: str, limit: int = 1):
return [
{
"id": "rev-3",
"status": "DEPLOYING",
"created_at": "2026-10-20T10:00:00Z",
},
{
"id": "rev-2",
"status": "DEPLOYED",
"created_at": "2026-10-19T10:00:00Z",
},
{"id": "rev-1", "status": "DEPLOYED", "created_at": None},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"revisions",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"dep-123",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Revision ID Status Created At ",
"----------- --------- --------------------",
"rev-3 DEPLOYING 2026-10-20T10:00:00Z",
"rev-2 DEPLOYED 2026-10-19T10:00:00Z",
"rev-1 REPLACED - ",
]
def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -52,8 +52,6 @@ ARCHIVE = ("/tmp/src.tgz", 2048, "langgraph.json")
OBJECT_PATH = "tarballs/src.tgz"
PLATFORM_FORMAT = "{{.Os}}/{{.Architecture}}"
DIGESTS_FORMAT = "{{json .RepoDigests}}"
CONTEXT_HOST_FORMAT = "{{.Endpoints.docker.Host}}"
CONTEXT_HOST = "unix:///Users/dev/.docker/run/docker.sock"
NOT_A_CLI_DEPLOYMENT = (
"push token is only available for 'internal_docker' source deployments"
)
@@ -95,7 +93,6 @@ class ControlPlaneDouble:
timeline: list[str]
existing_deployments: list[dict] = field(default_factory=list)
push_token_status: int = 200
push_token_extra: dict = field(default_factory=dict)
create_error: str | None = None
listeners: list[dict] = field(default_factory=list)
listeners_by_id: dict[str, dict] = field(default_factory=dict)
@@ -111,11 +108,7 @@ class ControlPlaneDouble:
def _respond(self, request: httpx.Request) -> httpx.Response:
method, path = request.method, request.url.path
if (method, path) == ("GET", "/v2/listeners"):
offset = int(request.url.params.get("offset", 0))
limit = int(request.url.params.get("limit", 20))
return httpx.Response(
200, json={"resources": self.listeners[offset : offset + limit]}
)
return httpx.Response(200, json={"resources": self.listeners})
if method == "GET" and path.startswith("/v2/listeners/"):
listener_id = path.rsplit("/", 1)[-1]
if not _looks_like_a_uuid(listener_id):
@@ -154,12 +147,7 @@ class ControlPlaneDouble:
if self.push_token_status != 200:
return httpx.Response(self.push_token_status, text=NOT_A_CLI_DEPLOYMENT)
return httpx.Response(
200,
json={
"token": PUSH_TOKEN,
"registry_url": REGISTRY_URL,
**self.push_token_extra,
},
200, json={"token": PUSH_TOKEN, "registry_url": REGISTRY_URL}
)
if path.endswith("/upload-url"):
return httpx.Response(
@@ -198,8 +186,6 @@ class DockerCommand:
class DockerDouble:
timeline: list[str]
failing_pushes: int = 0
context_lookup_fails: bool = False
context_host: str = CONTEXT_HOST
builds: list[dict] = field(default_factory=list)
commands: list[DockerCommand] = field(default_factory=list)
@@ -243,10 +229,6 @@ class DockerDouble:
if "push" in args and self.failing_pushes > 0:
self.failing_pushes -= 1
raise click.exceptions.Exit(1)
if CONTEXT_HOST_FORMAT in args:
if self.context_lookup_fails:
raise click.exceptions.Exit(1)
return f"{self.context_host}\n", None
if PLATFORM_FORMAT in args:
return "linux/amd64\n", None
if DIGESTS_FORMAT in args:
@@ -256,8 +238,6 @@ class DockerDouble:
@staticmethod
def _verb(args: tuple[str, ...]) -> str:
if CONTEXT_HOST_FORMAT in args:
return "inspect-context"
if PLATFORM_FORMAT in args:
return "inspect-platform"
if DIGESTS_FORMAT in args:
@@ -306,12 +286,7 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro
json.dumps({"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]})
)
monkeypatch.chdir(tmp_path)
for name in (
"LANGSMITH_TENANT_ID",
"LANGSMITH_ENDPOINT",
"LANGGRAPH_HOST_URL",
"DOCKER_HOST",
):
for name in ("LANGSMITH_TENANT_ID", "LANGSMITH_ENDPOINT", "LANGGRAPH_HOST_URL"):
monkeypatch.delenv(name, raising=False)
timeline: list[str] = []
@@ -348,44 +323,6 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro
return DeployProject(control_plane, docker, timeline, uploads)
@pytest.mark.parametrize("build_mode", ["--no-remote", "--remote"])
@pytest.mark.parametrize(
("detail", "expects_dev_hint"),
[
("Scale to zero deployments are not supported in AWS environments", True),
("No eligible cluster is currently available to host this deployment.", False),
],
)
def test_create_failure_preserves_backend_error_and_only_hints_for_aws_rejection(
deploy_project: DeployProject, build_mode: str, detail: str, expects_dev_hint: bool
) -> None:
deploy_project.control_plane.create_error = detail
result = deploy_project.run(build_mode)
assert result.exit_code == 1, result.output
assert detail in result.output
assert ("Use --deployment-type dev instead." in result.output) is expects_dev_hint
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"deployment_type": "dev_zero"
}
assert deploy_project.timeline == [LIST_DEPLOYMENTS, CREATE_DEPLOYMENT]
@pytest.mark.parametrize("build_mode", ["--no-remote", "--remote"])
def test_aws_serverless_creation_succeeds_when_backend_accepts_it(
deploy_project: DeployProject, build_mode: str
) -> None:
result = deploy_project.run(
build_mode, host_url="https://aws.api.host.langchain.com"
)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"deployment_type": "dev_zero"
}
def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order(
deploy_project: DeployProject,
) -> None:
@@ -397,7 +334,6 @@ def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order(
CREATE_DEPLOYMENT,
"docker build",
_push_token(CREATED_ID),
"docker inspect-context",
"docker login",
"docker tag",
"docker push",
@@ -407,41 +343,15 @@ def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order(
assert "Deployment updated" in result.output
@pytest.mark.parametrize(
("deployment_type", "wire_type"),
[
("serverless", "dev_zero"),
("dedicated", "prod"),
("dev", "dev"),
("prod", "prod"),
],
)
@pytest.mark.parametrize("build_mode", ["--no-remote", "--remote"])
def test_deployment_type_aliases(
deploy_project: DeployProject, deployment_type: str, wire_type: str, build_mode: str
def test_first_local_deploy_creates_an_internal_docker_deployment(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(build_mode, "--deployment-type", deployment_type)
deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"deployment_type": wire_type
}
@pytest.mark.parametrize(
("build_mode", "source"),
[("--no-remote", "internal_docker"), ("--remote", "internal_source")],
)
def test_first_deploy_defaults_to_serverless(
deploy_project: DeployProject, build_mode: str, source: str
) -> None:
result = deploy_project.run(build_mode)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT] == {
"name": "my-app",
"source": source,
"source_config": {"deployment_type": "dev_zero"},
"source": "internal_docker",
"source_config": {"deployment_type": "dev"},
"source_revision_config": {},
"secrets": [],
}
@@ -453,7 +363,7 @@ def test_first_deploy_defaults_to_serverless(
pytest.param(
"arm64",
("docker", "buildx", "build"),
("--platform", "linux/amd64", "--load", "--progress=plain"),
("--platform", "linux/amd64", "--load", "--progress=quiet"),
id="apple_silicon_cross_builds_for_linux_amd64",
),
pytest.param(
@@ -490,7 +400,7 @@ def test_local_deploy_logs_in_with_the_control_plane_push_token(
login = deploy_project.docker.command("login")
assert login.args[:2] == ("docker", "--config")
assert login.args[-5:] == (
assert login.args[3:] == (
"login",
"-u",
"oauth2accesstoken",
@@ -500,95 +410,6 @@ def test_local_deploy_logs_in_with_the_control_plane_push_token(
assert login.kwargs["input"] == f"{PUSH_TOKEN}\n"
def test_local_deploy_logs_in_and_pushes_through_the_active_docker_context(
deploy_project: DeployProject,
) -> None:
"""The temporary config has no current context, so without the active
context's endpoint Docker would push through /var/run/docker.sock."""
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
for verb in ("login", "push"):
args = deploy_project.docker.command(verb).args
assert args[3:5] == ("--host", CONTEXT_HOST), verb
def test_local_deploy_leaves_an_explicit_docker_host_to_docker(
deploy_project: DeployProject, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("DOCKER_HOST", "tcp://127.0.0.1:2375")
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
for verb in ("login", "push"):
assert "--host" not in deploy_project.docker.command(verb).args, verb
def test_local_deploy_keeps_dockers_default_when_the_context_lookup_fails(
deploy_project: DeployProject,
) -> None:
deploy_project.docker.context_lookup_fails = True
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
assert "--host" not in deploy_project.docker.command("push").args
def test_local_deploy_ignores_a_context_without_a_docker_endpoint(
deploy_project: DeployProject,
) -> None:
deploy_project.docker.context_host = "<no value>"
result = deploy_project.run("--no-remote")
assert result.exit_code == 0, result.output
assert "--host" not in deploy_project.docker.command("push").args
def test_local_deploy_tells_the_control_plane_it_honors_registry_usernames(
deploy_project: DeployProject,
) -> None:
deploy_project.run("--no-remote")
assert deploy_project.control_plane.bodies[_push_token(CREATED_ID)] == {
"supports_registry_username": True
}
ECR_REPOSITORY = "123456789012.dkr.ecr.us-east-2.amazonaws.com/lsd/agent-1"
ECR_PUSH_TOKEN_FIELDS = {"username": "AWS", "repository_url": ECR_REPOSITORY}
def test_local_deploy_logs_in_with_the_username_the_control_plane_returns(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.push_token_extra = ECR_PUSH_TOKEN_FIELDS
deploy_project.run("--no-remote")
login = deploy_project.docker.command("login")
assert login.args[-5:] == (
"login",
"-u",
"AWS",
"--password-stdin",
"123456789012.dkr.ecr.us-east-2.amazonaws.com",
)
def test_local_deploy_pushes_to_exactly_the_returned_repository(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.push_token_extra = ECR_PUSH_TOKEN_FIELDS
deploy_project.run("--no-remote")
tag = deploy_project.docker.command("tag")
assert tag.args[-1] == f"{ECR_REPOSITORY}:latest"
def test_local_deploy_tags_the_build_into_the_token_registry(
deploy_project: DeployProject,
) -> None:
@@ -637,7 +458,6 @@ def test_prebuilt_image_is_validated_and_pushed_without_a_build(
assert deploy_project.docker.builds == []
assert deploy_project.docker.verbs() == [
"docker inspect-platform",
"docker inspect-context",
"docker login",
"docker tag",
"docker push",
@@ -1098,100 +918,6 @@ def test_self_hosted_control_plane_places_when_asked(
}
def test_image_pull_secret_is_included_in_resource_spec_on_create(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--push-to",
PUSH_REPOSITORY,
"--image-pull-secret",
"regcred",
"--image-pull-secret",
"other-secret",
)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"resource_spec": {
"image_pull_secrets": [
{"name": "regcred"},
{"name": "other-secret"},
],
},
}
def test_image_pull_secret_works_with_image_uri_too(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--image-uri", EXTERNAL_DIGEST, "--image-pull-secret", "regcred"
)
assert result.exit_code == 0, result.output
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == {
"resource_spec": {"image_pull_secrets": [{"name": "regcred"}]},
}
def test_image_pull_secret_without_push_to_or_image_uri_is_refused(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run("--image-pull-secret", "regcred")
assert result.exit_code != 0
assert "only apply when creating a deployment with --push-to" in result.output
assert deploy_project.timeline == []
def test_an_invalid_image_pull_secret_name_is_rejected_before_any_docker_work(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--push-to", PUSH_REPOSITORY, "--image-pull-secret", "Not_Valid!"
)
assert result.exit_code != 0
assert "not a valid Kubernetes" in result.output
assert deploy_project.docker.verbs() == []
assert deploy_project.timeline == []
def test_image_pull_secret_cannot_update_an_existing_deployment(
deploy_project: DeployProject,
) -> None:
result = deploy_project.run(
"--deployment-id",
"dep-1",
"--push-to",
PUSH_REPOSITORY,
"--image-pull-secret",
"regcred",
)
assert result.exit_code != 0
assert "--image-pull-secret" in result.output
assert "dep-1" in result.output
assert deploy_project.timeline == []
def test_image_pull_secret_cannot_update_a_deployment_found_by_name(
deploy_project: DeployProject,
) -> None:
deploy_project.control_plane.existing_deployments = [
{"id": "dep-ext", "name": "my-app", "source": "external_docker"}
]
result = deploy_project.run(
"--image-uri", EXTERNAL_DIGEST, "--image-pull-secret", "regcred"
)
assert result.exit_code != 0
assert "--image-pull-secret" in result.output
assert deploy_project.docker.verbs() == []
assert _patch("dep-ext") not in deploy_project.timeline
def test_updating_a_deployment_never_looks_up_listeners(
deploy_project: DeployProject,
) -> None:
@@ -1292,16 +1018,14 @@ def test_listener_flags_without_push_to_make_no_call_at_all(
assert deploy_project.timeline == []
def test_listeners_beyond_the_first_page_are_counted(
deploy_project: DeployProject,
) -> None:
def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> None:
deploy_project.control_plane.listeners = [
{
"id": str(uuid.UUID(int=index)),
"compute_id": "cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
}
for index in range(101)
for index in range(100)
]
result = deploy_project.run(
@@ -1309,8 +1033,7 @@ def test_listeners_beyond_the_first_page_are_counted(
)
assert result.exit_code != 0
assert "... and 91 more" in result.output
assert "first 100" not in result.output
assert "first 100" in result.output
def test_a_managed_build_in_a_listener_workspace_points_at_push_to(
-138
View File
@@ -16,7 +16,6 @@ from langgraph_cli.config import (
config_to_docker,
default_base_image,
docker_tag,
get_unknown_keys,
has_disallowed_build_command_content,
validate_config,
validate_config_file,
@@ -156,7 +155,6 @@ def test_validate_config():
"store": None,
"auth": None,
"encryption": None,
"priority": None,
"webhooks": None,
"checkpointer": None,
"http": None,
@@ -186,7 +184,6 @@ def test_validate_config():
"store": None,
"auth": None,
"encryption": None,
"priority": None,
"webhooks": None,
"checkpointer": None,
"http": None,
@@ -1292,109 +1289,6 @@ def test_config_to_docker_python_encryption_formatted():
)
@pytest.mark.parametrize(
"path", ["./priority.py:priority", "my_package.priority:priority"]
)
def test_config_to_docker_python_priority(path: str):
# Test that priority config is kept by validation and isn't an unknown key
raw_config = {
"python_version": "3.11",
"graphs": {"agent": "./agent.py:graph"},
"dependencies": ["."],
"priority": {"path": path},
}
validated = validate_config(raw_config)
assert validated["priority"] == {"path": path}
assert get_unknown_keys(raw_config) == []
def test_config_to_docker_python_priority_bad_path():
# Test that invalid priority path format raises ValueError
with pytest.raises(ValueError, match="Invalid priority.path format"):
validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./agent.py:graph"},
"dependencies": ["."],
"priority": {"path": "./priority.py"}, # Missing :attribute
}
)
@pytest.mark.parametrize("ext", [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"])
def test_config_to_docker_python_priority_js_path(ext: str):
# Test that a JavaScript priority path is rejected
with pytest.raises(ValueError, match="JavaScript priority callbacks"):
validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./agent.py:graph"},
"dependencies": ["."],
"priority": {"path": f"./priority{ext}:priority"},
}
)
def test_config_to_docker_nodejs_priority():
# Test that a Node.js-only project can name a Python priority file
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"node_version": "20",
"graphs": {"agent": "./graphs/agent.js:graph"},
"priority": {"path": "./priority.py:priority"},
}
),
base_image="langchain/langgraphjs-api",
)
assert (
"""ENV LANGGRAPH_PRIORITY='{"path": "./priority.py:priority"}'"""
in actual_docker_stdin
)
def test_config_to_docker_python_priority_formatted():
# Test that priority config is written to the Docker output with its container path
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./graphs/agent.py:graph"},
"priority": {"path": "./agent.py:my_priority"},
}
),
base_image="langchain/langgraph-api",
)
assert (
"ENV LANGGRAPH_PRIORITY="
"""'{"path": "/deps/outer-unit_tests/unit_tests/agent.py:my_priority"}'"""
in actual_docker_stdin
)
def test_config_to_docker_python_priority_not_in_dependencies():
# Test that a priority file outside the dependencies raises ValueError
with pytest.raises(
ValueError, match="Priority file .* not covered by dependencies"
):
config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"python_version": "3.11",
"dependencies": ["./graphs"],
"graphs": {"agent": "./graphs/agent.py:graph"},
"priority": {"path": "./agent.py:my_priority"},
}
),
base_image="langchain/langgraph-api",
)
def test_config_to_docker_nodejs_internal_docker_tag():
graphs = {"agent": "./graphs/agent.js:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
@@ -1837,38 +1731,6 @@ def test_config_to_docker_uv_lock_honors_root_workspace_sources():
)
def test_config_to_docker_uv_lock_rewrites_priority_path():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root, config_path = _write_uv_lock_workspace(
tmpdir_path,
agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
)
(project_root / "libs" / "shared" / "src" / "shared" / "priority.py").touch()
config = validate_config(
{
"python_version": "3.11",
"graphs": {
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": {"kind": "uv", "root": "../..", "package": "agent"},
"priority": {
"path": "../../libs/shared/src/shared/priority.py:priority"
},
}
)
docker, _ = config_to_docker(
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
assert (
"ENV LANGGRAPH_PRIORITY="
"""'{"path": "/deps/workspace/libs/shared/src/shared/priority.py:priority"}'"""
in docker
)
def test_config_to_docker_uv_lock_ignores_unrelated_workspace_package_sources():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
@@ -75,7 +75,6 @@ def test_agent_create(deployment_api, tmp_path, monkeypatch):
"agent_id": "customer-support",
"agent_environment": "staging",
"limit": "100",
"offset": "0",
}
payload = json.loads(requests[1].content)
assert payload["agent"] == {
+24 -179
View File
@@ -16,7 +16,6 @@ from langgraph_cli.deploy import (
BuildAndPush,
ById,
ByName,
CreateOnlySettings,
CustomerRegistrySource,
DockerBuildCommand,
ExistingDeployment,
@@ -33,10 +32,8 @@ from langgraph_cli.deploy import (
_Emitter,
_env_without_deployment_name,
_parse_env_from_config,
_print_deployment_result,
_resolve_env_path,
_resolve_pushed_image_digest,
_resource_spec,
_select_source,
_validate_prebuilt_image,
find_deployment_by_name,
@@ -49,9 +46,7 @@ from langgraph_cli.image_reference import ImageReference
class TestDockerConfigForToken:
def test_creates_config_json(self):
with _docker_config_for_token(
"us-docker.pkg.dev", "my-token", "oauth2accesstoken"
) as cfg:
with _docker_config_for_token("us-docker.pkg.dev", "my-token") as cfg:
config_path = os.path.join(cfg, "config.json")
assert os.path.isfile(config_path)
with open(config_path) as f:
@@ -59,26 +54,13 @@ class TestDockerConfigForToken:
expected_auth = base64.b64encode(b"oauth2accesstoken:my-token").decode()
assert data == {"auths": {"us-docker.pkg.dev": {"auth": expected_auth}}}
def test_uses_the_given_username(self):
with _docker_config_for_token(
"123456789012.dkr.ecr.us-east-2.amazonaws.com", "ecr-password", "AWS"
) as cfg:
with open(os.path.join(cfg, "config.json")) as f:
data = json.load(f)
expected_auth = base64.b64encode(b"AWS:ecr-password").decode()
assert data["auths"]["123456789012.dkr.ecr.us-east-2.amazonaws.com"] == {
"auth": expected_auth
}
def test_tempdir_cleaned_up(self):
with _docker_config_for_token(
"registry.example.com", "tok", "oauth2accesstoken"
) as cfg:
with _docker_config_for_token("registry.example.com", "tok") as cfg:
assert os.path.isdir(cfg)
assert not os.path.exists(cfg)
def test_different_registries(self):
with _docker_config_for_token("gcr.io", "token123", "oauth2accesstoken") as cfg:
with _docker_config_for_token("gcr.io", "token123") as cfg:
with open(os.path.join(cfg, "config.json")) as f:
data = json.load(f)
assert "gcr.io" in data["auths"]
@@ -537,39 +519,6 @@ class TestEmitterTextMode:
assert "URL: https://app.test" in lines
@pytest.mark.parametrize(
("deployment", "expected_url_lines"),
[
pytest.param(
{
"id": "dep-1",
"source_config": {"custom_url": "https://agent.example.com"},
},
["URL: https://agent.example.com"],
id="custom_url_is_printed",
),
pytest.param(
{"id": "dep-1", "source_config": {}}, [], id="no_custom_url_no_line"
),
],
)
def test_a_deployed_result_prints_the_deployment_url(
capsys, monkeypatch, deployment, expected_url_lines
):
class FakeClient:
def get_deployment(self, deployment_id: str) -> dict:
return deployment
monkeypatch.setattr(deploy_mod, "_emitter", None)
_print_deployment_result(
FakeClient(), "dep-1", "DEPLOYED", dashboard_label="Deployment dashboard"
)
lines = [line.strip() for line in capsys.readouterr().out.splitlines()]
assert [line for line in lines if line.startswith("URL:")] == expected_url_lines
# ---------------------------------------------------------------------------
# --no-input guard on _create_host_backend_client
# ---------------------------------------------------------------------------
@@ -644,9 +593,9 @@ class TestDockerBuildCommand:
False,
DockerBuildCommand(
("docker", "buildx", "build"),
("--platform", "linux/amd64", "--load", "--progress=plain"),
("--platform", "linux/amd64", "--load", "--progress=quiet"),
),
id="other_hosts_capture_plain_build_logs",
id="other_hosts_cross_build_quietly",
),
pytest.param(
"arm64",
@@ -672,7 +621,6 @@ class TestSelectSource:
"tag": None,
"remote_build_flag": None,
"placement": RequestedPlacement(),
"image_pull_secrets": (),
"selector": ByName("my-app"),
}
REPOSITORY = "registry.example.com/app"
@@ -688,7 +636,7 @@ class TestSelectSource:
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="push_to_selects_the_external_source_with_the_default_tag",
),
@@ -700,7 +648,7 @@ class TestSelectSource:
reference=ImageReference(REPOSITORY, "v2"),
prebuilt_image=None,
),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="push_to_keeps_a_tag_given_in_the_reference",
),
@@ -712,7 +660,7 @@ class TestSelectSource:
reference=ImageReference(REPOSITORY, "v3"),
prebuilt_image=None,
),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="tag_flag_composes_with_push_to",
),
@@ -724,7 +672,7 @@ class TestSelectSource:
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image="app:dev",
),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="prebuilt_image_is_retagged_for_push_to_without_docker_checks",
),
@@ -739,35 +687,16 @@ class TestSelectSource:
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
),
create_only=CreateOnlySettings(
RequestedPlacement("listener-1", "agents")
),
requested_placement=RequestedPlacement("listener-1", "agents"),
),
id="push_to_carries_the_requested_placement",
),
pytest.param(
{
"push_to": REPOSITORY,
"image_pull_secrets": ("regcred", "other-secret"),
},
True,
CustomerRegistrySource(
image=BuildAndPush(
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
),
create_only=CreateOnlySettings(
RequestedPlacement(), ("regcred", "other-secret")
),
),
id="push_to_carries_the_image_pull_secrets",
),
pytest.param(
{"image_uri": f"{REPOSITORY}@sha256:abc123"},
True,
CustomerRegistrySource(
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="image_uri_selects_the_published_image_source_by_digest",
),
@@ -776,7 +705,7 @@ class TestSelectSource:
False,
CustomerRegistrySource(
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
create_only=CreateOnlySettings(RequestedPlacement()),
requested_placement=RequestedPlacement(),
),
id="image_uri_needs_no_local_docker_and_is_trimmed",
),
@@ -788,9 +717,7 @@ class TestSelectSource:
False,
CustomerRegistrySource(
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
create_only=CreateOnlySettings(
RequestedPlacement("listener-1", "agents")
),
requested_placement=RequestedPlacement("listener-1", "agents"),
),
id="image_uri_carries_the_requested_placement",
),
@@ -879,11 +806,6 @@ class TestSelectSource:
"only apply when creating a deployment with --push-to",
id="namespace_without_push_to",
),
pytest.param(
{"image_pull_secrets": ("regcred",)},
"only apply when creating a deployment with --push-to",
id="image_pull_secret_without_push_to",
),
pytest.param(
{"image_uri": REPOSITORY, "push_to": REPOSITORY},
"--image-uri cannot be combined with --push-to.",
@@ -1244,7 +1166,6 @@ def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_versio
"name": "agent",
"name_contains": "agent",
"limit": "100",
"offset": "0",
}
assert found == ExistingDeployment("dep-1", "github")
@@ -1272,29 +1193,20 @@ def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deplo
assert find_deployment_by_name(client, "brand-new-agent") is None
def test_a_deployment_beyond_the_first_page_is_found_by_name():
deployments = [
*(
{"id": f"dep-{index}", "name": f"other-agent-{index}"}
for index in range(100)
),
{"id": "dep-target", "name": "agent", "source": "external_docker"},
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
page = [
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
]
def handler(req: httpx.Request) -> httpx.Response:
offset = int(req.url.params["offset"])
limit = int(req.url.params["limit"])
return httpx.Response(
200, json={"resources": deployments[offset : offset + limit]}
)
client = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
"https://api.example.com",
"key",
transport=httpx.MockTransport(
lambda req: httpx.Response(200, json={"resources": page})
),
)
assert find_deployment_by_name(client, "agent") == ExistingDeployment(
"dep-target", "external_docker"
)
with pytest.raises(click.ClickException, match="--deployment-id"):
find_deployment_by_name(client, "brand-new-agent")
def test_a_partial_page_without_a_match_means_the_name_is_free():
@@ -1331,7 +1243,7 @@ def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
with pytest.raises(click.UsageError, match="dep-1"):
with pytest.raises(click.UsageError, match="--deployment-id"):
_select_source(
push_to="registry.example.com/app",
image=None,
@@ -1340,77 +1252,10 @@ def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
tag=None,
remote_build_flag=None,
placement=RequestedPlacement(listener_id="listener-1"),
image_pull_secrets=(),
selector=ById("dep-1"),
)
def test_a_deployment_id_with_image_pull_secrets_is_refused_without_probing_docker(
monkeypatch,
):
def explode() -> tuple[bool, str | None]:
raise AssertionError("docker must not be probed for an argv-only conflict")
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
with pytest.raises(click.UsageError, match="dep-1"):
_select_source(
push_to="registry.example.com/app",
image=None,
image_uri=None,
image_name=None,
tag=None,
remote_build_flag=None,
placement=RequestedPlacement(),
image_pull_secrets=("regcred",),
selector=ById("dep-1"),
)
class TestResourceSpec:
def test_no_secrets_keeps_the_operator_default(self):
assert _resource_spec(()) == {}
def test_secrets_become_local_object_references_in_order(self):
assert _resource_spec(("regcred", "other-secret")) == {
"image_pull_secrets": [
{"name": "regcred"},
{"name": "other-secret"},
],
}
class TestCreateOnlySettings:
@pytest.mark.parametrize(
"name",
[
pytest.param("Bad_Name", id="uppercase_and_underscore"),
pytest.param("", id="empty"),
pytest.param("-leading-hyphen", id="leading_hyphen"),
pytest.param("a" * 254, id="too_long"),
],
)
def test_an_invalid_secret_name_is_rejected_up_front(self, name):
with pytest.raises(click.UsageError, match="not a valid Kubernetes"):
CreateOnlySettings(RequestedPlacement(), (name,))
def test_a_valid_secret_name_is_accepted(self):
CreateOnlySettings(RequestedPlacement(), ("regcred-1.example",))
def test_ensure_not_existing_mentions_the_api_and_ui_for_pull_secrets(self):
settings = CreateOnlySettings(RequestedPlacement(), ("regcred",))
with pytest.raises(click.UsageError, match="API or the LangSmith UI"):
settings.ensure_not_existing("dep-1")
def test_ensure_not_existing_keeps_the_placement_message_unchanged(self):
settings = CreateOnlySettings(RequestedPlacement(listener_id="listener-1"))
with pytest.raises(
click.UsageError,
match="Listener and namespace are fixed when a deployment is created",
):
settings.ensure_not_existing("dep-1")
class TestPlacementOnAKnownListener:
@pytest.mark.parametrize(
("request_", "listener", "expected"),
-17
View File
@@ -1,17 +0,0 @@
import sys
from langgraph_cli.exec import subp_exec
async def test_verbose_prints_command_but_not_stdin(capsys):
await subp_exec(
sys.executable,
"-c",
"import sys; sys.stdin.read()",
input="registry-push-token\n",
verbose=True,
)
out = capsys.readouterr().out
assert out.startswith(f"+ {sys.executable} -c"), out
assert "registry-push-token" not in out, out
+9 -72
View File
@@ -575,7 +575,7 @@ def test_list_listeners_asks_for_a_full_page():
)
assert c.list_listeners() == [{"id": "listener-1"}]
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100&offset=0"
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
@pytest.mark.parametrize(
@@ -602,30 +602,29 @@ def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expect
assert endpoints.is_cloud is expected
FIRST_PAGE = {"limit": "100", "offset": "0"}
@pytest.mark.parametrize(
("call", "expected_params"),
[
pytest.param(
lambda c: c.list_deployments(name="agent"),
{"name": "agent", **FIRST_PAGE},
{"name": "agent"},
id="exact_name_filters_server_side",
),
pytest.param(
lambda c: c.list_deployments(name_contains="age"),
{"name_contains": "age", **FIRST_PAGE},
{"name_contains": "age"},
id="substring_search_keeps_its_own_parameter",
),
pytest.param(
lambda c: c.list_deployments(),
FIRST_PAGE,
id="no_filter_sends_only_the_page",
{},
id="no_filter_sends_no_parameters",
),
pytest.param(
lambda c: c.list_deployments(name="agent", name_contains="agent"),
{"name": "agent", "name_contains": "agent", **FIRST_PAGE},
lambda c: c.list_deployments(
name="agent", name_contains="agent", limit=100
),
{"name": "agent", "name_contains": "agent", "limit": "100"},
id="both_filters_travel_together_for_older_servers",
),
],
@@ -646,68 +645,6 @@ def test_list_deployments_sends_one_name_filter(call, expected_params):
assert seen == expected_params
@pytest.mark.parametrize(
"list_every",
[
pytest.param(HostBackendClient.list_deployments, id="deployments"),
pytest.param(HostBackendClient.list_listeners, id="listeners"),
],
)
def test_list_endpoints_read_every_page(list_every):
resources = [{"id": f"resource-{index}"} for index in range(101)]
offsets: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
offset = int(req.url.params["offset"])
limit = int(req.url.params["limit"])
offsets.append(req.url.params["offset"])
return httpx.Response(
200, json={"resources": resources[offset : offset + limit]}
)
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
assert list_every(c) == resources
assert offsets == ["0", "100"]
FIRST_PAGE_OF_DEPLOYMENTS = [
{"id": f"dep-{index}", "status": "DEPLOYED"} for index in range(100)
]
@pytest.mark.parametrize(
"repeated_page",
[
pytest.param(FIRST_PAGE_OF_DEPLOYMENTS, id="same_page_again"),
pytest.param(
[
{"id": "dep-99", "status": "DEPLOYING"},
*FIRST_PAGE_OF_DEPLOYMENTS[:99],
],
id="same_deployments_reordered_and_updated",
),
],
)
def test_list_deployments_fails_when_the_server_ignores_the_offset(repeated_page):
pages = iter([FIRST_PAGE_OF_DEPLOYMENTS, repeated_page])
def handler(req: httpx.Request) -> httpx.Response:
page = next(pages, None)
if page is None:
return httpx.Response(500, json={"detail": "the guard let paging run on"})
return httpx.Response(200, json={"resources": page})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
with pytest.raises(HostBackendError, match="ignores 'offset'"):
c.list_deployments()
@pytest.mark.parametrize(
("body", "expected"),
[
+98 -4
View File
@@ -1,6 +1,11 @@
from unittest.mock import patch
from langgraph_cli.deploy import Table
from langgraph_cli.deploy import (
_extract_deployment_url,
format_deployments_table,
format_listeners_table,
format_revisions_table,
)
from langgraph_cli.util import clean_empty_lines, warn_non_wolfi_distro
@@ -189,7 +194,96 @@ def test_warn_non_wolfi_distro_does_not_modify_config():
assert config_copy == original_config # Config should remain unchanged
def test_a_table_without_rows_still_renders_its_headers():
table = Table(("Name", "Status"), lambda resource: (None, None))
def test_extract_deployment_url_uses_custom_url():
deployment = {"source_config": {"custom_url": "https://example.com/custom"}}
assert _extract_deployment_url(deployment) == "https://example.com/custom"
assert table.render([]).splitlines() == ["Name Status", "---- ------"]
def test_extract_deployment_url_defaults_to_dash():
assert _extract_deployment_url({"id": "dep-123"}) == "-"
def test_format_deployments_table():
output = format_deployments_table(
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"url": "https://beta.example.com",
},
]
)
assert "Deployment ID" in output
assert "Deployment Name" in output
assert "Deployment URL" in output
assert "dep-123" in output
assert "alpha" in output
assert "https://alpha.example.com" in output
assert "dep-456" in output
def test_format_revisions_table():
output = format_revisions_table(
[
{
"id": "rev-123",
"status": "DEPLOYED",
"created_at": "2023-11-09T10:00:00Z",
},
{
"id": "rev-456",
"status": "CREATING",
"created_at": "2023-11-07T05:31:56Z",
},
{
"id": "rev-789",
"status": "DEPLOYED",
"created_at": "2023-11-08T10:00:00Z",
},
]
)
assert "Revision ID" in output
assert "Status" in output
assert "Created At" in output
assert "rev-123" in output
assert "CREATING" in output
assert "2023-11-07T05:31:56Z" in output
assert "rev-456" in output
assert "rev-789" in output
assert "REPLACED" in output
def test_format_listeners_table():
output = format_listeners_table(
[
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
},
{
"id": "listener-2",
"compute_id": "multi-cluster",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
{
"id": "listener-3",
"compute_id": "broken-cluster",
},
]
)
assert "Listener ID" in output
assert "Compute ID" in output
assert "Namespaces" in output
assert "listener-1" in output
assert "prod-cluster" in output
assert "agents" in output
assert "listener-2" in output
assert "agents, agents-staging" in output
assert "listener-3" in output
assert "broken-cluster" in output
+10 -10
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.11"
[manifest]
@@ -52,11 +52,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
@@ -267,20 +267,20 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "ormsgpack" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/69/31fdbdc65a85bbd6178afa193c772bb926620f47b4869638bc2bc80afaaa/langgraph_checkpoint-4.3.0.tar.gz", hash = "sha256:c75965d84cc2c1d549163e910a15bcb577758001b141619d05297c463280b018", size = 182652, upload-time = "2026-10-12T22:26:31.478Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/0c/84747e340bf4f29291c84cdd5733fc8d0a822f3d33bb24e664a18afa4a7c/langgraph_checkpoint-4.3.0-py3-none-any.whl", hash = "sha256:bedfafe2f997ded60e4fa593e79f56f436a6e45586392dc382aa810d0c751c64", size = 58063, upload-time = "2026-10-12T22:26:30.429Z" },
{ url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" },
]
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.3"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -288,9 +288,9 @@ dependencies = [
{ name = "psycopg" },
{ name = "psycopg-pool" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/bf/d0ab4d6e4d61952de2f77044d7407b7ce09e07d53e7bb448cf9df55c35e5/langgraph_checkpoint_postgres-3.1.3.tar.gz", hash = "sha256:a152a9c0c3d5931bc949b64e01e8c7da20be57a32aa754626446318e90a07650", size = 158122, upload-time = "2026-10-12T23:05:19.759Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/92/1e8959f8cd1b56e672fde3227f6fd642be85af6c5fd662d73921074aa39d/langgraph_checkpoint_postgres-3.1.1.tar.gz", hash = "sha256:d320e147ddad8c374cd546df0b52b532dd54d0541dd9fd23fc738cbd5de76f41", size = 150413, upload-time = "2026-07-30T19:15:39.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/42/659106ed829ee026144e32ddd589735f978d2ed09681020e5965cdfca04c/langgraph_checkpoint_postgres-3.1.3-py3-none-any.whl", hash = "sha256:050ae583223e24d97747f27b9e06e7345bf13c972f1fb6ed33bb3d1f9c11cee4", size = 52048, upload-time = "2026-10-12T23:05:18.854Z" },
{ url = "https://files.pythonhosted.org/packages/03/32/ba457698a48a0e18d786caa770033067049fbe36d6846f8e50f13b594b51/langgraph_checkpoint_postgres-3.1.1-py3-none-any.whl", hash = "sha256:6e353aecd8150de144fef8e51a49076f58b7d6830d4cf51392b7ad4d79832ba7", size = 50778, upload-time = "2026-07-30T19:15:37.405Z" },
]
[[package]]
+3 -3
View File
@@ -26,11 +26,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
+73 -124
View File
@@ -1,10 +1,10 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'emscripten'",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform != 'emscripten')",
"(python_full_version >= '3.11' and python_full_version < '3.15' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')",
"python_full_version < '3.11'",
]
@@ -24,8 +24,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
@@ -169,11 +168,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
@@ -417,7 +416,7 @@ version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
@@ -470,11 +469,11 @@ wheels = [
[[package]]
name = "distlib"
version = "0.4.4"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz", hash = "sha256:ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d", size = 621304, upload-time = "2026-10-16T13:56:34.203Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl", hash = "sha256:cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638", size = 472099, upload-time = "2026-10-16T13:56:32.053Z" },
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
@@ -497,7 +496,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -536,7 +535,7 @@ name = "grpcio"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
wheels = [
@@ -812,8 +811,7 @@ dependencies = [
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
wheels = [
@@ -1002,7 +1000,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "tenacity" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ 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" }
@@ -1015,7 +1013,7 @@ name = "langchain-protocol"
version = "0.0.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" }
wheels = [
@@ -1339,114 +1337,84 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "opentelemetry-exporter-http-transport"
version = "0.66b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "opentelemetry-exporter-otlp-common"
version = "0.66b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" },
{ url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.45.1"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" },
{ url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.45.1"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-http-transport", extra = ["requests"] },
{ name = "opentelemetry-exporter-otlp-common" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" },
{ url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.45.1"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" },
{ url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.45.1"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" },
{ url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.66b1"
version = "0.62b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" },
{ url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" },
]
[[package]]
@@ -1588,11 +1556,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.3"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
@@ -1674,7 +1642,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" }
@@ -1687,7 +1655,7 @@ name = "pydantic-core"
version = "2.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" }
wheels = [
@@ -1850,8 +1818,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
@@ -2068,11 +2035,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "84.1.0"
version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/eb/7e5f628ee2eb168fecbf8026de7856b2543d6d609cc85f086b9a91ac93d6/setuptools-84.1.0.tar.gz", hash = "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", size = 1171783, upload-time = "2026-10-19T20:11:43.489Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", hash = "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9", size = 819380, upload-time = "2026-10-19T20:11:41.684Z" },
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
@@ -2112,7 +2079,7 @@ version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
@@ -2256,34 +2223,17 @@ wheels = [
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'emscripten'",
"python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform != 'emscripten')",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
@@ -2342,28 +2292,28 @@ wheels = [
[[package]]
name = "uv"
version = "0.13.4"
version = "0.11.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/55/609455d21e87430c11fe734abfced50a3bdf177b2f49d41fbb19c199cbc3/uv-0.13.4.tar.gz", hash = "sha256:b0bff5b9a5061f7a35b1d0c1a94c19af134222c48eb7aadc42f85a038be1b248", size = 7482486, upload-time = "2026-10-20T15:12:06.801Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/bd/4349da5356b17a1c0e36c8552c809925f86f6292de9b1f599f01e8e8093d/uv-0.13.4-py3-none-linux_armv6l.whl", hash = "sha256:205a4f93e568fb24f45a2f3834f1c0a8f1adb5f396bf473c71030ed707dce048", size = 18272390, upload-time = "2026-10-20T15:11:02.468Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/0cb77a1ae43959772f8f7873eb1f4061907fbb1694ce55c79b231f639b1d/uv-0.13.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b4bbacea78c9db136cd2ec3147cbfd9ed910b4db5e1877109bd74d0e73bb2eaf", size = 17384113, upload-time = "2026-10-20T15:11:07.172Z" },
{ url = "https://files.pythonhosted.org/packages/26/44/5a8733dd1b2e8d6f9cffd1ff6428a3e11dcc7e146a7657e32cfb8d9fb4a4/uv-0.13.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95f4eb2b991b53b50ec2b18ab612fca8ba1e7a75a47af0331fb8bcd21da3eb03", size = 14433723, upload-time = "2026-10-20T15:11:10.185Z" },
{ url = "https://files.pythonhosted.org/packages/f8/39/be14f051bf34a1822d634819890a2d0cba3b69101e633fb7b9f54d7b4020/uv-0.13.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e90ab42825587821096cb0d071e387468da38cfa22818c91f9c0b3c0453048e8", size = 17816443, upload-time = "2026-10-20T15:11:13.674Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/5df0f8bc6367dd37b98432a7c462e2918554f6db2a71211574852b8f0727/uv-0.13.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3a9dcb6762c3ba249d8c084315ba03839082af3ece3536cc4164ec735c351d68", size = 17828025, upload-time = "2026-10-20T15:11:16.806Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9c/2fed5357700b7c304b94214979e4efe680c3bc6e337a97eedaa372fe0947/uv-0.13.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ae6ff2b07892379dc767c9064c2b8bbfac88e767edac7ce28e1b0f15d86abb4", size = 17843737, upload-time = "2026-10-20T15:11:20.043Z" },
{ url = "https://files.pythonhosted.org/packages/4c/2e/ee026284f096236dc3e1abed0316f9edab65c0ea176ea0072c064e62478d/uv-0.13.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25cc14ff4fd8335d3be2cc493a621a003fdafcb0fb1c953c905daabe4def5ab8", size = 18576350, upload-time = "2026-10-20T15:11:24.035Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f1/67fbdb649c8c5d0cb38ea74829080479b77c8631cda5d77fc1b28f027ca7/uv-0.13.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77062b02856c74697f48328d8a6583ab07e6da0a993e86c2db46f3d1da27c637", size = 19831234, upload-time = "2026-10-20T15:11:27.627Z" },
{ url = "https://files.pythonhosted.org/packages/bd/00/7d49e3a1a3e98e5244fa38e0c964dc8de568bf3bb101257a63dc2602efed/uv-0.13.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb0e2028d9a8929884344afb7d91d0f65d9783f640d28aede89fca3602bedde9", size = 19702886, upload-time = "2026-10-20T15:11:31.128Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a1/de02c0913726728eca56ee258875cbba9c7b0572c63f494fd9f55c7852e3/uv-0.13.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e4332791b4d55b6dde8695b7fa6079b26991519f44760887c5dfbd5ca4fe53", size = 17181168, upload-time = "2026-10-20T15:11:34.583Z" },
{ url = "https://files.pythonhosted.org/packages/a8/cd/f4a3c7377674366798c56aabd0f9a1f05687c3939f5ba857a3ea6093ff84/uv-0.13.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7b3cdf868e5415631fe797b648c0d6945d38d388f23367bdd0b6332e80a1fcb0", size = 16502020, upload-time = "2026-10-20T15:11:37.88Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f4/ce79657c5a0f51b602db9a663529baafff03b9fe8cc41e09ad3f7246b88a/uv-0.13.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4e6b08578c920b86910897f36bb6da7229c8e7e0187976570e2a538cb28b777e", size = 18000980, upload-time = "2026-10-20T15:11:41.59Z" },
{ url = "https://files.pythonhosted.org/packages/21/e6/7d77022d5661f8e01578bc5423bf02af408220e7e7ac5ee15eb94742c7cb/uv-0.13.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d86a5dec8ab30c70519c71ca5085c8aaf28664836cbc600951cedaa3fc678cbf", size = 18179083, upload-time = "2026-10-20T15:11:45.191Z" },
{ url = "https://files.pythonhosted.org/packages/f4/40/1bf0324800d38009774ba087a6fbffddc76a3e5bf42c081b9298b1236fb5/uv-0.13.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5d89d1028c2e22977d9215cbb79cfe12559cb0c8a507a12e2f3a7e47930cf1bf", size = 18010389, upload-time = "2026-10-20T15:11:48.963Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ec/f352ca2cc3b4b6191bb4ec8cdf4bc190eed0ba8bda4c33b7fd66c5d33cec/uv-0.13.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:52b5c753a7c2d5f8323a70bb4c93118988356331d0758afa6446c81f974e7196", size = 19004998, upload-time = "2026-10-20T15:11:52.628Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4f/fc05ff4292edfe785b47874ec86d3d83ca2bf735cff8a9d2170ffb66ad83/uv-0.13.4-py3-none-win32.whl", hash = "sha256:d333f01852b817ba05612de31474739da1af7e5463d119b30a9c4331eba1f408", size = 16190502, upload-time = "2026-10-20T15:11:56.657Z" },
{ url = "https://files.pythonhosted.org/packages/2f/b9/b526eab6c72eacc705a58378f6850fde046b28c86fed01bd42da61367c6b/uv-0.13.4-py3-none-win_amd64.whl", hash = "sha256:b2ff80397273fff0e02c20eacae6882834c811827d6fc08f658db156fe754386", size = 15331833, upload-time = "2026-10-20T15:11:59.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/4d5fa89d3d10ff6a84e547ef6e02c45b2f2d50e66682da778acf60db63f1/uv-0.13.4-py3-none-win_arm64.whl", hash = "sha256:deffa5a4afdb48f3e44fb26e80377d256b330ddcad0373daff4b54f6162b43a4", size = 15690151, upload-time = "2026-10-20T15:12:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" },
{ url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" },
{ url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" },
{ url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" },
{ url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" },
{ url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" },
{ url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" },
{ url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" },
{ url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" },
]
[[package]]
@@ -2425,19 +2375,18 @@ wheels = [
[[package]]
name = "virtualenv"
version = "21.14.13"
version = "21.7.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "python-discovery" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/aa/bff50daee0488e16d611258a9b6c5a1d0c9e128f8ca424e9f4e1c0444ef2/virtualenv-21.14.13.tar.gz", hash = "sha256:de5307ba4b9a0001d9c8ea02832d38f63ca432880f416c1bface61c4323fd905", size = 5570301, upload-time = "2026-10-22T20:10:16.952Z" }
sdist = { url = "https://files.pythonhosted.org/packages/13/50/c9b84eb106d0db420b9878dac0a386c5791726f127ce20b06897f6e3a1e9/virtualenv-21.7.13.tar.gz", hash = "sha256:0355558b6f33619aab31347e43643b0ebc97f61ea3acf617b2b69e1f8a843d11", size = 5360306, upload-time = "2026-09-18T04:35:49.354Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/a9/c10d8a44a29fb94a18b43fdf11f38caca53ae7c9406f11a72b1cb8f624f1/virtualenv-21.14.13-py3-none-any.whl", hash = "sha256:5fbb04138a4861580b78ba9110d529a26bc48fcc93c8ec36bc0202eeed96a788", size = 5557094, upload-time = "2026-10-22T20:10:15.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ce/e74453531b0c49a58d0e8a4f9bab4495705859fee4a4f7de27d58f4a791a/virtualenv-21.7.13-py3-none-any.whl", hash = "sha256:1bea5af7463f59c4719db48fe739579a2a4f569c96f26c086edda85c96da9f59", size = 5328680, upload-time = "2026-09-18T04:35:47.194Z" },
]
[[package]]
+18 -33
View File
@@ -43,31 +43,14 @@ start-services:
stop-services:
docker compose -f tests/compose-postgres.yml -f tests/compose-redis.yml down -v
DEV_SERVER_PYTHON ?= 3.12
DEV_SERVER_ENV = $(CURDIR)/.venvs/dev-server
start-dev-server:
uv sync --frozen --group test --no-dev
UV_PROJECT_ENVIRONMENT=$(DEV_SERVER_ENV) uv sync --frozen --group test --no-dev --python $(DEV_SERVER_PYTHON)
@LOG_LEVEL=warning UV_PROJECT_ENVIRONMENT=$(DEV_SERVER_ENV) uv run --no-sync --python $(DEV_SERVER_PYTHON) langgraph dev --config tests/example_app/langgraph.json --no-browser --no-reload & \
PID=$$!; echo "$$PID" > .devserver.pid; \
for ATTEMPT in $$(seq 1 60); do \
if ! kill -0 $$PID 2>/dev/null; then break; fi; \
if curl --fail --silent --max-time 1 http://localhost:2024/ok >/dev/null; then \
echo "Dev server ready."; exit 0; \
fi; \
sleep 1; \
done; \
echo "Dev server failed to become ready." >&2; \
kill $$PID 2>/dev/null || true; \
wait $$PID 2>/dev/null || true; \
rm -f .devserver.pid; exit 1
LOG_LEVEL=warning uv run langgraph dev --config tests/example_app/langgraph.json --no-browser & echo "$$!" > .devserver.pid
@echo "Dev server started."
stop-dev-server:
@if [ -f .devserver.pid ]; then \
kill $$(cat .devserver.pid) 2>/dev/null || true; \
rm -f .devserver.pid; \
echo "Dev server stopped."; \
kill `cat .devserver.pid` && rm .devserver.pid; \
echo "Dev server stopped."; \
else \
echo "No dev server PID file found."; \
fi
@@ -77,12 +60,13 @@ NO_DOCKER ?= $(sh command -v docker >/dev/null 2>&1 && echo "false" || echo "tru
test:
if [ "$(NO_DOCKER)" = "false" ]; then \
trap '$(MAKE) stop-dev-server; $(MAKE) stop-services' EXIT; \
trap 'exit 130' INT; trap 'exit 143' TERM; \
$(MAKE) start-services &&\
$(MAKE) start-dev-server &&\
uv run --no-sync pytest $(TEST); \
exit $$?; \
make start-services &&\
make start-dev-server &&\
uv run pytest $(TEST); \
EXIT_CODE=$$?; \
make stop-services; \
make stop-dev-server; \
exit $$EXIT_CODE; \
else \
NO_DOCKER=true uv run pytest $(TEST) ; \
EXIT_CODE=$$?; \
@@ -90,12 +74,13 @@ test:
fi
test_parallel:
trap '$(MAKE) stop-dev-server; $(MAKE) stop-services' EXIT; \
trap 'exit 130' INT; trap 'exit 143' TERM; \
$(MAKE) start-services &&\
$(MAKE) start-dev-server &&\
uv run --no-sync pytest -n auto --dist worksteal $(TEST) -vv --lf; \
exit $$?
make start-services &&\
make start-dev-server &&\
uv run pytest -n auto --dist worksteal $(TEST) -vv --lf; \
EXIT_CODE=$$?; \
make stop-services; \
make stop-dev-server; \
exit $$EXIT_CODE
integration_tests:
uv run pytest integration_tests
+6 -22
View File
@@ -29,19 +29,7 @@ from langgraph._internal._constants import (
NS_SEP,
)
class _DefaultRecursionLimit(int):
"""The limit `ensure_config` fills in when none is given.
`merge_configs` skips values of this type rather than any equal value, so an
explicit limit that happens to equal the default still applies. It checks the
type, not identity, because copying or pickling a config rebuilds the value.
"""
DEFAULT_RECURSION_LIMIT = _DefaultRecursionLimit(
getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007")
)
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT = int(
getenv("LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", "5000")
)
@@ -194,7 +182,7 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
base.get("callbacks"), cast(Callbacks, value)
)
elif key == "recursion_limit":
if not isinstance(config["recursion_limit"], _DefaultRecursionLimit):
if config["recursion_limit"] != DEFAULT_RECURSION_LIMIT:
base["recursion_limit"] = config["recursion_limit"]
else:
base[key] = config[key] # type: ignore[literal-required]
@@ -347,7 +335,6 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
recursion_limit=DEFAULT_RECURSION_LIMIT,
configurable={},
)
inherited_tags: set[str] = set()
if var_config := var_child_runnable_config.get():
empty.update(
{
@@ -356,7 +343,6 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
if _is_not_empty(v)
},
)
inherited_tags.update(empty["tags"])
# An explicit config that supplies its own checkpoint coordinate (a
# thread_id, or any checkpoint_ns/checkpoint_id/checkpoint_map) is addressing
# its own checkpoint lineage, so drop the inherited ambient configurable
@@ -409,14 +395,12 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
elif k == "tags":
# Concatenate tags across configs so values bound via
# with_config(...) are preserved when later configs
# supply additional tags. Matches merge_configs. Tags the
# ambient config already has are skipped: a subgraph gets
# its parent's config both explicitly and as the ambient
# one, so they would double at every nesting level.
# supply additional tags. Matches merge_configs.
existing_tags: list[str] | None = empty.get("tags")
new_tags = [t for t in cast(list, v) if t not in inherited_tags]
empty["tags"] = (
[*existing_tags, *new_tags] if existing_tags else new_tags
[*existing_tags, *cast(list, v)]
if existing_tags
else list(cast(list, v))
)
else:
empty[k] = v # type: ignore[literal-required]
@@ -74,6 +74,8 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
# holds a `Runtime` instance with context, store, stream writer, etc.
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
# holds a mapping of task ns -> resume value for resuming tasks
CONFIG_KEY_RESUME_MAP_SENT = sys.intern("__pregel_resume_map_sent")
# holds the set of resume map ids already forwarded to a remote graph in this run
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
# flow through stream_mode="messages"; set by StreamingHandler only.
@@ -134,6 +136,7 @@ RESERVED = {
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUME_MAP_SENT,
CONFIG_KEY_STREAM_MESSAGES_V2,
# other constants
PUSH,
@@ -2,7 +2,6 @@ import collections.abc
from collections.abc import Callable, Sequence
from typing import Any, Generic
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import NotRequired, Required, Self
from langgraph._internal._constants import OVERWRITE
@@ -117,9 +116,6 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
def from_checkpoint(self, checkpoint: Value) -> Self:
empty = self.__class__(self.typ, self.operator)
empty.key = self.key
# A thread written while this field was a DeltaChannel stores snapshots.
if isinstance(checkpoint, _DeltaSnapshot):
checkpoint = checkpoint.value
if checkpoint is not MISSING:
empty.value = checkpoint
return empty
+65 -3
View File
@@ -24,9 +24,6 @@ from typing_extensions import TypedDict, deprecated
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, NS_SEP
from langgraph.graph.state import StateGraph
from langgraph.pregel._messages import (
_messages_delta_reducer as _messages_delta_reducer,
)
from langgraph.warnings import LangGraphDeprecatedSinceV10
__all__ = (
@@ -247,6 +244,71 @@ def add_messages(
return merged
def _messages_delta_reducer(
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
Processes all writes in one pass — dedup by ID, `RemoveMessage`
tombstoning — without calling `add_messages`.
This reducer is batching-invariant, as required by `DeltaChannel`:
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
Raw dict / string / tuple inputs are coerced to typed `BaseMessage`
objects so that HTTP-driven graphs work without a separate coercion
step. This is not full `add_messages` parity — `REMOVE_ALL_MESSAGES`,
unknown-id `RemoveMessage` errors, missing-id UUID assignment, and
`BaseMessageChunk` conversion are not handled here.
Example::
from typing import Annotated
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
"""
# Each write is either a list of message-likes or a single message-like
# (BaseMessage / dict / str / tuple). Only lists flatten; everything
# else is one message.
flat: list[Any] = []
for w in writes:
if isinstance(w, list):
flat.extend(w)
else:
flat.append(w)
# Steady state: the reducer's own output is already typed, so skip
# `convert_to_messages` on state when the first element is a BaseMessage.
# Only raw input (initial dicts, deserialized blobs) hits the slow path.
if state and isinstance(state[0], BaseMessage):
state_msgs = state
else:
state_msgs = cast("list[AnyMessage]", convert_to_messages(state))
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
index: dict[str, int] = {
m.id: i for i, m in enumerate(state_msgs) if m.id is not None
}
result: list[AnyMessage | None] = list(state_msgs)
for msg in msgs:
mid = msg.id
if mid is None:
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None
del index[mid]
elif mid in index:
result[index[mid]] = msg
else:
index[mid] = len(result)
result.append(msg)
return [m for m in result if m is not None]
@deprecated(
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
category=None,
+21 -59
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
import uuid
from collections.abc import Callable, Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime, timezone
from inspect import signature
from typing import Any, Literal
from typing import Any, Literal, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
@@ -12,7 +12,6 @@ from langgraph.checkpoint.base import (
ChannelVersions,
Checkpoint,
CheckpointTuple,
DeltaChannelHistory,
PendingWrite,
)
from langgraph.checkpoint.base.id import uuid6
@@ -33,7 +32,6 @@ from langgraph._internal._constants import (
)
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate, _get_overwrite
from langgraph.channels.delta import DeltaChannel
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
@@ -154,23 +152,6 @@ def delta_channels_with_pending_writes(
}
def delta_channels_overwritten(
specs: Mapping[str, Any], writes: Iterable[tuple[str, Any]]
) -> set[str]:
"""Return the names of the DeltaChannels that `writes` set with an `Overwrite`.
`update_state` saves a full snapshot of these channels in the checkpoint it
creates, like the loop does when a node returns an `Overwrite`. Otherwise,
reading the channel later starts from an older snapshot and replays the
writes the `Overwrite` threw away.
"""
return {
ch
for ch, value in writes
if isinstance(specs.get(ch), DeltaChannel) and _get_overwrite(value)[0]
}
def checkpoint_superseded(
saver: BaseCheckpointSaver, config: RunnableConfig, saved: CheckpointTuple
) -> bool:
@@ -275,7 +256,6 @@ def create_checkpoint(
get_next_version: GetNextVersion | None = None,
channels_to_snapshot: set[str] | None = None,
stored_versions: ChannelVersions | None = None,
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
) -> Checkpoint:
"""Build a new Checkpoint from the previous one and live channel state.
@@ -334,9 +314,7 @@ def create_checkpoint(
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=channel_versions,
versions_seen=_mark_bumps_seen(
checkpoint["versions_seen"], bumped, trigger_to_nodes or {}
),
versions_seen=_mark_bumps_seen(checkpoint["versions_seen"], bumped),
updated_channels=None if updated_channels is None else sorted(updated_channels),
)
@@ -344,27 +322,19 @@ def create_checkpoint(
def _mark_bumps_seen(
versions_seen: dict[str, ChannelVersions],
bumped: Mapping[str, tuple[Any, Any]],
trigger_to_nodes: Mapping[str, Sequence[str]],
) -> dict[str, ChannelVersions]:
"""Advance whoever had seen a bumped channel's old version to the new one.
A bump that only stores a snapshot is not a write. Left unseen, it would
re-fire `interrupt_before` and rerun the channel's subscribers. A channel
bumped from no version was never written, so it also goes to the
subscribers that never ran: they have no entry, and would start on the bump.
For each entry it advances, `SNAPSHOT_BUMPS` keeps the new version and the
one the node really read, so `versions_seen_without_bumps` can put the read
back.
re-fire `interrupt_before` and rerun the channel's subscribers. For each
entry it advances, `SNAPSHOT_BUMPS` keeps the new version and the one the
node really read, so `versions_seen_without_bumps` can put the read back.
"""
if not bumped:
return versions_seen
out = dict(versions_seen)
for k, (old, _) in bumped.items():
if old is None:
for node in trigger_to_nodes.get(k, ()):
out.setdefault(node, {})
marks = dict(versions_seen.get(SNAPSHOT_BUMPS, {}))
for node, seen in out.items():
for node, seen in versions_seen.items():
if node == SNAPSHOT_BUMPS:
continue
for k, (old, new) in bumped.items():
@@ -410,39 +380,23 @@ def versions_seen_without_bumps(
def _delta_channels_to_replay(
specs: Mapping[str, BaseChannel], checkpoint: Checkpoint
) -> list[str]:
"""Channels whose value at `checkpoint` the ancestor walk rebuilds.
"""DeltaChannels whose value at `checkpoint` the ancestor walk rebuilds.
A `_DeltaSnapshot` blob or a plain value (migration) resolves directly via
`from_checkpoint`, so only a channel with nothing stored here needs the
walk. A channel with no version was never written, so it is empty without
one; a walk for it would find no snapshot to stop at and read every
ancestor, every time the thread is loaded.
A `BinaryOperatorAggregate` stores its value at every version, so one with
nothing stored was written as a `DeltaChannel` before its annotation
changed, and is rebuilt the same way.
"""
return [
k
for k, spec in specs.items()
if isinstance(spec, (DeltaChannel, BinaryOperatorAggregate))
if isinstance(spec, DeltaChannel)
and k in checkpoint["channel_versions"]
and checkpoint["channel_values"].get(k, MISSING) is MISSING
]
def _replay(spec: BaseChannel, history: DeltaChannelHistory) -> BaseChannel:
ch = spec.from_checkpoint(history.get("seed", MISSING))
if isinstance(ch, DeltaChannel):
ch.replay_writes(history["writes"])
else:
# One update per write: the writes span many supersteps, and a single
# call would reject a second Overwrite and drop the writes after one.
for _, _, value in history["writes"]:
ch.update([value])
return ch
def _require_saver_for_history(
delta_channels: list[str],
saver: BaseCheckpointSaver | None,
@@ -482,7 +436,7 @@ def channels_from_checkpoint(
delta_channels = _delta_channels_to_replay(channel_specs, checkpoint)
_require_saver_for_history(delta_channels, saver, config)
histories: Mapping[str, DeltaChannelHistory] = {}
histories: Mapping[str, Any] = {}
if delta_channels and saver is not None and config is not None:
histories = saver.get_delta_channel_history(
config=config, channels=delta_channels
@@ -492,7 +446,11 @@ def channels_from_checkpoint(
for k, spec in channel_specs.items():
ch: BaseChannel
if k in histories:
ch = _replay(spec, histories[k])
delta_spec = cast(DeltaChannel, spec)
history = histories[k]
replay_ch = delta_spec.from_checkpoint(history.get("seed", MISSING))
replay_ch.replay_writes(history["writes"])
ch = replay_ch
else:
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
channels[k] = ch
@@ -517,7 +475,7 @@ async def achannels_from_checkpoint(
delta_channels = _delta_channels_to_replay(channel_specs, checkpoint)
_require_saver_for_history(delta_channels, saver, config)
histories: Mapping[str, DeltaChannelHistory] = {}
histories: Mapping[str, Any] = {}
if delta_channels and saver is not None and config is not None:
histories = await saver.aget_delta_channel_history(
config=config, channels=delta_channels
@@ -527,7 +485,11 @@ async def achannels_from_checkpoint(
for k, spec in channel_specs.items():
ch: BaseChannel
if k in histories:
ch = _replay(spec, histories[k])
delta_spec = cast(DeltaChannel, spec)
history = histories[k]
replay_ch = delta_spec.from_checkpoint(history.get("seed", MISSING))
replay_ch.replay_writes(history["writes"])
ch = replay_ch
else:
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
channels[k] = ch
+112 -178
View File
@@ -19,7 +19,6 @@ from typing import (
TypeVar,
cast,
)
from uuid import UUID, uuid5
from langchain_core.callbacks import AsyncParentRunManager, ParentRunManager
from langchain_core.runnables import RunnableConfig
@@ -44,6 +43,7 @@ from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_REPLAY_STATE,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUME_MAP_SENT,
CONFIG_KEY_RESUMING,
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SCRATCHPAD,
@@ -161,12 +161,6 @@ def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
return StreamProtocol(__call__, {mode for s in streams for mode in s.modes})
def _cacheable(writes: WritesT) -> bool:
# A cache hit skips the node, so a cached error would skip it without
# raising: only a node that finished goes to the cache.
return not any(c in (INTERRUPT, ERROR) for c, _ in writes)
class PregelLoop:
config: RunnableConfig
store: BaseStore | None
@@ -198,7 +192,6 @@ class PregelLoop:
Callable[
[
concurrent.futures.Future | None,
Sequence[Any],
RunnableConfig,
Checkpoint,
str,
@@ -212,13 +205,11 @@ class PregelLoop:
submit: Submit
channels: Mapping[str, BaseChannel]
# Futures from `checkpointer.put_writes` calls that produced delta-channel
# writes. `_put_checkpoint` hands this list to the save it submits, which
# waits for them first, so a checkpoint never becomes durable before the
# writes that produced it. If a write or the previous save failed, the
# save fails too: a DeltaChannel is rebuilt from its writes along the
# parent chain, so a checkpoint saved past either gap reads back short
# for good. Initialised to `[]` in both sync and async `__enter__`;
# stays `None` only when no checkpointer.
# writes. `_checkpointer_put_after_previous` drains this list (swap to a
# local `futs` then reset to `[]` and wait/gather) before putting the
# next checkpoint, so a checkpoint never becomes durable before the
# writes that produced it. Initialised to `[]` in both sync and async
# `__enter__`; stays `None` only when no checkpointer.
_delta_write_futs: list[Any] | None = None
# Same pattern as `_delta_write_futs` but for error-handler writes.
@@ -257,10 +248,8 @@ class PregelLoop:
_resume_loaded_writes: Sequence[PendingWrite] = ()
# Tasks `_reapply_writes_to_succeeded_nodes` handed loaded writes back to.
_reapplied_task_ids: set[str]
# Tasks that wrote during this run, the only ones exit mode saves at exit.
_written_task_ids: set[str]
# A checkpoint addressed by `checkpoint_id` that the thread has moved past,
# so it may already have children.
# An update or fork checkpoint addressed by `checkpoint_id` that the thread
# has moved past, so it may already have children.
_addressed_checkpoint_superseded: bool = False
# The checkpoint_config that points at the parent loaded at `__enter__`
@@ -278,9 +267,6 @@ class PregelLoop:
# `_put_exit_delta_writes` uses this to decide between anchoring on
# the existing parent (True) or creating a lazy stub (False).
_has_persisted_parent: bool = False
# True iff `__enter__` loaded the thread's latest checkpoint, not one a
# `checkpoint_id` addressed, so nothing has been built on it yet.
_loaded_latest: bool = False
managed: ManagedValueMapping
checkpoint: Checkpoint
@@ -351,6 +337,7 @@ class PregelLoop:
self.interrupt_before = interrupt_before
self.manager = manager
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
self.is_replaying = CONFIG_KEY_CHECKPOINT_ID in config[CONF]
self._migrate_checkpoint = migrate_checkpoint
self.trigger_to_nodes = trigger_to_nodes
self.retry_policy = retry_policy
@@ -408,14 +395,6 @@ class PregelLoop:
else ()
)
self.prev_checkpoint_config = None
# Whether the run points this loop at a checkpoint: a truthy
# `checkpoint_id`, this graph's `checkpoint_map` entry, or a replaying
# parent. Every task config carries `checkpoint_id: None`, so a key
# check would count every subgraph.
self._addressed = bool(
self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID)
or self.config[CONF].get(CONFIG_KEY_REPLAY_STATE)
)
runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
self.control = runtime.control if isinstance(runtime, Runtime) else None
@@ -458,19 +437,13 @@ class PregelLoop:
return None
return self._graph_lifecycle_events.popleft()
def put_writes(
self, task_id: str, writes: WritesT, *, cached: bool = False
) -> None:
def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick."""
if not writes:
return
self._written_task_ids.add(task_id)
# deduplicate writes to special channels, last write wins
if all(w[0] in WRITES_IDX_MAP for w in writes):
writes = list({w[0]: w for w in writes}.values())
# Before the writes are saved, kept in `checkpoint_pending_writes` or
# applied, so all of them hold the same message ids.
ensure_message_ids(self.specs, writes)
if task_id == NULL_TASK_ID:
# writes for the null task are accumulated
self.checkpoint_pending_writes = [
@@ -478,11 +451,6 @@ class PregelLoop:
for w in self.checkpoint_pending_writes
if w[0] != task_id or w[1] not in WRITES_IDX_MAP
]
# Savers key a regular write by its position in the list, and the
# special ones dropped above won't be there next time, so they go
# last: a resume saved ahead of an update would move it to a new
# slot once the list is saved again, and it would apply twice.
writes = sorted(writes, key=lambda w: w[0] in WRITES_IDX_MAP)
writes_to_save: WritesT = [
w[1:] for w in self.checkpoint_pending_writes if w[0] == task_id
] + list(writes)
@@ -512,6 +480,14 @@ class PregelLoop:
# save writes
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
# Assign stable IDs to any id=None BaseMessages in DeltaChannel writes
# before the background thread serialises them. Without this, reducers
# that assign IDs inside apply_writes() race with serialisation and
# store id=None, causing get_state() replays to produce a different UUID
# on every call.
for c, v in writes_to_save:
if isinstance(self.specs.get(c), DeltaChannel):
ensure_message_ids(v)
if self.durability != "exit" and self.checkpointer_put_writes is not None:
config = patch_configurable(
self.checkpoint_config,
@@ -554,7 +530,7 @@ class PregelLoop:
self._error_handler_write_futs.append(fut)
# output writes
if hasattr(self, "tasks"):
self.output_writes(task_id, writes, cached=cached)
self.output_writes(task_id, writes)
def _put_pending_writes(self) -> None:
if self.checkpointer_put_writes is None:
@@ -571,12 +547,10 @@ class PregelLoop:
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
)
# Writes loaded with the checkpoint are stored on it already. Saving them
# again can file them under new indexes, and then they apply twice.
# group by task id
by_task = defaultdict(list)
for task_id, channel, value in self.checkpoint_pending_writes:
if task_id in self._written_task_ids:
by_task[task_id].append((channel, value))
by_task[task_id].append((channel, value))
# submit writes to checkpointer
for task_id, writes in by_task.items():
if self.checkpointer_put_writes_accepts_task_path and hasattr(
@@ -657,17 +631,58 @@ class PregelLoop:
# check if iteration limit is reached
if self.step > self.stop:
self.status = "out_of_steps"
# stream the checkpoint the last step saved, as a tick that runs would
self._prepare_tasks_and_emit_checkpoint()
return False
self._prepare_tasks_and_emit_checkpoint()
# prepare next tasks
self.tasks = prepare_next_tasks(
self.checkpoint,
self.checkpoint_pending_writes,
self.nodes,
self.channels,
self.managed,
self.config,
self.step,
self.stop,
for_execution=True,
manager=self.manager,
store=self.store,
checkpointer=self.checkpointer,
trigger_to_nodes=self.trigger_to_nodes,
updated_channels=self.updated_channels,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
)
# produce debug output
if self._checkpointer_put_after_previous is not None:
self._emit(
"checkpoints",
map_debug_checkpoint,
{
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
},
self.channels,
self.stream_keys,
self.checkpoint_metadata,
self.tasks.values(),
self.checkpoint_pending_writes,
self.prev_checkpoint_config,
self.output_keys,
)
# if no more tasks, we're done
if not self.tasks:
self.status = "done"
return False
if self.control is not None and self.control.drain_requested:
self.status = "draining"
return False
# if there are pending writes from a previous loop, apply them
if self._reapplies_pending_writes and self.checkpoint_pending_writes:
self._reapply_writes_to_succeeded_nodes(self.tasks)
@@ -680,13 +695,6 @@ class PregelLoop:
self.status = "interrupt_before"
raise GraphInterrupt()
# stop before running the next tasks if a drain was requested. after
# the breakpoint check: a resume passes the next step's breakpoints,
# so draining in front of one would skip it
if self.control is not None and self.control.drain_requested:
self.status = "draining"
return False
# produce debug output
self._emit("tasks", map_debug_tasks, self.tasks.values())
@@ -769,53 +777,10 @@ class PregelLoop:
self.checkpoint, self.interrupt_after, self.tasks.values()
):
self.status = "interrupt_after"
# the next tick, which streams this checkpoint, never runs
self._prepare_tasks_and_emit_checkpoint()
raise GraphInterrupt()
# unset resuming flag
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
def _prepare_tasks_and_emit_checkpoint(self) -> None:
self.tasks = prepare_next_tasks(
self.checkpoint,
self.checkpoint_pending_writes,
self.nodes,
self.channels,
self.managed,
self.config,
self.step,
self.stop,
for_execution=True,
manager=self.manager,
store=self.store,
checkpointer=self.checkpointer,
trigger_to_nodes=self.trigger_to_nodes,
updated_channels=self.updated_channels,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
)
# produce debug output
if self._checkpointer_put_after_previous is not None:
self._emit(
"checkpoints",
map_debug_checkpoint,
{
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
},
self.channels,
self.stream_keys,
self.checkpoint_metadata,
self.tasks.values(),
self.checkpoint_pending_writes,
self.prev_checkpoint_config,
self.output_keys,
)
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
raise NotImplementedError
@@ -927,7 +892,6 @@ class PregelLoop:
if tid != NULL_TASK_ID and isinstance(self.specs.get(ch), DeltaChannel)
}
self._exit_command_writes = []
self._written_task_ids = set()
# Resuming from a previous checkpoint requires two things:
# 1. A prior checkpoint exists (channel_versions is non-empty)
# 2. The input signals continuation (not a fresh run with new input)
@@ -955,7 +919,7 @@ class PregelLoop:
# writes so that interrupt() calls re-fire instead of returning
# stale values. But if we're actively resuming, keep them —
# multi-interrupt scenarios need previously resolved values preserved.
is_time_traveling = self._addressed and (
is_time_traveling = self.is_replaying and (
# Time-travel to a subgraph checkpoint: the parent sets
# RESUMING=True (it can't distinguish time-travel from resume),
# so we check if this subgraph's own ns is in checkpoint_map.
@@ -974,10 +938,6 @@ class PregelLoop:
or configurable.get(CONFIG_KEY_RESUMING, False)
)
)
# Finished tasks rerun only on time travel, or on a resume at a
# checkpoint the thread has moved past. A resume addressed at the
# thread's latest checkpoint hands their writes back, as without an id.
self.is_replaying = is_time_traveling or self._addressed_checkpoint_superseded
if is_time_traveling:
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME
@@ -1029,6 +989,7 @@ class PregelLoop:
and all(is_xxh3_128_hexdigest(k) for k in resume)
):
self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
self.config[CONF][CONFIG_KEY_RESUME_MAP_SENT] = set()
else:
if len(self._pending_interrupts()) > 1:
raise RuntimeError(
@@ -1046,8 +1007,6 @@ class PregelLoop:
# save writes
for tid, ws in writes.items():
if forks:
# These skip `put_writes`, which gives messages their ids.
ensure_message_ids(self.specs, ws)
carried.extend((tid, c, v) for c, v in ws)
else:
self.put_writes(tid, ws)
@@ -1112,9 +1071,6 @@ class PregelLoop:
)
# map inputs to channel updates
elif input_writes := deque(map_input(input_keys, self.input)):
# Before the input is applied, so this run reads the message ids
# that are saved with it.
ensure_message_ids(self.specs, input_writes)
# discard any unfinished tasks from previous checkpoint
discard_tasks = prepare_next_tasks(
self.checkpoint,
@@ -1160,26 +1116,16 @@ class PregelLoop:
self._exit_delta_writes.append(
(self.step, NULL_TASK_ID, "", c, v)
)
# A DeltaChannel reads its input from the writes stored on the
# checkpoint this run starts from, under a task id of their own:
# readers apply a checkpoint's NULL_TASK_ID writes as its own state.
# A new thread has no checkpoint to store them on, and one a
# `checkpoint_id` addressed may have children that would read them,
# so then the input checkpoint snapshots the channel instead.
# Persist delta-channel input writes so sub-freq inputs are
# recoverable via ancestor walk (mirrors the Command input path).
if self.durability != "exit":
delta_input = [
(c, v)
for c, v in input_writes
if isinstance(self.specs.get(c), DeltaChannel)
]
if delta_input and self._has_persisted_parent and self._loaded_latest:
self.put_writes(
str(uuid5(UUID(self.checkpoint["id"]), INPUT)), delta_input
)
else:
self._delta_channels_forced_snapshot.update(
c for c, _ in delta_input
)
if delta_input:
self.put_writes(NULL_TASK_ID, delta_input)
# save input checkpoint
self.updated_channels = updated_channels
self._put_checkpoint({"source": "input"})
@@ -1193,11 +1139,11 @@ class PregelLoop:
# checkpoint ID since the fork was created after the subgraph's
# checkpoints from the original execution.
#
# Only gate on is_time_traveling (not _addressed). When the
# Only gate on is_time_traveling (not is_replaying). When the
# client resumes with an explicit checkpoint_id that happens to
# point at the current head (e.g. LangGraph Studio sending
# `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
# the run is addressed but not time traveling. In that
# is_replaying is True but is_time_traveling is False. In that
# case subgraphs should load their latest checkpoint normally,
# not go through ReplayState's before-bound lookup which would
# miss subgraph checkpoints created during processing of the
@@ -1309,7 +1255,6 @@ class PregelLoop:
else None,
channels_to_snapshot=channels_to_snapshot,
stored_versions=self.checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
for k in channels_to_snapshot:
new_counters[k] = (0, 0)
@@ -1356,17 +1301,12 @@ class PregelLoop:
)
self.checkpoint_previous_versions = channel_versions
# Take this checkpoint's writes now: saves run in the background
# and can start out of order, so a save that took them itself
# could get another checkpoint's writes.
delta_write_futs, self._delta_write_futs = self._delta_write_futs, []
# save it, without blocking
# if there's a previous checkpoint save in progress, wait for it
# ensuring checkpointers receive checkpoints in order
self._put_checkpoint_fut = self.submit(
self._checkpointer_put_after_previous,
getattr(self, "_put_checkpoint_fut", None),
delta_write_futs,
self.checkpoint_config,
copy_checkpoint(self.checkpoint),
self.checkpoint_metadata,
@@ -1399,14 +1339,6 @@ class PregelLoop:
):
return
# A checkpoint a `checkpoint_id` addressed may already have children,
# which read the writes stored on it, so then the channels this run
# wrote snapshot instead.
if self._has_persisted_parent and not self._loaded_latest:
self._delta_channels_forced_snapshot.update(
w[3] for w in self._exit_delta_writes
)
counters = dict(
self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
)
@@ -1444,7 +1376,6 @@ class PregelLoop:
self._put_checkpoint_fut = self.submit(
self._checkpointer_put_after_previous,
getattr(self, "_put_checkpoint_fut", None),
(),
stub_put_config,
stub_cp,
{"step": -2},
@@ -1712,19 +1643,21 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
def _checkpointer_put_after_previous(
self,
prev: concurrent.futures.Future | None,
delta_write_futs: Sequence[concurrent.futures.Future],
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
for fut in delta_write_futs:
fut.result()
if prev is not None:
prev.result()
cast(BaseCheckpointSaver, self.checkpointer).put(
config, checkpoint, metadata, new_versions
)
if self._delta_write_futs:
futs, self._delta_write_futs = self._delta_write_futs, []
concurrent.futures.wait(futs)
try:
if prev is not None:
prev.result()
finally:
cast(BaseCheckpointSaver, self.checkpointer).put(
config, checkpoint, metadata, new_versions
)
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
if self.cache is None:
@@ -1746,7 +1679,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
) -> PregelExecutableTask | None:
if pushed := super().accept_push(task, write_idx, call):
for task in self.match_cached_writes():
self.put_writes(task.id, task.writes, cached=True)
self.output_writes(task.id, task.writes, cached=True)
return pushed
def schedule_error_handler(
@@ -1783,18 +1716,16 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
if self._reapplies_pending_writes:
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
for task in self.match_cached_writes():
self.put_writes(task.id, task.writes, cached=True)
self.output_writes(task.id, task.writes, cached=True)
return handler_task
def put_writes(
self, task_id: str, writes: WritesT, *, cached: bool = False
) -> None:
def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick."""
super().put_writes(task_id, writes, cached=cached)
if cached or not writes or self.cache is None or not hasattr(self, "tasks"):
super().put_writes(task_id, writes)
if not writes or self.cache is None or not hasattr(self, "tasks"):
return
task = self.tasks.get(task_id)
if task is None or task.cache_key is None or not _cacheable(writes):
if task is None or task.cache_key is None:
return
self.submit(
self.cache.set,
@@ -1817,7 +1748,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = self.checkpointer.get_tuple(self.checkpoint_config)
if saved is not None:
if saved is not None and saved.metadata.get("source") in ("update", "fork"):
self._addressed_checkpoint_superseded = checkpoint_superseded(
self.checkpointer, self.checkpoint_config, saved
)
@@ -1838,7 +1769,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# Normal case: fetch the most recent checkpoint for this
# graph/thread. Returns None on first invocation.
saved = self.checkpointer.get_tuple(self.checkpoint_config)
self._loaded_latest = True
# Capture before the synthetic-empty fallback below overwrites `saved`.
# `_put_exit_delta_writes` uses this on first run (no persisted parent)
@@ -1968,19 +1898,23 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
async def _checkpointer_put_after_previous(
self,
prev: asyncio.Task | None,
delta_write_futs: Sequence[asyncio.Future],
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
if delta_write_futs:
await asyncio.gather(*delta_write_futs)
if prev is not None:
await prev
await cast(BaseCheckpointSaver, self.checkpointer).aput(
config, checkpoint, metadata, new_versions
)
# Drain DeltaChannel write futures before committing the checkpoint so
# ancestor walks never see a checkpoint without its backing writes.
if self._delta_write_futs:
futs, self._delta_write_futs = self._delta_write_futs, []
await asyncio.gather(*futs)
try:
if prev is not None:
await prev
finally:
await cast(BaseCheckpointSaver, self.checkpointer).aput(
config, checkpoint, metadata, new_versions
)
async def amatch_cached_writes(self) -> Sequence[PregelExecutableTask]:
if self.cache is None:
@@ -2002,7 +1936,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
) -> PregelExecutableTask | None:
if pushed := super().accept_push(task, write_idx, call):
for task in await self.amatch_cached_writes():
self.put_writes(task.id, task.writes, cached=True)
self.output_writes(task.id, task.writes, cached=True)
return pushed
async def aschedule_error_handler(
@@ -2039,18 +1973,19 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if self._reapplies_pending_writes:
self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
for task in await self.amatch_cached_writes():
self.put_writes(task.id, task.writes, cached=True)
self.output_writes(task.id, task.writes, cached=True)
return handler_task
def put_writes(
self, task_id: str, writes: WritesT, *, cached: bool = False
) -> None:
def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick."""
super().put_writes(task_id, writes, cached=cached)
if cached or not writes or self.cache is None or not hasattr(self, "tasks"):
super().put_writes(task_id, writes)
if not writes or self.cache is None or not hasattr(self, "tasks"):
return
task = self.tasks.get(task_id)
if task is None or task.cache_key is None or not _cacheable(writes):
if task is None or task.cache_key is None:
return
if writes[0][0] in (INTERRUPT, ERROR):
# only cache successful tasks
return
self.submit(
self.cache.aset,
@@ -2073,7 +2008,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# This covers both normal replay and subgraphs resolved via
# checkpoint_map during time-travel.
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
if saved is not None:
if saved is not None and saved.metadata.get("source") in ("update", "fork"):
self._addressed_checkpoint_superseded = await acheckpoint_superseded(
self.checkpointer, self.checkpoint_config, saved
)
@@ -2094,7 +2029,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# Normal case: fetch the most recent checkpoint for this
# graph/thread. Returns None on first invocation.
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
self._loaded_latest = True
# Capture before the synthetic-empty fallback below overwrites `saved`.
# `_put_exit_delta_writes` uses this on first run (no persisted parent)
+35 -176
View File
@@ -1,13 +1,6 @@
from __future__ import annotations
from collections.abc import (
AsyncIterator,
Callable,
Iterator,
Mapping,
MutableSequence,
Sequence,
)
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from dataclasses import fields, is_dataclass
from typing import (
Any,
@@ -17,18 +10,16 @@ from typing import (
from uuid import UUID, uuid4
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import AnyMessage, BaseMessage, RemoveMessage, ToolMessage
from langchain_core.messages import BaseMessage, ToolMessage
from langchain_core.messages.utils import convert_to_messages
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from pydantic import BaseModel
from langgraph._internal._config import filter_to_user_tags
from langgraph._internal._constants import NS_SEP
from langgraph.channels.binop import _get_overwrite
from langgraph.channels.delta import DeltaChannel
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
from langgraph.pregel.protocol import StreamChunk
from langgraph.types import Command, Overwrite
from langgraph.types import Command
try:
from langchain_core.tracers._streaming import _StreamingCallbackHandler
@@ -417,74 +408,6 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
self.stream((meta[0], "messages", (event, v2_meta)))
# Defined here rather than in `langgraph.graph.message`, which re-exports it:
# `ensure_message_ids` has to recognize it, and importing that module from the
# loop would be an import cycle.
def _messages_delta_reducer(
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
Processes all writes in one pass — dedup by ID, `RemoveMessage`
tombstoning — without calling `add_messages`.
This reducer is batching-invariant, as required by `DeltaChannel`:
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
Raw dict / string / tuple inputs are coerced to typed `BaseMessage`
objects so that HTTP-driven graphs work without a separate coercion
step. This is not full `add_messages` parity — `REMOVE_ALL_MESSAGES`,
unknown-id `RemoveMessage` errors, missing-id UUID assignment, and
`BaseMessageChunk` conversion are not handled here.
Example::
from typing import Annotated
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
"""
# Each write is either a list of message-likes or a single message-like
# (BaseMessage / dict / str / tuple). Only lists flatten; everything
# else is one message.
flat: list[Any] = []
for w in writes:
if isinstance(w, list):
flat.extend(w)
else:
flat.append(w)
# Steady state: the reducer's own output is already typed, so skip
# `convert_to_messages` on state when the first element is a BaseMessage.
# Only raw input (initial dicts, deserialized blobs) hits the slow path.
if state and isinstance(state[0], BaseMessage):
state_msgs = state
else:
state_msgs = cast("list[AnyMessage]", convert_to_messages(state))
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
index: dict[str, int] = {
m.id: i for i, m in enumerate(state_msgs) if m.id is not None
}
result: list[AnyMessage | None] = list(state_msgs)
for msg in msgs:
mid = msg.id
if mid is None:
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None
del index[mid]
elif mid in index:
result[index[mid]] = msg
else:
index[mid] = len(result)
result.append(msg)
return [m for m in result if m is not None]
# Known role values (OpenAI-style) and type values (LangChain serialisation)
# that identify a dict as a message. Checked before coercing to BaseMessage so
# we don't accidentally touch unrelated dicts that happen to have a "role" key.
@@ -500,103 +423,39 @@ def _is_message_dict(item: dict) -> bool:
return item.get("role") in _MESSAGE_ROLES or item.get("type") in _MESSAGE_TYPES
def ensure_message_ids(
channels: Mapping[str, Any], writes: Sequence[tuple[str, Any]]
) -> None:
"""Give the messages written to DeltaChannels stable ids.
def ensure_message_ids(value: Any) -> None:
"""Coerce message-like write values to typed BaseMessages with stable IDs.
A `DeltaChannel` replays its saved writes on every read, and its reducer
may not assign missing ids (`_messages_delta_reducer` doesn't), so a
message saved without an id comes back without one, or with a new one on
every read, and nothing can target it by id, for example `RemoveMessage`.
Called in put_writes() before DeltaChannel writes are submitted to the
checkpointer. Without this the checkpoint may store raw dicts or id=None
BaseMessages; every get_state() replay then produces a different UUID and
the same message appears with a different ID in each LangSmith trace.
In a channel whose reducer is `_messages_delta_reducer`, every message-like
it accepts (a message, a dict, a string, a `(role, content)` tuple; alone,
in a list or in an `Overwrite`) becomes a message with an id. Other
DeltaChannels may hold other data, so there only message objects get ids,
plus message dicts outside an `Overwrite`: one in a list becomes a message.
Anything that can't become a message is left as it is, so it fails where
it always has: a message dict in a list raises here, anything else when
the reducer reads it. An empty id counts as missing.
Handles three input shapes:
- BaseMessage objects: assign a UUID if id is None.
- Dicts with a known "role" (OpenAI-style) or "type" (LangChain format) at
the root level: stamp "id" into the dict in-place. The reducer's
convert_to_messages call will forward the id to the resulting BaseMessage.
- Lists of the above: apply the same logic to each element, replacing dict
items with coerced BaseMessages so the shared list reference seen by
checkpoint_pending_writes and the background thread both get typed messages.
A write that changes is replaced in `writes`: lists and dicts the caller
passed in are never changed, only the ids of message objects.
Mutating synchronously here (before the background thread is submitted) is
safe: the serialised bytes always reflect the post-coercion state.
"""
for i, (ch, value) in enumerate(writes):
spec = channels.get(ch)
if not isinstance(spec, DeltaChannel):
continue
if spec.reducer is _messages_delta_reducer:
new_value = _with_message_ids(value)
else:
new_value = _with_base_message_ids(value)
if new_value is not value:
cast(MutableSequence[tuple[str, Any]], writes)[i] = (ch, new_value)
def _with_message_ids(value: Any) -> Any:
is_overwrite, overwrite_value = _get_overwrite(value)
if not is_overwrite:
if isinstance(value, list):
return _map_messages(value, _list_item_with_id)
return _message_with_id(value)
if isinstance(overwrite_value, (list, tuple)):
items = _map_messages(overwrite_value, _message_with_id)
if items is not overwrite_value:
return Overwrite(items)
return value
def _with_base_message_ids(value: Any) -> Any:
is_overwrite, overwrite_value = _get_overwrite(value)
if is_overwrite:
if isinstance(overwrite_value, (list, tuple)):
for item in overwrite_value:
_stamp_message_id(item)
return value
if isinstance(value, list):
return _map_messages(value, _base_list_item_with_id)
if isinstance(value, dict) and _is_message_dict(value) and not value.get("id"):
return {**value, "id": str(uuid4())}
_stamp_message_id(value)
return value
def _map_messages(items: list | tuple, fn: Callable[[Any], Any]) -> list | tuple:
mapped = [fn(item) for item in items]
if all(new is old for new, old in zip(mapped, items)):
return items
return mapped if isinstance(items, list) else tuple(mapped)
def _list_item_with_id(item: Any) -> Any:
if isinstance(item, dict) and _is_message_dict(item):
return _message_with_id(item, strict=True)
return _message_with_id(item)
def _base_list_item_with_id(item: Any) -> Any:
if isinstance(item, dict) and _is_message_dict(item):
return _message_with_id(item, strict=True)
_stamp_message_id(item)
return item
def _message_with_id(item: Any, *, strict: bool = False) -> Any:
if isinstance(item, BaseMessage):
_stamp_message_id(item)
return item
try:
message = convert_to_messages([item])[0]
except Exception:
if strict:
raise
return item
if not message.id:
message.id = str(uuid4())
return message
def _stamp_message_id(item: Any) -> None:
if isinstance(item, BaseMessage) and item.id is None:
item.id = str(uuid4())
if isinstance(value, BaseMessage):
if value.id is None:
value.id = str(uuid4())
elif isinstance(value, dict) and _is_message_dict(value):
if not value.get("id"):
value["id"] = str(uuid4())
elif isinstance(value, list):
for i, item in enumerate(value):
if isinstance(item, BaseMessage):
if item.id is None:
item.id = str(uuid4())
elif isinstance(item, dict) and _is_message_dict(item):
msg = convert_to_messages([item])[0]
if msg.id is None:
msg.id = str(uuid4())
value[i] = msg
+8 -1
View File
@@ -7,7 +7,7 @@ import sys
import threading
import time
import weakref
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
from contextlib import suppress
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
@@ -686,6 +686,8 @@ async def arun_with_retry(
task: PregelExecutableTask,
retry_policy: Sequence[RetryPolicy] | None,
stream: bool = False,
match_cached_writes: Callable[[], Awaitable[Sequence[PregelExecutableTask]]]
| None = None,
configurable: dict[str, Any] | None = None,
) -> None:
"""Run a task asynchronously with retries."""
@@ -709,6 +711,11 @@ async def arun_with_retry(
)
},
)
if match_cached_writes is not None and task.cache_key is not None:
for t in await match_cached_writes():
if t is task:
# if the task is already cached, return
return
while True:
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
if isinstance(runtime, Runtime):
+2 -7
View File
@@ -41,7 +41,7 @@ from langgraph._internal._future import chain_future, run_coroutine_threadsafe
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph._internal._typing import MISSING
from langgraph.constants import TAG_HIDDEN
from langgraph.errors import GraphBubbleUp, GraphInterrupt, is_invalid_resume
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.pregel._algo import Call
from langgraph.pregel._executor import Submit
from langgraph.pregel._retry import arun_with_retry, run_with_retry
@@ -621,11 +621,7 @@ def _should_stop_others(
handled_exception_ids: set[int] | None = None,
) -> bool:
"""Check if any task failed, if so, cancel all other tasks.
GraphInterrupts are not considered failures.
Neither is an invalid resume value: it's raised once the other tasks finish, so a
task answered in the same resume isn't cut off after its side effects and then run
again with its saved answer."""
GraphInterrupts are not considered failures."""
for fut in done:
if fut.cancelled():
continue
@@ -633,7 +629,6 @@ def _should_stop_others(
if (
id(exc) not in (handled_exception_ids or set())
and not isinstance(exc, GraphBubbleUp)
and not is_invalid_resume(exc)
and fut not in SKIP_RERAISE_SET
):
return True
+64 -161
View File
@@ -137,7 +137,6 @@ from langgraph.pregel._checkpoint import (
copy_checkpoint,
create_checkpoint,
create_checkpoint_plan_for_update_state_api,
delta_channels_overwritten,
delta_channels_with_pending_writes,
empty_checkpoint,
get_updated_channels_from_tasks,
@@ -153,7 +152,6 @@ from langgraph.pregel._loop import (
from langgraph.pregel._messages import (
StreamMessagesHandler,
StreamMessagesHandlerV2,
ensure_message_ids,
)
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
@@ -1768,7 +1766,6 @@ class Pregel(
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = checkpointer.put(
checkpoint_config,
@@ -1791,22 +1788,6 @@ class Pregel(
)
if input_writes := deque(map_input(self.input_channels, values)):
_store_or_fork_delta_writes(
checkpointer,
config,
saved,
checkpoint_config,
self.channels,
[
(
str(uuid5(UUID(checkpoint["id"]), INPUT)),
input_writes,
None,
)
],
fork_pending,
is_first=is_first,
)
updated_channels = apply_writes(
checkpoint,
channels,
@@ -1814,9 +1795,6 @@ class Pregel(
checkpointer.get_next_version,
self.trigger_to_nodes,
)
fork_pending |= delta_channels_overwritten(
self.channels, input_writes
)
# apply input write to channels
next_step = (
@@ -1844,7 +1822,6 @@ class Pregel(
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = checkpointer.put(
checkpoint_config,
@@ -1856,6 +1833,13 @@ class Pregel(
),
)
# store the writes
checkpointer.put_writes(
next_config,
input_writes,
str(uuid5(UUID(checkpoint["id"]), INPUT)),
)
return patch_checkpoint_map(
next_config, saved.metadata if saved else None
)
@@ -2049,22 +2033,30 @@ class Pregel(
),
)
updated_channels = get_updated_channels_from_tasks(run_tasks)
fork_pending |= delta_channels_overwritten(
self.channels, (w for t in run_tasks for w in t.writes)
)
_store_or_fork_delta_writes(
checkpointer,
config,
saved,
checkpoint_config,
self.channels,
[
(task_id, task.writes, task)
for task_id, task in zip(run_task_ids, run_tasks)
],
fork_pending,
is_first=is_first,
)
# The base's other children replay whatever is stored on it, so an
# edit of an older checkpoint stores none of its writes there: the
# checkpoint written here carries them, its delta channels
# snapshotted. Later supersteps address the checkpoint just written.
if (
is_first
and saved is not None
and checkpoint_superseded(checkpointer, config, saved)
):
fork_pending.update(
ch
for ch in updated_channels
if isinstance(self.channels.get(ch), DeltaChannel)
)
elif saved is not None:
for task_id, task in zip(run_task_ids, run_tasks):
channel_writes = [w for w in task.writes if w[0] != PUSH]
if channel_writes:
checkpointer.put_writes(
checkpoint_config,
channel_writes,
task_id,
**_task_path_kwarg(checkpointer.put_writes, task),
)
apply_writes(
checkpoint,
channels,
@@ -2094,7 +2086,6 @@ class Pregel(
else None,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = checkpointer.put(
checkpoint_config,
@@ -2267,7 +2258,6 @@ class Pregel(
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = await checkpointer.aput(
checkpoint_config,
@@ -2290,22 +2280,6 @@ class Pregel(
)
if input_writes := deque(map_input(self.input_channels, values)):
await _astore_or_fork_delta_writes(
checkpointer,
config,
saved,
checkpoint_config,
self.channels,
[
(
str(uuid5(UUID(checkpoint["id"]), INPUT)),
input_writes,
None,
)
],
fork_pending,
is_first=is_first,
)
updated_channels = apply_writes(
checkpoint,
channels,
@@ -2313,9 +2287,6 @@ class Pregel(
checkpointer.get_next_version,
self.trigger_to_nodes,
)
fork_pending |= delta_channels_overwritten(
self.channels, input_writes
)
# apply input write to channels
next_step = (
@@ -2343,7 +2314,6 @@ class Pregel(
get_next_version=checkpointer.get_next_version,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = await checkpointer.aput(
checkpoint_config,
@@ -2355,6 +2325,13 @@ class Pregel(
),
)
# store the writes
await checkpointer.aput_writes(
next_config,
input_writes,
str(uuid5(UUID(checkpoint["id"]), INPUT)),
)
return patch_checkpoint_map(
next_config, saved.metadata if saved else None
)
@@ -2547,22 +2524,30 @@ class Pregel(
),
)
updated_channels = get_updated_channels_from_tasks(run_tasks)
fork_pending |= delta_channels_overwritten(
self.channels, (w for t in run_tasks for w in t.writes)
)
await _astore_or_fork_delta_writes(
checkpointer,
config,
saved,
checkpoint_config,
self.channels,
[
(task_id, task.writes, task)
for task_id, task in zip(run_task_ids, run_tasks)
],
fork_pending,
is_first=is_first,
)
# The base's other children replay whatever is stored on it, so an
# edit of an older checkpoint stores none of its writes there: the
# checkpoint written here carries them, its delta channels
# snapshotted. Later supersteps address the checkpoint just written.
if (
is_first
and saved is not None
and await acheckpoint_superseded(checkpointer, config, saved)
):
fork_pending.update(
ch
for ch in updated_channels
if isinstance(self.channels.get(ch), DeltaChannel)
)
elif saved is not None:
for task_id, task in zip(run_task_ids, run_tasks):
channel_writes = [w for w in task.writes if w[0] != PUSH]
if channel_writes:
await checkpointer.aput_writes(
checkpoint_config,
channel_writes,
task_id,
**_task_path_kwarg(checkpointer.aput_writes, task),
)
apply_writes(
checkpoint,
channels,
@@ -2592,7 +2577,6 @@ class Pregel(
else None,
channels_to_snapshot=channels_to_snapshot,
stored_versions=checkpoint_previous_versions,
trigger_to_nodes=self.trigger_to_nodes,
)
next_config = await checkpointer.aput(
checkpoint_config,
@@ -3054,7 +3038,7 @@ class Pregel(
# with channel updates applied only at the transition between steps.
while loop.tick():
for task in loop.match_cached_writes():
loop.put_writes(task.id, task.writes, cached=True)
loop.output_writes(task.id, task.writes, cached=True)
for _ in runner.tick(
[t for t in loop.tasks.values() if not t.writes],
timeout=self.step_timeout,
@@ -3525,7 +3509,7 @@ class Pregel(
try:
while loop.tick():
for task in await loop.amatch_cached_writes():
loop.put_writes(task.id, task.writes, cached=True)
loop.output_writes(task.id, task.writes, cached=True)
async for _ in runner.atick(
[t for t in loop.tasks.values() if not t.writes],
timeout=self.step_timeout,
@@ -4271,87 +4255,6 @@ def _task_path_kwarg(put_writes: Callable[..., Any], task: PregelTaskWrites) ->
return {"task_path": task_path_str(task.path)}
_UpdateWrites = Sequence[tuple[str, Sequence[tuple[str, Any]], PregelTaskWrites | None]]
def _store_or_fork_delta_writes(
checkpointer: BaseCheckpointSaver,
config: RunnableConfig,
saved: CheckpointTuple | None,
checkpoint_config: RunnableConfig,
channels: Mapping[str, BaseChannel | ManagedValueSpec],
writes: _UpdateWrites,
fork_pending: set[str],
*,
is_first: bool,
) -> None:
"""Save an update's writes where its DeltaChannels will read them.
A DeltaChannel rebuilds its value from the writes saved on a checkpoint's
ancestors, so the writes go on the checkpoint the update builds on. If the
thread already moved past that checkpoint, its other children would read
them too, so the new checkpoint snapshots those channels instead. `writes`
holds `(task_id, writes, task)` per task; `task` is `None` for input. A
task's `PUSH` writes are left out: the update saves them on the checkpoint
it creates. The messages in `writes` get their ids first, in place, as the
loop's `put_writes` does, so pass the writes the update then applies.
"""
for _, task_writes, _ in writes:
ensure_message_ids(channels, task_writes)
if saved is None:
return
if is_first and checkpoint_superseded(checkpointer, config, saved):
fork_pending.update(
ch
for _, task_writes, _ in writes
for ch, _ in task_writes
if isinstance(channels.get(ch), DeltaChannel)
)
return
for task_id, task_writes, task in writes:
if channel_writes := [w for w in task_writes if w[0] != PUSH]:
checkpointer.put_writes(
checkpoint_config,
channel_writes,
task_id,
**(_task_path_kwarg(checkpointer.put_writes, task) if task else {}),
)
async def _astore_or_fork_delta_writes(
checkpointer: BaseCheckpointSaver,
config: RunnableConfig,
saved: CheckpointTuple | None,
checkpoint_config: RunnableConfig,
channels: Mapping[str, BaseChannel | ManagedValueSpec],
writes: _UpdateWrites,
fork_pending: set[str],
*,
is_first: bool,
) -> None:
"""Async `_store_or_fork_delta_writes`."""
for _, task_writes, _ in writes:
ensure_message_ids(channels, task_writes)
if saved is None:
return
if is_first and await acheckpoint_superseded(checkpointer, config, saved):
fork_pending.update(
ch
for _, task_writes, _ in writes
for ch, _ in task_writes
if isinstance(channels.get(ch), DeltaChannel)
)
return
for task_id, task_writes, task in writes:
if channel_writes := [w for w in task_writes if w[0] != PUSH]:
await checkpointer.aput_writes(
checkpoint_config,
channel_writes,
task_id,
**(_task_path_kwarg(checkpointer.aput_writes, task) if task else {}),
)
def _update_task_id(checkpoint_id: str, i: int) -> str:
"""Task id for the `i`th update of a superstep that has no task to reuse.
+116 -24
View File
@@ -29,10 +29,12 @@ from langgraph_sdk.client import (
get_client,
get_sync_client,
)
from langgraph_sdk.errors import NotFoundError
from langgraph_sdk.schema import (
Checkpoint,
Context,
QueryParamTypes,
Thread,
ThreadState,
)
from langgraph_sdk.schema import (
@@ -49,6 +51,10 @@ from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUME_MAP_SENT,
CONFIG_KEY_RESUMING,
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
INTERRUPT,
@@ -81,6 +87,9 @@ _CONF_DROPLIST = frozenset(
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_RESUME_MAP_SENT,
CONFIG_KEY_RESUMING,
),
)
@@ -433,6 +442,43 @@ class RemoteGraph(PregelProtocol):
return sanitized
@staticmethod
def _is_resuming(config: RunnableConfig) -> bool:
"""Whether a parent graph is re-running this node to resume or retry it."""
conf = config.get(CONF, {})
return CONFIG_KEY_SCRATCHPAD in conf and bool(conf.get(CONFIG_KEY_RESUMING))
@staticmethod
def _resume_command(
config: RunnableConfig, thread: Thread | None
) -> CommandSDK | None:
"""Build a command that resumes an interrupted remote thread."""
if thread is None or thread["status"] != "interrupted":
return None
pending = [
Interrupt(**i)
for ints in (thread.get("interrupts") or {}).values()
for i in ints
]
if not pending:
return None
conf = config[CONF]
if resume_map := conf.get(CONFIG_KEY_RESUME_MAP):
sent: set[str] = conf.get(CONFIG_KEY_RESUME_MAP_SENT, set())
# Do not forward the whole map: it can hold answers for other remotes.
# Send each answer once per run: a later interrupt in the same remote
# node reuses the id, so a retry would apply the old answer to it.
if scoped := {
i.id: resume_map[i.id]
for i in pending
if i.id in resume_map and i.id not in sent
}:
sent.update(scoped)
return {"resume": scoped}
elif (resume := conf[CONFIG_KEY_SCRATCHPAD].get_null_resume(True)) is not None:
return {"resume": resume}
raise GraphInterrupt(pending)
def get_state(
self,
config: RunnableConfig,
@@ -794,13 +840,21 @@ class RemoteGraph(PregelProtocol):
stream_modes, requested, req_single, stream = self._get_stream_modes(
stream_mode, config
)
if isinstance(input, Command):
command: CommandSDK | None = cast(CommandSDK, asdict(input))
input = None
else:
command = None
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
command: CommandSDK | None = None
if isinstance(input, Command):
command = cast(CommandSDK, asdict(input))
elif thread_id is not None and self._is_resuming(merged_config):
try:
thread = sync_client.threads.get(thread_id, headers=headers)
except NotFoundError:
thread = None
command = self._resume_command(merged_config, thread)
if command is not None:
input = None
interrupted = False
interrupts: dict[str, Interrupt] = {}
for chunk in sync_client.runs.stream(
thread_id=thread_id,
assistant_id=self.assistant_id,
@@ -832,16 +886,24 @@ class RemoteGraph(PregelProtocol):
if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
caller_ns = tuple(caller_ns.split(NS_SEP))
ns = caller_ns + ns
is_interrupt = (
chunk.event.startswith("updates")
and isinstance(chunk.data, dict)
and INTERRUPT in chunk.data
)
if interrupted and not is_interrupt and not chunk.event.startswith("error"):
continue
# stream to parent stream
if stream is not None and mode in stream.modes:
stream((ns, mode, chunk.data))
# raise interrupt or errors
if chunk.event.startswith("updates"):
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
if caller_ns:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
# collect interrupts or raise errors
if is_interrupt:
if caller_ns:
interrupted = True
for i in chunk.data[INTERRUPT]:
item = Interrupt(**i)
interrupts.setdefault(item.id, item)
continue
elif chunk.event.startswith("error"):
raise RemoteException(chunk.data)
# filter for what was actually requested
@@ -861,6 +923,11 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
@@ -869,6 +936,8 @@ class RemoteGraph(PregelProtocol):
yield chunk.data
else:
yield chunk
if interrupted:
raise GraphInterrupt(list(interrupts.values()))
@overload
def astream(
@@ -944,13 +1013,21 @@ class RemoteGraph(PregelProtocol):
stream_modes, requested, req_single, stream = self._get_stream_modes(
stream_mode, config
)
if isinstance(input, Command):
command: CommandSDK | None = cast(CommandSDK, asdict(input))
input = None
else:
command = None
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
command: CommandSDK | None = None
if isinstance(input, Command):
command = cast(CommandSDK, asdict(input))
elif thread_id is not None and self._is_resuming(merged_config):
try:
thread = await client.threads.get(thread_id, headers=headers)
except NotFoundError:
thread = None
command = self._resume_command(merged_config, thread)
if command is not None:
input = None
interrupted = False
interrupts: dict[str, Interrupt] = {}
async for chunk in client.runs.stream(
thread_id=thread_id,
assistant_id=self.assistant_id,
@@ -982,16 +1059,24 @@ class RemoteGraph(PregelProtocol):
if caller_ns := (config or {}).get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS):
caller_ns = tuple(caller_ns.split(NS_SEP))
ns = caller_ns + ns
is_interrupt = (
chunk.event.startswith("updates")
and isinstance(chunk.data, dict)
and INTERRUPT in chunk.data
)
if interrupted and not is_interrupt and not chunk.event.startswith("error"):
continue
# stream to parent stream
if stream is not None and mode in stream.modes:
stream((ns, mode, chunk.data))
# raise interrupt or errors
if chunk.event.startswith("updates"):
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
if caller_ns:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
# collect interrupts or raise errors
if is_interrupt:
if caller_ns:
interrupted = True
for i in chunk.data[INTERRUPT]:
item = Interrupt(**i)
interrupts.setdefault(item.id, item)
continue
elif chunk.event.startswith("error"):
raise RemoteException(chunk.data)
# filter for what was actually requested
@@ -1011,6 +1096,11 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
@@ -1019,6 +1109,8 @@ class RemoteGraph(PregelProtocol):
yield chunk.data
else:
yield chunk
if interrupted:
raise GraphInterrupt(list(interrupts.values()))
def stream_events(
self,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.17"
version = "1.2.14"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+1 -33
View File
@@ -1,19 +1,11 @@
from collections.abc import Callable
import pytest
from langchain_core.callbacks import AsyncCallbackManager, BaseCallbackHandler
from langgraph._internal._config import (
DEFAULT_RECURSION_LIMIT,
get_async_callback_manager_for_config,
)
from langgraph._internal._config import get_async_callback_manager_for_config
from langgraph.graph import StateGraph
from langgraph.pregel import Pregel
pytestmark = pytest.mark.anyio
EXPLICIT_DEFAULT_LIMIT = int(DEFAULT_RECURSION_LIMIT)
def test_new_async_manager_includes_tags() -> None:
config = {"callbacks": None}
@@ -122,27 +114,3 @@ async def test_with_config_tags_preserved_on_invoke() -> None:
await graph.ainvoke({}, {"tags": ["invoke"]})
assert "bound" in captured, "bound tag was dropped by ensure_config overwrite"
assert "invoke" in captured, "invoke-time tag not present"
@pytest.mark.parametrize(
"rebind",
[
lambda g: g.with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT),
lambda g: g.with_config({"recursion_limit": EXPLICIT_DEFAULT_LIMIT}),
lambda g: g.with_config(
{"recursion_limit": 1}, recursion_limit=EXPLICIT_DEFAULT_LIMIT
),
lambda g: g.with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT).with_config(
tags=["t"]
),
],
ids=["kwarg", "config", "kwarg_over_config", "kept_by_later_with_config"],
)
def test_with_config_applies_recursion_limit_equal_to_default(
rebind: Callable[[Pregel], Pregel],
) -> None:
builder = StateGraph(dict)
builder.add_node("node", lambda state: state)
builder.add_edge("__start__", "node")
graph = builder.compile().with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT + 1)
assert rebind(graph).config["recursion_limit"] == EXPLICIT_DEFAULT_LIMIT
@@ -521,11 +521,10 @@ def test_addressed_resume_keeps_a_rerun_tasks_write_to_a_new_channel(
graph = builder.compile(checkpointer=sync_checkpointer)
config = {"configurable": {"thread_id": "t"}}
graph.invoke(_both("in"), config, durability=durability)
paused = graph.get_state(config).config
graph.update_state(config, None)
head = graph.get_state(config).config
live = graph.invoke(
Command(resume="yes", update={"flag": True}), paused, durability=durability
Command(resume="yes", update={"flag": True}), head, durability=durability
)
state = graph.get_state(config)
@@ -626,33 +625,3 @@ def test_exit_resume_retried_after_its_final_checkpoint_fails_reruns_the_resumed
state = graph.get_state(config)
assert state.values["log"] == state.values["plain"]
assert sorted(state.values["plain"]) == ["ask", "done", "in"]
class _TurnState(_ResumeState):
turn: int
@pytest.mark.parametrize("exit_runs", [1, 2])
def test_exit_runs_from_a_checkpoint_the_thread_moved_past_leave_its_newer_branch_alone(
sync_checkpointer: BaseCheckpointSaver, exit_runs: int
) -> None:
builder = StateGraph(_TurnState)
builder.add_node("a", lambda state: _both(f"a{state['turn']}"))
builder.add_node("b", lambda state: _both(f"b{state['turn']}"))
builder.add_edge(START, "a")
builder.add_edge("a", "b")
graph = builder.compile(checkpointer=sync_checkpointer)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"turn": 1, **_both("in1")}, config)
end_of_turn_1 = graph.get_state(config).config
graph.invoke({"turn": 2, **_both("in2")}, config)
for turn in range(3, 3 + exit_runs):
graph.invoke(
{"turn": turn, **_both(f"in{turn}")}, end_of_turn_1, durability="exit"
)
history = list(graph.get_state_history(config))
assert [s.values.get("log", []) for s in history] == [
s.values.get("plain", []) for s in history
]
@@ -16,11 +16,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Command, Durability, Overwrite
pytestmark = pytest.mark.anyio
@@ -38,19 +34,18 @@ def _append_reducer(
return result
def _build_graph(
checkpointer: Any,
*,
reducer: Any = _append_reducer,
write: Any = lambda: [AIMessage(content="reply", id="ai-1")],
) -> Any:
def _build_graph(checkpointer: Any) -> Any:
State = TypedDict( # noqa: UP013
"State",
{"messages": Annotated[list, DeltaChannel(reducer, snapshot_frequency=50)]},
{
"messages": Annotated[
list, DeltaChannel(_append_reducer, snapshot_frequency=50)
]
},
) # type: ignore[call-overload]
def agent(state: dict) -> dict:
return {"messages": write()}
return {"messages": [AIMessage(content="reply", id="ai-1")]}
return (
StateGraph(State)
@@ -143,193 +138,3 @@ def test_delta_channel_dict_style_message_gets_stable_id() -> None:
assert len(set(ids)) == 1, (
f"dict-style HumanMessage id must be stable across get_state() calls; got {ids}"
)
@pytest.mark.parametrize("container", [list, tuple])
def test_delta_channel_message_in_an_overwrite_gets_stable_id(container: type) -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "overwrite-id-stability"}}
graph.invoke(
{"messages": Overwrite(container([HumanMessage(content="hello")]))}, config
)
ids = [
next(
m.id
for m in graph.get_state(config).values["messages"]
if isinstance(m, HumanMessage)
)
for _ in range(3)
]
assert ids[0] is not None, "a message inside an Overwrite should get a UUID"
assert len(set(ids)) == 1, (
f"its id must be stable across get_state() calls; got {ids}"
)
# Writes of "hello" in every shape `_messages_delta_reducer` accepts.
MESSAGE_SHAPES = [
pytest.param(lambda: [HumanMessage("hello")], id="message"),
pytest.param(lambda: HumanMessage("hello"), id="bare message"),
pytest.param(lambda: [{"role": "user", "content": "hello"}], id="dict"),
pytest.param(lambda: {"type": "human", "content": "hello"}, id="bare dict"),
pytest.param(
lambda: [{"role": "user", "content": "hello", "id": ""}], id="dict, empty id"
),
pytest.param(lambda: [("user", "hello")], id="tuple"),
pytest.param(lambda: ("user", "hello"), id="bare tuple"),
pytest.param(lambda: ["hello"], id="string"),
pytest.param(lambda: "hello", id="bare string"),
pytest.param(lambda: Overwrite(["hello"]), id="Overwrite"),
pytest.param(lambda: Overwrite(("hello",)), id="Overwrite of a tuple"),
pytest.param(lambda: {"__overwrite__": [("user", "hello")]}, id="Overwrite dict"),
]
def _hello_ids(graph: Any, config: dict) -> list[str | None]:
return [
next(
m.id
for m in graph.get_state(config).values["messages"]
if m.content == "hello"
)
for _ in range(2)
]
@pytest.mark.parametrize("write", MESSAGE_SHAPES)
def test_input_message_of_any_shape_gets_stable_id(write: Any) -> None:
graph = _build_graph(InMemorySaver(), reducer=_messages_delta_reducer)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": write()}, config)
first, second = _hello_ids(graph, config)
assert first, "the message should get a UUID"
assert first == second, "its id must be the same on every read"
@pytest.mark.parametrize("write", MESSAGE_SHAPES)
def test_node_message_of_any_shape_gets_stable_id(write: Any) -> None:
graph = _build_graph(InMemorySaver(), reducer=_messages_delta_reducer, write=write)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": []}, config)
first, second = _hello_ids(graph, config)
assert first, "the message should get a UUID"
assert first == second, "its id must be the same on every read"
def test_a_list_written_twice_adds_its_messages_twice() -> None:
turn = [("user", "hello")]
graph = _build_graph(InMemorySaver(), reducer=_messages_delta_reducer)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": turn}, config)
graph.invoke({"messages": turn}, config)
hellos = [
m.id for m in graph.get_state(config).values["messages"] if m.content == "hello"
]
assert len(set(hellos)) == 2, f"each write should add a message; got {hellos}"
assert turn == [("user", "hello")], "the caller's list should be left as it was"
def test_a_forked_command_update_gets_a_stable_id() -> None:
graph = _build_graph(InMemorySaver(), reducer=_messages_delta_reducer)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("a", id="a")]}, config)
before_agent = next(
s.config for s in graph.get_state_history(config) if s.next == ("agent",)
)
graph.invoke(Command(update={"messages": "hello"}), before_agent)
first, second = _hello_ids(graph, config)
assert first, "the message should get a UUID"
assert first == second, "its id must be the same on every read"
def test_a_message_dict_the_reducer_rejects_fails_the_run_before_it_is_saved() -> None:
graph = _build_graph(
InMemorySaver(),
reducer=_messages_delta_reducer,
write=lambda: [{"role": "tool", "content": "x"}],
)
config = {"configurable": {"thread_id": "t"}}
with pytest.raises(KeyError):
graph.invoke({"messages": [HumanMessage("a", id="a")]}, config)
assert [m.id for m in graph.get_state(config).values["messages"]] == ["a"]
def _pregel_graph(seen: list[list[str | None]]) -> Any:
def look(state: dict) -> list:
seen.append([m.id for m in state["messages"]])
return []
node = (
NodeBuilder()
.subscribe_to("go", read=False)
.read_from("messages")
.do(look)
.write_to("messages")
)
return Pregel(
nodes={"look": node},
channels={
"messages": DeltaChannel(_messages_delta_reducer),
"go": LastValue(int),
},
input_channels=["messages", "go"],
output_channels=["messages"],
checkpointer=InMemorySaver(),
)
def test_pregel_input_message_gets_stable_id_on_a_new_thread(
durability: Durability,
) -> None:
graph = _pregel_graph([])
config = {"configurable": {"thread_id": "t"}}
graph.invoke(
{"messages": [HumanMessage("hello")], "go": 1}, config, durability=durability
)
first, second = _hello_ids(graph, config)
assert first, "the message should get a UUID"
assert first == second, "its id must be the same on every read"
def test_a_node_reads_the_pregel_input_message_ids_that_are_saved() -> None:
seen: list[list[str | None]] = []
graph = _pregel_graph(seen)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("first", id="first")], "go": 1}, config)
graph.invoke({"messages": [{"role": "user", "content": "hello"}], "go": 1}, config)
saved = [m.id for m in graph.get_state(config).values["messages"]]
assert None not in saved
assert seen[-1] == saved, "the node must see the ids that are saved"
@pytest.mark.parametrize(
"value",
[
pytest.param({"type": "tool", "name": "search"}, id="not a message"),
pytest.param({"role": "user", "content": "x"}, id="message-shaped"),
],
)
def test_other_delta_channels_keep_the_dicts_in_an_overwrite(value: dict) -> None:
graph = _build_graph(InMemorySaver(), write=lambda: Overwrite([dict(value)]))
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": []}, config)
assert graph.get_state(config).values["messages"] == [value]
@@ -1,122 +0,0 @@
"""A node served from the node cache saves its writes like a node that ran,
without writing them back to the cache, and a node that fails isn't cached."""
import operator
from typing import Annotated, Any
import pytest
from langgraph.cache.memory import InMemoryCache
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.types import CachePolicy, Durability
pytestmark = pytest.mark.anyio
INPUT = {"log": [], "plain": []}
def _append(current: list, writes: list) -> list:
return [*current, *(item for write in writes for item in write)]
class _State(TypedDict):
log: Annotated[list, DeltaChannel(_append)]
plain: Annotated[list, operator.add]
class _CountsSets(InMemoryCache):
sets = 0
def set(self, keys: Any) -> None:
self.sets += 1
super().set(keys)
def _a_then_cached_b_then_c(runs: list[str], cache: InMemoryCache) -> Any:
def node(name: str) -> Any:
def run(state: _State) -> dict:
runs.append(name)
return {"log": [name], "plain": [name]}
return run
builder = StateGraph(_State)
builder.add_node("a", node("a"))
builder.add_node("b", node("b"), cache_policy=CachePolicy())
builder.add_node("c", node("c"))
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", "c")
return builder.compile(checkpointer=InMemorySaver(), cache=cache)
def test_a_cache_hit_saves_its_writes_without_caching_them_again(
durability: Durability,
) -> None:
runs: list[str] = []
cache = _CountsSets()
graph = _a_then_cached_b_then_c(runs, cache)
graph.invoke(INPUT, {"configurable": {"thread_id": "1"}}, durability=durability)
config = {"configurable": {"thread_id": "2"}}
graph.invoke(INPUT, config, durability=durability)
assert runs == ["a", "b", "c", "a", "c"]
assert cache.sets == 1, "the cache hit was written back to the cache"
for state in graph.get_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
async def test_a_cache_hit_saves_its_writes_without_caching_them_again_async(
durability: Durability,
) -> None:
runs: list[str] = []
cache = _CountsSets()
graph = _a_then_cached_b_then_c(runs, cache)
await graph.ainvoke(
INPUT, {"configurable": {"thread_id": "1"}}, durability=durability
)
config = {"configurable": {"thread_id": "2"}}
await graph.ainvoke(INPUT, config, durability=durability)
assert runs == ["a", "b", "c", "a", "c"]
assert cache.sets == 1, "the cache hit was written back to the cache"
async for state in graph.aget_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
def _cached_node_that_fails(runs: list[str]) -> Any:
def fail(state: _State) -> dict:
runs.append("b")
raise ValueError("b failed")
builder = StateGraph(_State)
builder.add_node("b", fail, cache_policy=CachePolicy())
builder.add_edge(START, "b")
return builder.compile(checkpointer=InMemorySaver(), cache=InMemoryCache())
def test_a_node_that_fails_is_not_cached() -> None:
runs: list[str] = []
graph = _cached_node_that_fails(runs)
for thread in ("1", "2"):
with pytest.raises(ValueError, match="b failed"):
graph.invoke(INPUT, {"configurable": {"thread_id": thread}})
assert runs == ["b", "b"]
async def test_a_node_that_fails_is_not_cached_async() -> None:
runs: list[str] = []
graph = _cached_node_that_fails(runs)
for thread in ("1", "2"):
with pytest.raises(ValueError, match="b failed"):
await graph.ainvoke(INPUT, {"configurable": {"thread_id": thread}})
assert runs == ["b", "b"]
@@ -1,103 +0,0 @@
"""A run's input to a DeltaChannel input channel reads back on the checkpoints
built from it, and on no others."""
import pytest
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Durability
pytestmark = pytest.mark.anyio
def _sorted_extend(current: list, writes: list) -> list:
return sorted([*current, *(item for write in writes for item in write)])
def _delta_input_graph() -> Pregel:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log", "plain")
return Pregel(
nodes={"n": node},
channels={
"log": DeltaChannel(_sorted_extend),
"plain": BinaryOperatorAggregate(list, lambda a, b: sorted(a + b)),
"go": LastValue(int),
},
input_channels=["log", "plain", "go"],
output_channels=["log", "plain"],
checkpointer=InMemorySaver(),
)
def test_each_run_input_reads_back_on_its_own_checkpoints(
durability: Durability,
) -> None:
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"log": [0], "plain": [0], "go": 1}, config, durability=durability)
graph.invoke({"log": [5], "plain": [5], "go": 1}, config, durability=durability)
for state in graph.get_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
async def test_each_run_input_reads_back_on_its_own_checkpoints_async(
durability: Durability,
) -> None:
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke(
{"log": [0], "plain": [0], "go": 1}, config, durability=durability
)
await graph.ainvoke(
{"log": [5], "plain": [5], "go": 1}, config, durability=durability
)
async for state in graph.aget_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
OTHER_BRANCH_INPUTS = pytest.mark.parametrize(
"other_branch_input",
[{"go": 1}, {"log": [5], "plain": [5], "go": 1}],
ids=["other-branch-without-delta-input", "other-branch-with-delta-input"],
)
@OTHER_BRANCH_INPUTS
def test_run_input_from_an_older_checkpoint_stays_out_of_its_other_branch(
durability: Durability, other_branch_input: dict
) -> None:
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"go": 1}, config, durability=durability)
older = graph.get_state(config).config
graph.invoke(other_branch_input, config, durability=durability)
graph.invoke({"log": [7], "plain": [7], "go": 1}, older, durability=durability)
for state in graph.get_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
@OTHER_BRANCH_INPUTS
async def test_run_input_from_an_older_checkpoint_stays_out_of_its_other_branch_async(
durability: Durability, other_branch_input: dict
) -> None:
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"go": 1}, config, durability=durability)
older = (await graph.aget_state(config)).config
await graph.ainvoke(other_branch_input, config, durability=durability)
await graph.ainvoke(
{"log": [7], "plain": [7], "go": 1}, older, durability=durability
)
async for state in graph.aget_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
@@ -1,78 +0,0 @@
import pytest
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.pregel import NodeBuilder, Pregel
pytestmark = pytest.mark.anyio
CONFIG = {"configurable": {"thread_id": "t"}}
def _extend(current: list, writes: list) -> list:
return [*current, *(item for write in writes for item in write)]
def _graph(reads: list) -> Pregel:
writer = NodeBuilder().subscribe_only("a").do(lambda _: [1]).write_to("d")
reader = NodeBuilder().subscribe_only("d").do(lambda d: reads.append(list(d)))
return Pregel(
nodes={"writer": writer, "reader": reader},
channels={"a": LastValue(str), "d": DeltaChannel(_extend)},
input_channels="a",
output_channels=["d"],
checkpointer=InMemorySaver(),
)
def test_an_input_update_from_before_the_first_write_starts_only_the_writer() -> None:
reads: list = []
graph = _graph(reads)
graph.invoke("go", CONFIG)
first = next(
s.config for s in graph.get_state_history(CONFIG) if s.metadata["step"] == -1
)
fork = graph.update_state(first, {"a": "go"}, as_node="__input__")
assert graph.get_state(fork).next == ("writer",)
graph.invoke(None, fork)
assert reads == [[1], [1]]
def test_a_replay_from_before_the_first_write_forks_with_only_the_writer_next() -> None:
reads: list = []
graph = _graph(reads)
graph.invoke("go", CONFIG)
first = next(
s.config for s in graph.get_state_history(CONFIG) if s.metadata["step"] == -1
)
graph.invoke(None, first, durability="sync")
fork = next(
s for s in graph.get_state_history(CONFIG) if s.metadata["source"] == "fork"
)
assert fork.next == ("writer",)
graph.invoke(None, fork.config)
assert reads == [[1], [1], [1]]
async def test_an_ainput_update_from_before_the_first_write_starts_only_the_writer() -> (
None
):
reads: list = []
graph = _graph(reads)
await graph.ainvoke("go", CONFIG)
first = [
s.config
async for s in graph.aget_state_history(CONFIG)
if s.metadata["step"] == -1
][0]
fork = await graph.aupdate_state(first, {"a": "go"}, as_node="__input__")
assert (await graph.aget_state(fork)).next == ("writer",)
await graph.ainvoke(None, fork)
assert reads == [[1], [1]]
@@ -1,148 +0,0 @@
import operator
from typing import Annotated, Any
from unittest.mock import patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import TypedDict
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
from langgraph.pregel._checkpoint import channels_from_checkpoint, empty_checkpoint
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
class DeltaState(TypedDict):
messages: Annotated[
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=3)
]
class PlainState(TypedDict):
messages: Annotated[list, add_messages]
def _app(schema: type, checkpointer: BaseCheckpointSaver) -> Any:
builder = StateGraph(schema)
builder.add_node(
"chat",
lambda state: {"messages": [AIMessage(f"reply {len(state['messages'])}")]},
)
builder.add_edge(START, "chat")
builder.add_edge("chat", END)
return builder.compile(checkpointer=checkpointer)
def _delta_thread(checkpointer: BaseCheckpointSaver) -> RunnableConfig:
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
delta = _app(DeltaState, checkpointer)
for i in range(4):
delta.invoke({"messages": [HumanMessage(f"q{i}")]}, config)
assert any(
isinstance(t.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
for t in checkpointer.list(config)
), "the thread should have a snapshot to read through"
return config
def test_plain_reducer_reads_every_delta_written_checkpoint(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _delta_thread(sync_checkpointer)
delta = _app(DeltaState, sync_checkpointer)
plain = _app(PlainState, sync_checkpointer)
assert [s.values for s in plain.get_state_history(config)] == [
s.values for s in delta.get_state_history(config)
]
def test_plain_reducer_continues_delta_written_thread(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
config = _delta_thread(sync_checkpointer)
delta = _app(DeltaState, sync_checkpointer)
before = delta.get_state(config).values["messages"]
after = _app(PlainState, sync_checkpointer).invoke(
{"messages": [HumanMessage("q4")]}, config
)["messages"]
assert after[:-2] == before
assert [m.content for m in after[-2:]] == ["q4", f"reply {len(before) + 1}"]
head = sync_checkpointer.get_tuple(config)
assert head is not None
assert head.checkpoint["channel_values"]["messages"] == after
assert delta.get_state(config).values["messages"] == after
async def test_plain_reducer_reads_and_continues_delta_written_thread_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
delta = _app(DeltaState, async_checkpointer)
for i in range(4):
await delta.ainvoke({"messages": [HumanMessage(f"q{i}")]}, config)
plain = _app(PlainState, async_checkpointer)
expected = [s.values async for s in delta.aget_state_history(config)]
assert [s.values async for s in plain.aget_state_history(config)] == expected
after = (await plain.ainvoke({"messages": [HumanMessage("q4")]}, config))[
"messages"
]
assert after[:-2] == expected[0]["messages"]
@pytest.mark.parametrize(
("seed", "writes", "expected"),
[
(_DeltaSnapshot(["a"]), [["b"], ["c"]], ["a", "b", "c"]),
(["a"], [["b"], Overwrite(["c"]), ["d"]], ["c", "d"]),
(None, [["b"], Overwrite(["c"]), Overwrite(["d"]), ["e"]], ["d", "e"]),
],
)
def test_plain_reducer_replays_delta_history_one_write_at_a_time(
seed: Any, writes: list[Any], expected: list[str]
) -> None:
history: dict[str, Any] = {"writes": [("t", "items", w) for w in writes]}
if seed is not None:
history["seed"] = seed
checkpoint = empty_checkpoint()
checkpoint["channel_versions"]["items"] = 1
saver = InMemorySaver()
with patch.object(
saver, "get_delta_channel_history", return_value={"items": history}
):
channels, _ = channels_from_checkpoint(
{"items": BinaryOperatorAggregate(list, operator.add)},
checkpoint,
saver=saver,
config={"configurable": {"thread_id": "1"}},
)
assert channels["items"].get() == expected
def test_plain_thread_reads_never_walk_history() -> None:
saver = InMemorySaver()
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
plain = _app(PlainState, saver)
with patch.object(saver, "get_delta_channel_history") as walk:
plain.invoke({"messages": [HumanMessage("q0")]}, config)
plain.update_state(config, {"messages": [HumanMessage("note")]})
plain.invoke({"messages": [HumanMessage("q1")]}, config)
plain.get_state(config)
list(plain.get_state_history(config))
walk.assert_not_called()
@@ -1,110 +0,0 @@
"""An `Overwrite` through `update_state` snapshots its DeltaChannel on the
checkpoint the update saves, as a node's `Overwrite` does on the loop's."""
from typing import Annotated, Any
import pytest
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
class _State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def _messages_graph(saver: InMemorySaver) -> Any:
builder = StateGraph(_State)
builder.add_node("model", lambda state: {})
builder.add_edge(START, "model")
return builder.compile(checkpointer=saver)
def _extend(current: list, writes: list) -> list:
return [*current, *(item for write in writes for item in write)]
def _delta_input_graph(saver: InMemorySaver) -> Pregel:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log")
return Pregel(
nodes={"n": node},
channels={"log": DeltaChannel(_extend), "go": LastValue(int)},
input_channels=["log", "go"],
output_channels=["log"],
checkpointer=saver,
)
def test_update_state_with_an_overwrite_snapshots_the_channel() -> None:
saver = InMemorySaver()
graph = _messages_graph(saver)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage(content="a", id="1")]}, config)
graph.update_state(
config,
{"messages": Overwrite([HumanMessage(content="b", id="2")])},
as_node="model",
)
head = saver.get_tuple(config)
assert head is not None
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
assert [m.content for m in graph.get_state(config).values["messages"]] == ["b"]
async def test_aupdate_state_with_an_overwrite_snapshots_the_channel() -> None:
saver = InMemorySaver()
graph = _messages_graph(saver)
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"messages": [HumanMessage(content="a", id="1")]}, config)
await graph.aupdate_state(
config,
{"messages": Overwrite([HumanMessage(content="b", id="2")])},
as_node="model",
)
head = await saver.aget_tuple(config)
assert head is not None
assert isinstance(head.checkpoint["channel_values"].get("messages"), _DeltaSnapshot)
values = (await graph.aget_state(config)).values
assert [m.content for m in values["messages"]] == ["b"]
def test_update_state_as_input_with_an_overwrite_snapshots_the_channel() -> None:
saver = InMemorySaver()
graph = _delta_input_graph(saver)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"log": [0], "go": 1}, config)
graph.update_state(config, {"log": Overwrite([1])}, as_node="__input__")
head = saver.get_tuple(config)
assert head is not None
assert isinstance(head.checkpoint["channel_values"].get("log"), _DeltaSnapshot)
assert graph.get_state(config).values["log"] == [1]
async def test_aupdate_state_as_input_with_an_overwrite_snapshots_the_channel() -> None:
saver = InMemorySaver()
graph = _delta_input_graph(saver)
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"log": [0], "go": 1}, config)
await graph.aupdate_state(config, {"log": Overwrite([1])}, as_node="__input__")
head = await saver.aget_tuple(config)
assert head is not None
assert isinstance(head.checkpoint["channel_values"].get("log"), _DeltaSnapshot)
assert (await graph.aget_state(config)).values["log"] == [1]
@@ -25,13 +25,10 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import TypedDict
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Overwrite, StateSnapshot, StateUpdate
from langgraph.types import StateSnapshot, StateUpdate
pytestmark = pytest.mark.anyio
@@ -541,207 +538,3 @@ def test_update_state_that_snapshots_keeps_a_deferred_node_pending() -> None:
assert [m.content for m in final["messages"]] == ["s", "a", "u", "b"]
assert graph.get_state(config).next == ()
def _sorted_extend(current: list, writes: list) -> list:
return sorted([*current, *(item for write in writes for item in write)])
def _delta_input_graph(snapshot_frequency: int = 1000) -> Any:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log", "plain")
return Pregel(
nodes={"n": node},
channels={
"log": DeltaChannel(_sorted_extend, snapshot_frequency=snapshot_frequency),
"plain": BinaryOperatorAggregate(list, lambda a, b: sorted(a + b)),
"go": LastValue(int),
},
input_channels=["log", "plain", "go"],
output_channels=["log", "plain"],
checkpointer=InMemorySaver(),
)
@pytest.mark.parametrize("snapshot_frequency", [1, 2])
def test_update_as_input_reads_back_on_its_checkpoint_and_after_the_next_run(
snapshot_frequency: int,
) -> None:
graph = _delta_input_graph(snapshot_frequency)
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"log": [0], "plain": [0], "go": 1}, config)
graph.update_state(config, {"log": [1], "plain": [1], "go": 1}, as_node="__input__")
after_update = graph.get_state(config).values
graph.invoke(None, config)
after_run = graph.get_state(config).values
assert after_update["log"] == after_update["plain"]
assert after_run["log"] == after_run["plain"]
@pytest.mark.parametrize("snapshot_frequency", [1, 2])
async def test_aupdate_as_input_reads_back_on_its_checkpoint_and_after_the_next_run(
snapshot_frequency: int,
) -> None:
graph = _delta_input_graph(snapshot_frequency)
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"log": [0], "plain": [0], "go": 1}, config)
await graph.aupdate_state(
config, {"log": [1], "plain": [1], "go": 1}, as_node="__input__"
)
after_update = (await graph.aget_state(config)).values
await graph.ainvoke(None, config)
after_run = (await graph.aget_state(config)).values
assert after_update["log"] == after_update["plain"]
assert after_run["log"] == after_run["plain"]
def test_update_as_input_to_an_older_checkpoint_stays_out_of_its_other_branch() -> None:
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"go": 1}, config)
older = graph.get_state(config).config
graph.invoke({"go": 1}, config)
other_branch = graph.get_state(config)
edited = graph.update_state(
older, {"log": [1], "plain": [1], "go": 1}, as_node="__input__"
)
values = graph.get_state(edited).values
assert values["log"] == values["plain"]
assert graph.get_state(other_branch.config).values == other_branch.values
async def test_aupdate_as_input_to_an_older_checkpoint_stays_out_of_its_other_branch() -> (
None
):
graph = _delta_input_graph()
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"go": 1}, config)
older = (await graph.aget_state(config)).config
await graph.ainvoke({"go": 1}, config)
other_branch = await graph.aget_state(config)
edited = await graph.aupdate_state(
older, {"log": [1], "plain": [1], "go": 1}, as_node="__input__"
)
values = (await graph.aget_state(edited)).values
assert values["log"] == values["plain"]
assert (await graph.aget_state(other_branch.config)).values == other_branch.values
def _message_ids(graph: Any, config: dict) -> list[str | None]:
return [m.id for m in graph.get_state(config).values["messages"]]
async def _amessage_ids(graph: Any, config: dict) -> list[str | None]:
return [m.id for m in (await graph.aget_state(config)).values["messages"]]
def _messages_input_graph() -> Any:
node = (
NodeBuilder()
.subscribe_only("go")
.do(lambda _: [HumanMessage("n", id="n")])
.write_to("messages")
)
return Pregel(
nodes={"n": node},
channels={
"messages": DeltaChannel(_messages_delta_reducer),
"go": LastValue(int),
},
input_channels=["messages", "go"],
output_channels=["messages"],
checkpointer=InMemorySaver(),
)
# Writes of "b" in shapes `_messages_delta_reducer` accepts.
MESSAGE_SHAPES = [
pytest.param(lambda: [HumanMessage("b")], id="message"),
pytest.param(lambda: ["b"], id="string"),
pytest.param(lambda: "b", id="bare string"),
pytest.param(lambda: [("user", "b")], id="tuple"),
pytest.param(lambda: {"role": "user", "content": "b"}, id="bare dict"),
pytest.param(lambda: Overwrite([HumanMessage("b")]), id="Overwrite"),
]
@pytest.mark.parametrize("write", MESSAGE_SHAPES)
def test_update_state_gives_a_message_of_any_shape_an_id_that_every_read_keeps(
write: Any,
) -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("a", id="a")]}, config)
graph.update_state(config, {"messages": write()})
first, second = _message_ids(graph, config), _message_ids(graph, config)
assert first[-1] is not None
assert first == second
@pytest.mark.parametrize("write", MESSAGE_SHAPES)
async def test_aupdate_state_gives_a_message_of_any_shape_an_id_that_every_read_keeps(
write: Any,
) -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"messages": [HumanMessage("a", id="a")]}, config)
await graph.aupdate_state(config, {"messages": write()})
first, second = (
await _amessage_ids(graph, config),
await _amessage_ids(graph, config),
)
assert first[-1] is not None
assert first == second
@pytest.mark.parametrize("write", MESSAGE_SHAPES)
def test_update_as_input_gives_a_message_of_any_shape_an_id(write: Any) -> None:
graph = _messages_input_graph()
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("a", id="a")], "go": 1}, config)
graph.update_state(config, {"messages": write()}, as_node="__input__")
first, second = _message_ids(graph, config), _message_ids(graph, config)
assert first[-1] is not None
assert first == second
def test_update_state_on_an_older_checkpoint_gives_a_message_an_id() -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("a", id="a")]}, config)
older = graph.get_state(config).config
graph.invoke({"messages": [HumanMessage("c", id="c")]}, config)
branch = graph.update_state(older, {"messages": [HumanMessage("b")]})
assert _message_ids(graph, branch)[-1] is not None
async def test_aupdate_as_input_gives_a_message_an_id_that_every_read_keeps() -> None:
graph = _messages_input_graph()
config = {"configurable": {"thread_id": "t"}}
await graph.ainvoke({"messages": [HumanMessage("a", id="a")], "go": 1}, config)
await graph.aupdate_state(
config, {"messages": [HumanMessage("b")]}, as_node="__input__"
)
first, second = (
await _amessage_ids(graph, config),
await _amessage_ids(graph, config),
)
assert first[-1] is not None
assert first == second
@@ -1,168 +0,0 @@
"""A checkpoint must never be saved without the `DeltaChannel` writes it reads."""
import operator
import threading
from typing import Annotated, Any
import pytest
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.types import Durability
pytestmark = pytest.mark.anyio
INPUT = {"log": [], "plain": []}
FINAL = {"log": ["a", "b", "c"], "plain": ["a", "b", "c"]}
# Exit mode saves nothing before the failed write, so its retry starts over.
RETRIES = [
pytest.param("sync", None, id="sync"),
pytest.param("async", None, id="async"),
pytest.param("exit", INPUT, id="exit"),
]
def _append(current: list, writes: list) -> list:
return [*current, *(item for write in writes for item in write)]
class _State(TypedDict):
log: Annotated[list, DeltaChannel(_append)]
plain: Annotated[list, operator.add]
class _FailsTheWriteOfBOnce(InMemorySaver):
failed = False
def _fail_once(self, writes: Any) -> None:
if not self.failed and ("log", ["b"]) in writes:
self.failed = True
raise ConnectionError("b's write was not saved")
def put_writes(
self, config: Any, writes: Any, task_id: str, task_path: str = ""
) -> None:
self._fail_once(writes)
super().put_writes(config, writes, task_id, task_path)
async def aput_writes(
self, config: Any, writes: Any, task_id: str, task_path: str = ""
) -> None:
self._fail_once(writes)
await super().aput_writes(config, writes, task_id, task_path)
class _FailsTheSaveAfterAOnce(InMemorySaver):
failed = False
def _fail_once(self, checkpoint: Any) -> None:
if not self.failed and checkpoint["channel_values"].get("plain") == ["a"]:
self.failed = True
raise ConnectionError("the checkpoint after a was not saved")
def put(
self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
) -> Any:
self._fail_once(checkpoint)
return super().put(config, checkpoint, metadata, new_versions)
async def aput(
self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
) -> Any:
self._fail_once(checkpoint)
return await super().aput(config, checkpoint, metadata, new_versions)
def _a_then_b_then_c(saver: InMemorySaver) -> Any:
builder = StateGraph(_State)
for name in "abc":
builder.add_node(
name, lambda state, name=name: {"log": [name], "plain": [name]}
)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", "c")
return builder.compile(checkpointer=saver)
@pytest.mark.parametrize(("durability", "retry_input"), RETRIES)
def test_a_failed_delta_write_is_rerun_not_lost(
durability: Durability, retry_input: dict | None
) -> None:
graph = _a_then_b_then_c(_FailsTheWriteOfBOnce())
config = {"configurable": {"thread_id": "t"}}
with pytest.raises(ConnectionError):
graph.invoke(INPUT, config, durability=durability)
for state in graph.get_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
graph.invoke(retry_input, config, durability=durability)
assert graph.get_state(config).values == FINAL
@pytest.mark.parametrize(("durability", "retry_input"), RETRIES)
async def test_a_failed_delta_write_is_rerun_not_lost_async(
durability: Durability, retry_input: dict | None
) -> None:
graph = _a_then_b_then_c(_FailsTheWriteOfBOnce())
config = {"configurable": {"thread_id": "t"}}
with pytest.raises(ConnectionError):
await graph.ainvoke(INPUT, config, durability=durability)
async for state in graph.aget_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
await graph.ainvoke(retry_input, config, durability=durability)
assert (await graph.aget_state(config)).values == FINAL
@pytest.mark.parametrize("durability", ["sync", "async"])
def test_a_failed_checkpoint_save_is_rerun_not_built_on(
durability: Durability,
) -> None:
graph = _a_then_b_then_c(_FailsTheSaveAfterAOnce())
config = {"configurable": {"thread_id": "t"}}
with pytest.raises(ConnectionError):
graph.invoke(INPUT, config, durability=durability)
for state in graph.get_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
graph.invoke(None, config, durability=durability)
assert graph.get_state(config).values == FINAL
@pytest.mark.parametrize("durability", ["sync", "async"])
async def test_a_failed_checkpoint_save_is_rerun_not_built_on_async(
durability: Durability,
) -> None:
graph = _a_then_b_then_c(_FailsTheSaveAfterAOnce())
config = {"configurable": {"thread_id": "t"}}
with pytest.raises(ConnectionError):
await graph.ainvoke(INPUT, config, durability=durability)
async for state in graph.aget_state_history(config):
assert state.values.get("log", []) == state.values.get("plain", [])
await graph.ainvoke(None, config, durability=durability)
assert (await graph.aget_state(config)).values == FINAL
def test_a_delta_graph_finishes_on_a_single_background_thread() -> None:
graph = _a_then_b_then_c(InMemorySaver())
config = {"configurable": {"thread_id": "t"}, "max_concurrency": 1}
result: dict = {}
run = threading.Thread(
target=lambda: result.update(graph.invoke(INPUT, config, durability="async")),
daemon=True,
)
run.start()
run.join(timeout=10)
assert not run.is_alive(), "invoke hung"
assert result == FINAL
@@ -152,83 +152,6 @@ def test_finished_task_does_not_report_answered_interrupt(
assert asked == [["A1", "B"]]
def test_finished_task_output_applies_once(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
builder = StateGraph(State)
builder.add_node("a", lambda s: {"log": [f"a:{interrupt('A')}"]})
builder.add_node("b", lambda s: {"log": [f"b:{interrupt('B')}"]})
builder.add_node("c", lambda s: {"log": [f"c:{interrupt('C')}"]})
builder.add_edge(START, "a")
builder.add_edge(START, "b")
builder.add_edge(START, "c")
graph = builder.compile(checkpointer=sync_checkpointer)
config = _config()
result = graph.invoke({"log": []}, config, durability=durability)
while pending := result.get("__interrupt__"):
result = graph.invoke(
Command(resume={pending[0].id: "yes"}), config, durability=durability
)
assert sorted(result["log"]) == ["a:yes", "b:yes", "c:yes"]
def test_resume_with_update_applies_the_update_once(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
def ask_three_times(state: State) -> State:
answers = [interrupt(str(question)) for question in range(1, 4)]
return {"log": ["a:" + "".join(answers)]}
graph = (
StateGraph(State)
.add_node("a", ask_three_times)
.add_edge(START, "a")
.compile(checkpointer=sync_checkpointer)
)
config = _config()
result = graph.invoke({"log": []}, config, durability=durability)
for i in range(1, 4):
result = graph.invoke(
Command(resume=str(i), update={"log": [f"u{i}"]}),
config,
durability=durability,
)
assert sorted(result["log"]) == ["a:123", "u1", "u2", "u3"]
assert sorted(graph.get_state(config).values["log"]) == sorted(result["log"])
@pytest.mark.parametrize("checkpoint_id", ["none", "head"])
def test_resume_at_the_latest_checkpoint_does_not_rerun_finished_task(
sync_checkpointer: BaseCheckpointSaver, checkpoint_id: str
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls)
config = _config()
graph.invoke({"log": []}, config)
def resume(question: str, answer: str) -> Any:
snapshot = graph.get_state(config)
target = (
snapshot.config
if checkpoint_id == "head"
else {"configurable": {**config["configurable"], "checkpoint_id": None}}
)
return graph.invoke(
Command(resume={_interrupt_by_value(snapshot, question).id: answer}),
target,
)
resume("A1", "yes")
result = resume("B", "ok")
assert sorted(result["log"]) == ["a:yes", "b:ok"]
assert calls == {"a": 2, "b": 3}, "resuming B ran the finished A again"
@NEEDS_CONTEXTVARS
async def test_finished_task_does_not_report_answered_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
@@ -551,64 +474,6 @@ def test_functional_task_finished_with_none_is_not_rerun(
assert calls == {"a": 2, "b": 3}
def test_subgraph_finished_task_is_not_rerun(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
calls: Counter[str] = Counter()
builder = StateGraph(State)
builder.add_node("child", _build_parallel(None, calls))
builder.add_edge(START, "child")
graph = builder.compile(checkpointer=sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config, durability=durability)
snapshot = graph.get_state(config)
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "yes"}),
config,
durability=durability,
)
snapshot = graph.get_state(config)
result = graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}),
config,
durability=durability,
)
assert sorted(result["log"]) == ["a:yes", "b:ok"]
assert calls == {"a": 2, "b": 3}, "resuming B ran the finished A again"
def test_nested_entrypoint_finished_task_is_not_rerun(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
@task
def step_1(query: str) -> str:
calls["step_1"] += 1
return f"{query} bar"
@task
def human_feedback(query: str) -> str:
calls["human_feedback"] += 1
return f"{query} {interrupt('feedback?')}"
@entrypoint()
def child(query: str) -> str:
return human_feedback(step_1(query).result()).result()
@entrypoint(checkpointer=sync_checkpointer)
def parent(query: str) -> str:
return child.invoke(query)
config = _config()
parent.invoke("foo", config)
assert parent.invoke(Command(resume="baz"), config) == "foo bar baz"
assert calls == {"step_1": 1, "human_feedback": 2}
# --- Reading task status from recorded writes ---
+1 -62
View File
@@ -1,8 +1,5 @@
import asyncio
import operator
import sys
from dataclasses import dataclass
from typing import Annotated, Any
from typing import Any
import pytest
from langgraph.checkpoint.base import BaseCheckpointSaver
@@ -254,61 +251,3 @@ def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
assert graph.invoke(resume({"approved": True}), config) == {
"answer": ["ok", Decision(approved=True)]
}
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
async def test_invalid_resume_lets_other_answered_tasks_finish(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""An invalid answer fails the resume without cutting off the other answered tasks.
A task cancelled after its side effect keeps its answer and runs again on the next
resume, so the side effect would happen twice.
"""
sent: list[str] = []
class State(TypedDict):
log: Annotated[list[str], operator.add]
async def send(state: State) -> State:
interrupt("send?", response_schema=Decision)
sent.append("email")
await asyncio.sleep(0.05) # waiting on the reply to a request already sent
return {"log": ["sent"]}
async def check(state: State) -> State:
interrupt("check?", response_schema=Decision)
return {"log": ["checked"]}
graph = (
StateGraph(State)
.add_node("send", send)
.add_node("check", check)
.add_edge(START, "send")
.add_edge(START, "check")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
await graph.ainvoke({"log": []}, config)
ids = {i.value: i.id for i in (await graph.aget_state(config)).interrupts}
with pytest.raises(ValidationError, match="approved"):
await graph.ainvoke(
Command(
resume={
ids["send?"]: {"approved": True},
ids["check?"]: {"approved": "nope"},
}
),
config,
)
pending = [i.value for i in (await graph.aget_state(config)).interrupts]
assert (pending, sent) == (["check?"], ["email"])
result = await graph.ainvoke(
Command(resume={ids["check?"]: {"approved": True}}), config
)
assert (sorted(result["log"]), sent) == (["checked", "sent"], ["email"])
+2 -69
View File
@@ -4410,52 +4410,6 @@ def test_debug_retry(sync_checkpointer: BaseCheckpointSaver):
assert stream_parent_conf == history_parent_conf
def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("one", lambda state: {"x": 1})
.add_node("two", lambda state: {"x": 2})
.add_edge(START, "one")
.add_edge("one", "two")
.compile(checkpointer=sync_checkpointer, interrupt_after=["one"])
)
config = {"configurable": {"thread_id": "1"}}
checkpoints = list(graph.stream({"x": 0}, config, stream_mode="checkpoints"))
state = graph.get_state(config)
assert checkpoints[-1]["config"] == state.config
assert checkpoints[-1]["next"] == ["two"]
def test_recursion_limit_streams_the_last_checkpoint_saved(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("loop", lambda state: {"x": state["x"] + 1})
.add_edge(START, "loop")
.add_edge("loop", "loop")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
checkpoints = []
with pytest.raises(GraphRecursionError):
for chunk in graph.stream({"x": 0}, config, stream_mode="checkpoints"):
checkpoints.append(chunk)
assert checkpoints[-1]["config"] == graph.get_state(config).config
def test_debug_subgraphs(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
):
@@ -5978,9 +5932,9 @@ def test_no_redundant_put_writes_for_cached_task(
put_writes_task_ids: list[str] = []
orig = PregelLoop.put_writes
def spy(self, task_id, writes, **kwargs):
def spy(self, task_id, writes):
put_writes_task_ids.append(task_id)
return orig(self, task_id, writes, **kwargs)
return orig(self, task_id, writes)
with patch.object(PregelLoop, "put_writes", spy):
result = workflow.invoke(Command(resume="ans"), config=config)
@@ -6926,22 +6880,6 @@ def test_named_tasks_functional() -> None:
]
def test_invoke_tags_reach_a_node_three_subgraphs_deep_once() -> None:
class State(TypedDict):
copies: int
def leaf(state: State, config: RunnableConfig) -> State:
return {"copies": config["tags"].count("my-tag")}
graph = StateGraph(State).add_node("leaf", leaf).add_edge(START, "leaf").compile()
for _ in range(3):
graph = (
StateGraph(State).add_node("sub", graph).add_edge(START, "sub").compile()
)
assert graph.invoke({"copies": 0}, {"tags": ["my-tag"]}) == {"copies": 1}
def test_tags_stream_mode_messages() -> None:
model = GenericFakeChatModel(messages=iter(["foo"]), tags=["meow"])
graph = (
@@ -7799,8 +7737,6 @@ def test_parallel_interrupts(sync_checkpointer: BaseCheckpointSaver) -> None:
def test_parallel_interrupts_double(sync_checkpointer: BaseCheckpointSaver) -> None:
# --- CHILD GRAPH ---
answered: Counter[str] = Counter()
class ChildState(BaseModel):
prompt: str = Field(..., description="What is going to be asked to the user?")
human_input: str | None = Field(None, description="What the human said")
@@ -7810,7 +7746,6 @@ def test_parallel_interrupts_double(sync_checkpointer: BaseCheckpointSaver) -> N
def get_human_input(state: ChildState):
human_input = interrupt(state.prompt)
answered["human"] += 1
return dict(
human_inputs=[human_input], # update parent state
@@ -7818,7 +7753,6 @@ def test_parallel_interrupts_double(sync_checkpointer: BaseCheckpointSaver) -> N
def get_dolphin_input(state: ChildState):
human_input = interrupt(state.prompt)
answered["dolphin"] += 1
return dict(
human_inputs=[human_input], # update parent state
@@ -7912,7 +7846,6 @@ def test_parallel_interrupts_double(sync_checkpointer: BaseCheckpointSaver) -> N
assert invokes == 5
assert len(events) == 5
assert answered == {"human": 2, "dolphin": 2}
def test_pregel_loop_refcount():
+2 -51
View File
@@ -5875,55 +5875,6 @@ async def test_debug_retry(async_checkpointer: BaseCheckpointSaver):
assert stream_parent_conf == history_parent_conf
async def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
async_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("one", lambda state: {"x": 1})
.add_node("two", lambda state: {"x": 2})
.add_edge(START, "one")
.add_edge("one", "two")
.compile(checkpointer=async_checkpointer, interrupt_after=["one"])
)
config = {"configurable": {"thread_id": "1"}}
checkpoints = [
chunk
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints")
]
state = await graph.aget_state(config)
assert checkpoints[-1]["config"] == state.config
assert checkpoints[-1]["next"] == ["two"]
async def test_recursion_limit_streams_the_last_checkpoint_saved(
async_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("loop", lambda state: {"x": state["x"] + 1})
.add_edge(START, "loop")
.add_edge("loop", "loop")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
checkpoints = []
with pytest.raises(GraphRecursionError):
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints"):
checkpoints.append(chunk)
assert checkpoints[-1]["config"] == (await graph.aget_state(config)).config
async def test_debug_subgraphs(
async_checkpointer: BaseCheckpointSaver, durability: Durability
):
@@ -8206,9 +8157,9 @@ async def test_no_redundant_put_writes_for_cached_task(
put_writes_task_ids: list[str] = []
orig = PregelLoop.put_writes
def spy(self, task_id, writes, **kwargs):
def spy(self, task_id, writes):
put_writes_task_ids.append(task_id)
return orig(self, task_id, writes, **kwargs)
return orig(self, task_id, writes)
with patch.object(PregelLoop, "put_writes", spy):
result = await workflow.ainvoke(Command(resume="ans"), config=config)
-490
View File
@@ -1,490 +0,0 @@
import operator
import sys
from collections.abc import Callable
from contextlib import contextmanager
from threading import Event
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.base import PRUNE_DELTA_THREADS_SQL, PRUNE_HEADS_SQL
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
from langgraph.types import Command, StateSnapshot, interrupt
from tests.conftest import NO_DOCKER
from tests.conftest_checkpointer import (
DEFAULT_POSTGRES_URI,
_checkpointer_postgres,
_checkpointer_postgres_aio,
_checkpointer_postgres_aio_pipe,
_checkpointer_postgres_aio_pool,
_checkpointer_postgres_pipe,
_checkpointer_postgres_pool,
)
pytestmark = [
pytest.mark.anyio,
pytest.mark.skipif(NO_DOCKER, reason="needs Postgres"),
]
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
SYNC = [
_checkpointer_postgres,
_checkpointer_postgres_pipe,
_checkpointer_postgres_pool,
]
ASYNC = [
_checkpointer_postgres_aio,
_checkpointer_postgres_aio_pipe,
_checkpointer_postgres_aio_pool,
]
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
notes: Annotated[list[str], operator.add]
class DeltaState(TypedDict):
messages: Annotated[list[AnyMessage], DeltaChannel(_messages_delta_reducer)]
def _reply(ask: bool):
def reply(state: dict) -> dict:
n = len(state["messages"])
answer = interrupt("approve?") if ask else "ok"
return {"messages": [AIMessage(f"{answer} {n}", id=f"reply-{n}")]}
return reply
def _graph(
checkpointer: Any,
*,
ask: bool = False,
fail: set[str] | None = None,
calls: dict[str, int] | None = None,
before_fail: Callable[[], None] | None = None,
) -> Any:
def note(name: str):
def node(state: State) -> dict:
if calls is not None:
calls[name] = calls.get(name, 0) + 1
if fail is not None and name in fail:
if before_fail is not None:
before_fail()
fail.discard(name)
raise ValueError(f"{name} failed")
return {"notes": [name]}
return node
builder = StateGraph(State)
builder.add_node("left", note("left"))
builder.add_node("right", note("right"))
builder.add_node("reply", _reply(ask))
builder.add_edge(START, "left")
builder.add_edge(START, "right")
builder.add_edge(["left", "right"], "reply")
return builder.compile(checkpointer=checkpointer)
def _counting_subgraph(name: str, *, checkpointer: bool | None) -> Any:
class Calls(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
calls: Annotated[list[str], operator.add]
def count(state: Calls) -> dict:
n = len(state["messages"])
reply = AIMessage(f"{name} call {len(state['calls']) + 1}", id=f"{name}-{n}")
return {"calls": [name], "messages": [reply]}
builder = StateGraph(Calls)
builder.add_node("count", count)
builder.add_edge(START, "count")
return builder.compile(checkpointer=checkpointer)
def _delta_graph(checkpointer: Any) -> Any:
builder = StateGraph(DeltaState)
builder.add_node("reply", _reply(ask=False))
builder.add_edge(START, "reply")
return builder.compile(checkpointer=checkpointer)
def _config(thread_id: str) -> RunnableConfig:
return {"configurable": {"thread_id": thread_id}}
def _say(i: int) -> dict:
return {"messages": [HumanMessage(f"hi {i}", id=f"hi-{i}")]}
def _checkpoint_ids(checkpointer: Any, thread_id: str) -> list[str]:
return [c.checkpoint["id"] for c in checkpointer.list(_config(thread_id))]
def _namespaces(checkpointer: Any, thread_id: str) -> list[str]:
return sorted(
c.config["configurable"]["checkpoint_ns"]
for c in checkpointer.list(_config(thread_id))
)
async def _acheckpoint_ids(checkpointer: Any, thread_id: str) -> list[str]:
return [c.checkpoint["id"] async for c in checkpointer.alist(_config(thread_id))]
def _parentless(snapshot: StateSnapshot) -> StateSnapshot:
return snapshot._replace(
parent_config=None,
tasks=tuple(
t._replace(state=_parentless(t.state))
if isinstance(t.state, StateSnapshot)
else t
for t in snapshot.tasks
),
)
def _run_once_after(
saver: PostgresSaver, sql: str, action: Callable[[], Any], *, nth: int = 1
) -> None:
seen = 0
cursor = saver._cursor
class Hooked:
def __init__(self, cur: Any) -> None:
self.cur = cur
def __getattr__(self, name: str) -> Any:
return getattr(self.cur, name)
def execute(self, query: Any, params: Any = None, **kwargs: Any) -> None:
nonlocal seen
self.cur.execute(query, params, **kwargs)
if query is sql:
seen += 1
if seen == nth:
action()
@contextmanager
def hooked(**kwargs: Any):
with cursor(**kwargs) as cur:
yield Hooked(cur)
saver._cursor = hooked
@pytest.mark.parametrize("durability", ["sync", "async", "exit"])
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_keeps_the_state_and_the_thread_runs_on_like_its_twin(
make_checkpointer: Any, durability: str
) -> None:
with make_checkpointer() as checkpointer:
graph = _graph(checkpointer)
for i in range(3):
for thread_id in ("pruned", "twin"):
graph.invoke(_say(i), _config(thread_id), durability=durability)
before = graph.get_state(_config("pruned"))
checkpointer.prune(["pruned"])
assert _checkpoint_ids(checkpointer, "pruned") == [
before.config["configurable"]["checkpoint_id"]
]
assert graph.get_state(_config("pruned")) == _parentless(before)
for thread_id in ("pruned", "twin"):
graph.invoke(_say(3), _config(thread_id), durability=durability)
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_keeps_a_pending_interrupt_resumable(
make_checkpointer: Any,
) -> None:
with make_checkpointer() as checkpointer:
graph = _graph(checkpointer, ask=True)
for thread_id in ("pruned", "twin"):
for i in range(2):
graph.invoke(_say(i), _config(thread_id))
graph.invoke(Command(resume=f"yes {i}"), _config(thread_id))
graph.invoke(_say(2), _config(thread_id))
before = graph.get_state(_config("pruned"))
assert before.interrupts
checkpointer.prune(["pruned"])
assert graph.get_state(_config("pruned")) == _parentless(before)
for thread_id in ("pruned", "twin"):
graph.invoke(Command(resume="yes 2"), _config(thread_id))
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_keeps_writes_of_tasks_that_finished_before_a_failure(
make_checkpointer: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
with make_checkpointer() as checkpointer:
calls: dict[str, int] = {}
fail: set[str] = set()
left_persisted = Event()
put_writes = checkpointer.put_writes
def persist(config, writes, task_id, task_path=""):
put_writes(config, writes, task_id, task_path)
if ("notes", ["left"]) in writes:
left_persisted.set()
def wait_for_left() -> None:
assert left_persisted.wait(5), "left task writes were not persisted"
monkeypatch.setattr(checkpointer, "put_writes", persist)
graph = _graph(checkpointer, fail=fail, calls=calls, before_fail=wait_for_left)
for thread_id in ("pruned", "twin"):
for i in range(2):
graph.invoke(_say(i), _config(thread_id))
left_persisted.clear()
fail.add("right")
with pytest.raises(ValueError, match="right failed"):
graph.invoke(_say(2), _config(thread_id))
checkpoint = checkpointer.get_tuple(_config(thread_id))
assert checkpoint is not None
assert any(
channel == "notes" and value == ["left"]
for _, channel, value in checkpoint.pending_writes
)
before = graph.get_state(_config("pruned"))
checkpointer.prune(["pruned"])
assert graph.get_state(_config("pruned")) == _parentless(before)
left_runs = calls["left"]
graph.invoke(None, _config("pruned"))
assert calls["left"] == left_runs, "a task that had finished ran again"
graph.invoke(None, _config("twin"))
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_keeps_an_interrupted_subgraph_resumable(
make_checkpointer: Any,
) -> None:
with make_checkpointer() as checkpointer:
sub = StateGraph(State)
sub.add_node("reply", _reply(ask=True))
sub.add_edge(START, "reply")
parent = StateGraph(State)
parent.add_node("sub", sub.compile())
parent.add_edge(START, "sub")
graph = parent.compile(checkpointer=checkpointer)
for thread_id in ("pruned", "twin"):
for i in range(2):
graph.invoke(_say(i), _config(thread_id))
graph.invoke(Command(resume=f"yes {i}"), _config(thread_id))
graph.invoke(_say(2), _config(thread_id))
before = graph.get_state(_config("pruned"), subgraphs=True)
interrupted = before.tasks[0].state.config["configurable"]["checkpoint_ns"]
checkpointer.prune(["pruned"])
assert _namespaces(checkpointer, "pruned") == ["", interrupted]
assert graph.get_state(_config("pruned"), subgraphs=True) == _parentless(before)
for thread_id in ("pruned", "twin"):
graph.invoke(Command(resume="yes 2"), _config(thread_id))
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
@pytest.mark.parametrize("durability", ["sync", "async", "exit"])
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_keeps_a_checkpointer_true_subgraph_but_not_finished_calls(
make_checkpointer: Any, durability: str
) -> None:
with make_checkpointer() as checkpointer:
parent = StateGraph(State)
parent.add_node("memory", _counting_subgraph("memory", checkpointer=True))
parent.add_node("call", _counting_subgraph("call", checkpointer=None))
parent.add_edge(START, "memory")
parent.add_edge("memory", "call")
graph = parent.compile(checkpointer=checkpointer)
for i in range(3):
for thread_id in ("pruned", "twin"):
graph.invoke(_say(i), _config(thread_id), durability=durability)
checkpointer.prune(["pruned"])
assert _namespaces(checkpointer, "pruned") == ["", "memory"]
for thread_id in ("pruned", "twin"):
graph.invoke(_say(3), _config(thread_id), durability=durability)
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
def test_keep_latest_keeps_a_turn_saved_after_it_read_the_heads() -> None:
with (
_checkpointer_postgres() as checkpointer,
PostgresSaver.from_conn_string(
DEFAULT_POSTGRES_URI + checkpointer.conn.info.dbname
) as pruner,
):
graph = _graph(checkpointer)
for i in range(3):
for thread_id in ("pruned", "twin"):
graph.invoke(_say(i), _config(thread_id))
_run_once_after(
pruner, PRUNE_HEADS_SQL, lambda: graph.invoke(_say(3), _config("pruned"))
)
pruner.prune(["pruned"])
graph.invoke(_say(3), _config("twin"))
assert (
graph.get_state(_config("pruned")).values
== graph.get_state(_config("twin")).values
)
def test_keep_latest_refuses_a_thread_that_switches_to_a_delta_channel_mid_batch() -> (
None
):
with (
_checkpointer_postgres() as checkpointer,
PostgresSaver.from_conn_string(
DEFAULT_POSTGRES_URI + checkpointer.conn.info.dbname
) as pruner,
):
graph, delta_graph = _graph(checkpointer), _delta_graph(checkpointer)
for i in range(3):
for thread_id in ("switched", "twin"):
graph.invoke(_say(i), _config(thread_id))
_run_once_after(
pruner,
PRUNE_DELTA_THREADS_SQL,
lambda: delta_graph.invoke(_say(3), _config("switched")),
)
with pytest.raises(NotImplementedError, match="switched"):
pruner.prune(["switched"])
delta_graph.invoke(_say(3), _config("twin"))
assert (
delta_graph.get_state(_config("switched")).values
== delta_graph.get_state(_config("twin")).values
)
def test_keep_latest_keeps_a_delta_channel_turn_saved_after_the_thread_check() -> None:
with (
_checkpointer_postgres() as checkpointer,
PostgresSaver.from_conn_string(
DEFAULT_POSTGRES_URI + checkpointer.conn.info.dbname
) as pruner,
):
graph, delta_graph = _graph(checkpointer), _delta_graph(checkpointer)
for i in range(3):
for thread_id in ("switched", "twin"):
graph.invoke(_say(i), _config(thread_id))
head = _checkpoint_ids(checkpointer, "switched")[0]
_run_once_after(
pruner,
PRUNE_DELTA_THREADS_SQL,
lambda: delta_graph.invoke(_say(3), _config("switched")),
nth=2,
)
pruner.prune(["switched"])
assert _checkpoint_ids(checkpointer, "switched")[-1] == head
delta_graph.invoke(_say(3), _config("twin"))
assert (
delta_graph.get_state(_config("switched")).values
== delta_graph.get_state(_config("twin")).values
)
@pytest.mark.parametrize("make_checkpointer", SYNC)
def test_keep_latest_refuses_a_batch_with_a_delta_channel_thread(
make_checkpointer: Any,
) -> None:
with make_checkpointer() as checkpointer:
graph, delta_graph = _graph(checkpointer), _delta_graph(checkpointer)
for i in range(3):
graph.invoke(_say(i), _config("ordinary"))
delta_graph.invoke(_say(i), _config("delta"))
before = {t: _checkpoint_ids(checkpointer, t) for t in ("ordinary", "delta")}
delta_before = delta_graph.get_state(_config("delta"))
with pytest.raises(NotImplementedError, match="delta"):
checkpointer.prune(["ordinary", "delta"])
assert {t: _checkpoint_ids(checkpointer, t) for t in before} == before
assert delta_graph.get_state(_config("delta")) == delta_before
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("make_checkpointer", ASYNC)
async def test_akeep_latest_keeps_a_pending_interrupt_resumable(
make_checkpointer: Any,
) -> None:
async with make_checkpointer() as checkpointer:
graph = _graph(checkpointer, ask=True)
for thread_id in ("pruned", "twin"):
for i in range(2):
await graph.ainvoke(_say(i), _config(thread_id))
await graph.ainvoke(Command(resume=f"yes {i}"), _config(thread_id))
await graph.ainvoke(_say(2), _config(thread_id))
before = await graph.aget_state(_config("pruned"))
await checkpointer.aprune(["pruned"])
assert await _acheckpoint_ids(checkpointer, "pruned") == [
before.config["configurable"]["checkpoint_id"]
]
assert await graph.aget_state(_config("pruned")) == _parentless(before)
for thread_id in ("pruned", "twin"):
await graph.ainvoke(Command(resume="yes 2"), _config(thread_id))
assert (await graph.aget_state(_config("pruned"))).values == (
await graph.aget_state(_config("twin"))
).values
@pytest.mark.parametrize("make_checkpointer", ASYNC)
async def test_akeep_latest_refuses_a_batch_with_a_delta_channel_thread(
make_checkpointer: Any,
) -> None:
async with make_checkpointer() as checkpointer:
graph, delta_graph = _graph(checkpointer), _delta_graph(checkpointer)
for i in range(3):
await graph.ainvoke(_say(i), _config("ordinary"))
await delta_graph.ainvoke(_say(i), _config("delta"))
before = {
t: await _acheckpoint_ids(checkpointer, t) for t in ("ordinary", "delta")
}
with pytest.raises(NotImplementedError, match="delta"):
await checkpointer.aprune(["ordinary", "delta"])
assert {t: await _acheckpoint_ids(checkpointer, t) for t in before} == before
+440 -47
View File
@@ -16,11 +16,13 @@ from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.channels.last_value import LastValue
from langgraph.errors import GraphInterrupt
from langgraph.func import task
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.pregel import Pregel
from langgraph.pregel.remote import RemoteGraph
from langgraph.types import Interrupt, StateSnapshot
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.pregel.remote import RemoteException, RemoteGraph
from langgraph.types import Command, Interrupt, RetryPolicy, StateSnapshot
from tests.any_str import AnyStr
from tests.conftest import NO_DOCKER
from tests.example_app.example_graph import app
@@ -819,50 +821,6 @@ async def test_astream():
]
_CALLER_CONFIG: RunnableConfig = {
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "outer:123"}
}
_SUBGRAPH_PARTS = [
StreamPart(event="values|sub:abc", data={"chunk": "inner"}),
StreamPart(event="values", data={"chunk": "root"}),
]
_CALLER_PREFIXED = [
(("outer:123", "sub:abc"), {"chunk": "inner"}),
(("outer:123",), {"chunk": "root"}),
]
def test_stream_subgraphs_keeps_caller_namespace():
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = _SUBGRAPH_PARTS
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
stream_parts = list(
remote_pregel.stream(
{"input": "data"}, _CALLER_CONFIG, stream_mode="values", subgraphs=True
)
)
assert stream_parts == _CALLER_PREFIXED
async def test_astream_subgraphs_keeps_caller_namespace():
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = _SUBGRAPH_PARTS
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
stream_parts = [
part
async for part in remote_pregel.astream(
{"input": "data"}, _CALLER_CONFIG, stream_mode="values", subgraphs=True
)
]
assert stream_parts == _CALLER_PREFIXED
def test_invoke():
# set up test
mock_sync_client = MagicMock()
@@ -928,6 +886,441 @@ def test_stream_sanitizes_thread_id():
assert not passed_config["configurable"]
class _ApprovalState(TypedDict, total=False):
request: str
approved: bool
result: str
_REMOTE_INTERRUPT_ID = "0123456789abcdef0123456789abcdef"
_REMOTE_INTERRUPT_RUN = [
StreamPart(
event="updates",
data={
"__interrupt__": [
{"value": {"question": "Approve X?"}, "id": _REMOTE_INTERRUPT_ID}
]
},
)
]
_REMOTE_DONE_RUN = [
StreamPart(
event="values", data={"request": "X", "approved": True, "result": "done"}
)
]
_INTERRUPTED_THREAD = {
"status": "interrupted",
"interrupts": {
"task": [{"value": {"question": "Approve X?"}, "id": _REMOTE_INTERRUPT_ID}]
},
}
_IDLE_THREAD = {"status": "idle", "interrupts": {}}
_RETRY = RetryPolicy(initial_interval=0, jitter=False, retry_on=ValueError)
def _remote_interrupt_parent(remote_client: MagicMock, next_client: MagicMock):
return (
StateGraph(_ApprovalState)
.add_node(
"agent_b",
RemoteGraph("agent_b", sync_client=remote_client, client=remote_client),
retry_policy=_RETRY,
)
.add_node(
"agent_c",
RemoteGraph("agent_c", sync_client=next_client, client=next_client),
retry_policy=_RETRY,
)
.add_edge(START, "agent_b")
.add_edge("agent_b", "agent_c")
.compile(checkpointer=InMemorySaver())
)
def _sync_clients(remote_runs: list, next_runs: list) -> tuple[MagicMock, MagicMock]:
remote_client = MagicMock()
remote_client.runs.stream.side_effect = remote_runs
remote_client.threads.get.return_value = _INTERRUPTED_THREAD
next_client = MagicMock()
next_client.runs.stream.side_effect = next_runs
next_client.threads.get.return_value = _IDLE_THREAD
return remote_client, next_client
@pytest.mark.parametrize(
"resume",
[{"approved": True}, {_REMOTE_INTERRUPT_ID: {"approved": True}}],
ids=["value", "interrupt_id"],
)
def test_subgraph_resume_is_forwarded_to_remote_thread(resume):
remote_client, next_client = _sync_clients(
[_REMOTE_INTERRUPT_RUN, _REMOTE_DONE_RUN], [_REMOTE_DONE_RUN]
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
out = parent.invoke({"request": "X"}, config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
out = parent.invoke(Command(resume=resume), config)
assert "__interrupt__" not in out
assert out["result"] == "done"
first, second = remote_client.runs.stream.call_args_list
assert first.kwargs["input"] == {"request": "X"}
assert first.kwargs["command"] is None
assert second.kwargs["input"] is None
assert second.kwargs["command"] == {"resume": resume}
assert second.kwargs["thread_id"] == first.kwargs["thread_id"]
next_kwargs = next_client.runs.stream.call_args.kwargs
assert next_kwargs["command"] is None
assert next_kwargs["input"] == {"request": "X", "approved": True, "result": "done"}
def test_subgraph_resume_for_other_interrupt_reraises_remote_interrupt():
remote_client, next_client = _sync_clients([_REMOTE_INTERRUPT_RUN], [])
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
other_id = "fedcba9876543210fedcba9876543210"
out = parent.invoke(Command(resume={other_id: {"approved": True}}), config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
assert remote_client.runs.stream.call_count == 1
def test_subgraph_retry_after_resume_sends_input_to_idle_remote_thread():
remote_client, next_client = _sync_clients(
[_REMOTE_INTERRUPT_RUN, _REMOTE_DONE_RUN],
[ValueError("transient"), _REMOTE_DONE_RUN],
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
out = parent.invoke(
Command(resume={_REMOTE_INTERRUPT_ID: {"approved": True}}), config
)
assert out["result"] == "done"
retry = next_client.runs.stream.call_args_list[1]
assert retry.kwargs["command"] is None
assert retry.kwargs["input"] == {"request": "X", "approved": True, "result": "done"}
def test_subgraph_retry_after_consumed_resume_reraises_remote_interrupt():
remote_client, next_client = _sync_clients(
[_REMOTE_INTERRUPT_RUN, ValueError("transient"), _REMOTE_DONE_RUN],
[_REMOTE_DONE_RUN],
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
out = parent.invoke(Command(resume={"approved": True}), config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
assert remote_client.runs.stream.call_count == 2
out = parent.invoke(Command(resume={"approved": True}), config)
assert out["result"] == "done"
for call in remote_client.runs.stream.call_args_list[1:]:
assert call.kwargs["input"] is None
assert call.kwargs["command"] == {"resume": {"approved": True}}
def test_subgraph_resume_map_is_scoped_to_each_remote():
id_a, id_b = _REMOTE_INTERRUPT_ID, "fedcba9876543210fedcba9876543210"
def client(interrupt_id: str, done: dict) -> MagicMock:
pending = [{"value": "q", "id": interrupt_id}]
c = MagicMock()
c.runs.stream.side_effect = [
[StreamPart(event="updates", data={"__interrupt__": pending})],
[StreamPart(event="values", data=done)],
]
c.threads.get.return_value = {
"status": "interrupted",
"interrupts": {"task": pending},
}
return c
client_a = client(id_a, {"approved": True})
client_b = client(id_b, {"result": "done"})
parent = (
StateGraph(_ApprovalState)
.add_node("a", RemoteGraph("a", sync_client=client_a, client=client_a))
.add_node("b", RemoteGraph("b", sync_client=client_b, client=client_b))
.add_edge(START, "a")
.add_edge(START, "b")
.compile(checkpointer=InMemorySaver())
)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
out = parent.invoke(Command(resume={id_a: "answer a", id_b: "answer b"}), config)
assert out["approved"] is True
assert out["result"] == "done"
assert client_a.runs.stream.call_args.kwargs["command"] == {
"resume": {id_a: "answer a"}
}
assert client_b.runs.stream.call_args.kwargs["command"] == {
"resume": {id_b: "answer b"}
}
def _resumed_then_dropped():
# A remote node's next interrupt() reuses the id of the one just resumed.
yield from _REMOTE_INTERRUPT_RUN
raise ValueError("transient")
def test_subgraph_retry_does_not_resend_resume_map():
remote_client, next_client = _sync_clients(
[_REMOTE_INTERRUPT_RUN, _resumed_then_dropped()], []
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
out = parent.invoke(
Command(resume={_REMOTE_INTERRUPT_ID: {"approved": True}}), config
)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
assert remote_client.runs.stream.call_count == 2
def _ancestor_retry_parent(remote_client: MagicMock, via: str = "node"):
# A low-level Pregel subgraph has no START step, so an ancestor retry's
# RESUMING flag reaches the RemoteGraph task with a fresh node_attempt.
remote = RemoteGraph("agent_b", sync_client=remote_client, client=remote_client)
inner = Pregel(
nodes={
"agent_b": NodeBuilder()
.subscribe_only("request")
.do(remote)
.write_to("result")
},
channels={"request": LastValue(str), "result": LastValue(object)},
input_channels=["request"],
output_channels=["result"],
)
if via == "node":
p = inner
elif via == "task":
@task
def child(request: str):
return inner.invoke({"request": request})["result"]
def p(state: _ApprovalState):
return {"result": child(state["request"]).result()}
else:
@task
async def child(request: str):
return (await inner.ainvoke({"request": request}))["result"]
async def p(state: _ApprovalState):
return {"result": await child(state["request"])}
return (
StateGraph(_ApprovalState)
.add_node("p", p, retry_policy=_RETRY)
.add_edge(START, "p")
.compile(checkpointer=InMemorySaver())
)
@pytest.mark.parametrize("via", ["node", "task"])
def test_subgraph_ancestor_retry_does_not_resend_resume_map(via):
remote_client, _ = _sync_clients(
[_REMOTE_INTERRUPT_RUN, _resumed_then_dropped(), _REMOTE_DONE_RUN], []
)
parent = _ancestor_retry_parent(remote_client, via)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
out = parent.invoke(Command(resume={_REMOTE_INTERRUPT_ID: "first"}), config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
parent.invoke(Command(resume={_REMOTE_INTERRUPT_ID: "second"}), config)
assert [c.kwargs["command"] for c in remote_client.runs.stream.call_args_list] == [
None,
{"resume": {_REMOTE_INTERRUPT_ID: "first"}},
{"resume": {_REMOTE_INTERRUPT_ID: "second"}},
]
def test_subgraph_empty_remote_interrupt_pauses_parent():
remote_client, next_client = _sync_clients(
[[StreamPart(event="updates", data={"__interrupt__": []})]], []
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
parent.invoke({"request": "X"}, config)
assert parent.get_state(config).next == ("agent_b",)
next_client.runs.stream.assert_not_called()
def test_subgraph_remote_error_after_interrupt_is_raised():
remote_client, next_client = _sync_clients(
[
[
*_REMOTE_INTERRUPT_RUN,
StreamPart(event="error", data={"error": "boom", "message": "x"}),
]
],
[],
)
parent = _remote_interrupt_parent(remote_client, next_client)
with pytest.raises(RemoteException):
parent.invoke({"request": "X"}, {"configurable": {"thread_id": "t"}})
def test_subgraph_surfaces_all_remote_interrupts():
second_id = "fedcba9876543210fedcba9876543210"
remote_client, next_client = _sync_clients(
[
[
*_REMOTE_INTERRUPT_RUN,
StreamPart(event="values", data={"request": "X"}),
StreamPart(
event="updates",
data={"__interrupt__": [{"value": "second", "id": second_id}]},
),
]
],
[],
)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
out = parent.invoke({"request": "X"}, config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID),
Interrupt(value="second", id=second_id),
]
async def test_subgraph_resume_is_forwarded_to_remote_thread_async():
def async_runs(*runs: list) -> list[MagicMock]:
iters = []
for parts in runs:
async_iter = MagicMock()
async_iter.__aiter__.return_value = parts
iters.append(async_iter)
return iters
remote_client = MagicMock()
remote_client.runs.stream.side_effect = async_runs(
_REMOTE_INTERRUPT_RUN, _REMOTE_DONE_RUN
)
remote_client.threads.get = AsyncMock(return_value=_INTERRUPTED_THREAD)
next_client = MagicMock()
next_client.runs.stream.side_effect = async_runs(_REMOTE_DONE_RUN)
next_client.threads.get = AsyncMock(return_value=_IDLE_THREAD)
parent = _remote_interrupt_parent(remote_client, next_client)
config = {"configurable": {"thread_id": "parent_thread"}}
out = await parent.ainvoke({"request": "X"}, config)
assert "__interrupt__" in out
out = await parent.ainvoke(Command(resume={"approved": True}), config)
assert "__interrupt__" not in out
assert out["result"] == "done"
second = remote_client.runs.stream.call_args_list[1]
assert second.kwargs["input"] is None
assert second.kwargs["command"] == {"resume": {"approved": True}}
assert next_client.runs.stream.call_args.kwargs["command"] is None
async def test_subgraph_retry_does_not_resend_resume_map_async():
async def run(*parts: StreamPart, error: Exception | None = None):
for part in parts:
yield part
if error:
raise error
remote_client = MagicMock()
remote_client.runs.stream.side_effect = [
run(*_REMOTE_INTERRUPT_RUN),
run(*_REMOTE_INTERRUPT_RUN, error=ValueError("transient")),
]
remote_client.threads.get = AsyncMock(return_value=_INTERRUPTED_THREAD)
parent = _remote_interrupt_parent(remote_client, MagicMock())
config = {"configurable": {"thread_id": "parent_thread"}}
await parent.ainvoke({"request": "X"}, config)
resume = {_REMOTE_INTERRUPT_ID: {"approved": True}}
out = await parent.ainvoke(Command(resume=resume), config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
assert remote_client.runs.stream.call_count == 2
assert remote_client.runs.stream.call_args.kwargs["command"] == {"resume": resume}
@pytest.mark.parametrize(
"via", ["node", pytest.param("atask", marks=NEEDS_CONTEXTVARS)]
)
async def test_subgraph_ancestor_retry_does_not_resend_resume_map_async(via):
async def run(*parts: StreamPart, error: Exception | None = None):
for part in parts:
yield part
if error:
raise error
remote_client = MagicMock()
remote_client.runs.stream.side_effect = [
run(*_REMOTE_INTERRUPT_RUN),
run(*_REMOTE_INTERRUPT_RUN, error=ValueError("transient")),
run(*_REMOTE_DONE_RUN),
]
remote_client.threads.get = AsyncMock(return_value=_INTERRUPTED_THREAD)
parent = _ancestor_retry_parent(remote_client, via)
config = {"configurable": {"thread_id": "parent_thread"}}
await parent.ainvoke({"request": "X"}, config)
out = await parent.ainvoke(Command(resume={_REMOTE_INTERRUPT_ID: "first"}), config)
assert out["__interrupt__"] == [
Interrupt(value={"question": "Approve X?"}, id=_REMOTE_INTERRUPT_ID)
]
await parent.ainvoke(Command(resume={_REMOTE_INTERRUPT_ID: "second"}), config)
assert [c.kwargs["command"] for c in remote_client.runs.stream.call_args_list] == [
None,
{"resume": {_REMOTE_INTERRUPT_ID: "first"}},
{"resume": {_REMOTE_INTERRUPT_ID: "second"}},
]
@pytest.mark.anyio
async def test_ainvoke():
# set up test
-89
View File
@@ -248,95 +248,6 @@ def test_drain_from_subgraph_can_resume_parent() -> None:
}
class _BreakpointState(TypedDict, total=False):
first: str
second: str
def _drain_before_breakpoint_builder(
second_runs: list[str],
) -> StateGraph[_BreakpointState]:
def first(state: _BreakpointState, runtime: Runtime) -> _BreakpointState:
runtime.control.request_drain("rollout")
return {"first": "done"}
def second(state: _BreakpointState) -> _BreakpointState:
second_runs.append("second")
return {"second": "done"}
builder = StateGraph(_BreakpointState)
builder.add_node("first", first)
builder.add_node("second", second)
builder.add_edge(START, "first")
builder.add_edge("first", "second")
return builder
@pytest.mark.parametrize("at_compile", [True, False])
def test_drain_before_interrupt_before_stops_at_breakpoint(at_compile: bool) -> None:
# A resume passes the next step's breakpoints, so a drain right before a
# breakpoint has to stop there instead, or the resumed run skips it.
second_runs: list[str] = []
breakpoints = {"interrupt_before": ["second"]}
compiled = _drain_before_breakpoint_builder(second_runs).compile(
checkpointer=MemorySaver(), **(breakpoints if at_compile else {})
)
run_kwargs = {} if at_compile else breakpoints
config = {"configurable": {"thread_id": "drain-breakpoint"}}
assert compiled.invoke({}, config, control=RunControl(), **run_kwargs) == {
"first": "done"
}
assert compiled.get_state(config).next == ("second",)
assert second_runs == []
assert compiled.invoke(None, config, **run_kwargs) == {
"first": "done",
"second": "done",
}
assert second_runs == ["second"]
def test_drain_before_subgraph_interrupt_before_stops_at_breakpoint() -> None:
second_runs: list[str] = []
child = _drain_before_breakpoint_builder(second_runs).compile(
interrupt_before=["second"]
)
parent = StateGraph(_BreakpointState)
parent.add_node("child", child)
parent.add_edge(START, "child")
compiled = parent.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "drain-subgraph-breakpoint"}}
compiled.invoke({}, config, control=RunControl())
state = compiled.get_state(config, subgraphs=True)
assert state.next == ("child",)
assert state.tasks[0].state.next == ("second",)
assert second_runs == []
assert compiled.invoke(None, config) == {"first": "done", "second": "done"}
assert second_runs == ["second"]
@pytest.mark.anyio
async def test_drain_before_interrupt_before_stops_at_breakpoint_async() -> None:
second_runs: list[str] = []
compiled = _drain_before_breakpoint_builder(second_runs).compile(
checkpointer=MemorySaver(), interrupt_before=["second"]
)
config = {"configurable": {"thread_id": "drain-breakpoint-async"}}
assert await compiled.ainvoke({}, config, control=RunControl()) == {"first": "done"}
assert (await compiled.aget_state(config)).next == ("second",)
assert second_runs == []
assert await compiled.ainvoke(None, config) == {
"first": "done",
"second": "done",
}
assert second_runs == ["second"]
@pytest.mark.anyio
async def test_drain_requested_in_terminal_step_finishes_normally_async() -> None:
class State(TypedDict, total=False):
-37
View File
@@ -1177,43 +1177,6 @@ def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id(
assert result["value"] == ["sub_a", "human:answer", "sub_b"]
def test_subgraph_replay_reasks_answered_parallel_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replaying a subgraph checkpoint asks its step's questions again, including
one already answered. An update checkpoint replays in place instead of
forking, so its tasks keep their ids and its saved answers could be reused."""
subgraph = (
StateGraph(State)
.add_node("a", lambda s: {"value": [f"a:{interrupt('a')}"]})
.add_node("b", lambda s: {"value": [f"b:{interrupt('b')}"]})
.add_edge(START, "a")
.add_edge(START, "b")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("subgraph_node", subgraph)
.add_edge(START, "subgraph_node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
def subgraph_head() -> RunnableConfig:
return graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke({"value": []}, config)
graph.update_state(subgraph_head(), {"value": ["u"]}, as_node="__start__")
result = graph.invoke(None, config)
question_a = next(i for i in result["__interrupt__"] if i.value == "a")
graph.invoke(Command(resume={question_a.id: "yes"}), config)
result = graph.invoke(None, subgraph_head())
assert sorted(i.value for i in result["__interrupt__"]) == ["a", "b"]
def test_subgraph_replay_loads_accumulated_state_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
-36
View File
@@ -1,6 +1,4 @@
import copy
import functools
import pickle
import sys
import uuid
from collections.abc import Callable
@@ -27,7 +25,6 @@ from pydantic import BaseModel, Field
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import (
DEFAULT_RECURSION_LIMIT,
_is_not_empty,
_merge_callbacks,
ensure_config,
@@ -623,30 +620,6 @@ def test_merge_configs_merges_metadata_lc_versions() -> None:
assert merged["metadata"]["lc_agent_name"] == "agent"
@pytest.mark.parametrize(
"rebuild",
[
lambda config: config,
copy.deepcopy,
lambda config: pickle.loads(pickle.dumps(config)),
],
ids=["as_is", "deepcopy", "pickle"],
)
def test_merge_configs_skips_the_injected_default_recursion_limit(
rebuild: Callable[[RunnableConfig], RunnableConfig],
) -> None:
merged = merge_configs({"recursion_limit": 50}, rebuild(ensure_config()))
assert merged["recursion_limit"] == 50
def test_merge_configs_applies_an_explicit_recursion_limit_equal_to_default() -> None:
explicit_default = int(DEFAULT_RECURSION_LIMIT)
merged = merge_configs(
{"recursion_limit": 50}, {"recursion_limit": explicit_default}
)
assert merged["recursion_limit"] == explicit_default
def test_ensure_config_merges_metadata_lc_versions() -> None:
a = {
"metadata": {
@@ -799,15 +772,6 @@ def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None:
assert merged["tags"] == ["shared", "alpha", "shared", "beta"]
def test_ensure_config_skips_tags_inherited_from_ambient() -> None:
token = var_child_runnable_config.set({"tags": ["inherited"]})
try:
merged = ensure_config({"tags": ["inherited", "own"]})
finally:
var_child_runnable_config.reset(token)
assert merged["tags"] == ["inherited", "own"]
def test_ensure_config_merges_callbacks_across_configs() -> None:
a_cb = _TrackingCB("a")
b_cb = _TrackingCB("b")
+54 -63
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -202,11 +202,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -931,6 +931,18 @@ 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" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1439,7 +1451,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.17"
version = "1.2.14"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1673,7 +1685,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.2.0"
version = "3.1.2"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -1722,7 +1734,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.2"
version = "3.1.1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -1823,7 +1835,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.1"
version = "1.1.0"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1844,7 +1856,7 @@ dev = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg", extras = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -1864,7 +1876,7 @@ test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg", extras = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -1907,7 +1919,7 @@ requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-protocol", specifier = ">=0.0.15" },
{ name = "orjson", specifier = ">=3.11.5" },
{ name = "websockets", specifier = ">=14" },
{ name = "websockets", specifier = ">=14,<17" },
]
[package.metadata.requires-dev]
@@ -2099,7 +2111,7 @@ wheels = [
[[package]]
name = "nbconvert"
version = "7.17.2"
version = "7.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
@@ -2117,9 +2129,9 @@ dependencies = [
{ name = "pygments" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/6a/252c7620305a9661205aed7a2a4b3592f5207bfca56ad11b7dea2c5ec2f6/nbconvert-7.17.2.tar.gz", hash = "sha256:344a218e8595117ce37396ed025d994f146d076226ebac59255a2fa085ab24d8", size = 865989, upload-time = "2026-10-12T09:11:19.583Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/fd/a8d457af68848a8e5c12dd1d84ff35c415db471af78280e0703db4efbb12/nbconvert-7.17.2-py3-none-any.whl", hash = "sha256:6a143c11c00bf831604da4b1be4347d36ea52367057eb920135d69bc2862fed2", size = 262094, upload-time = "2026-10-12T09:11:17.215Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
]
[[package]]
@@ -2177,114 +2189,84 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "opentelemetry-exporter-http-transport"
version = "0.66b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "opentelemetry-exporter-otlp-common"
version = "0.66b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" },
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.45.1"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" },
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.45.1"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-http-transport", extra = ["requests"] },
{ name = "opentelemetry-exporter-otlp-common" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" }
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" },
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.45.1"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" },
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.45.1"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" },
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.66b1"
version = "0.60b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
]
[[package]]
@@ -4257,6 +4239,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/f3/1ac078fc8fceadcf066469acecacb35d2821cbfaf7d6fc5ac2107c7a314d/xxhash-4.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fac4832b638000106207bc44e44b9616a6a416aaee56c62b01d61f3705e49f58", size = 37252, upload-time = "2026-08-17T08:24:06.652Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.1.1"
version = "1.1.0"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -46,7 +46,7 @@ test = [
"langgraph-checkpoint-sqlite",
"langgraph-checkpoint-postgres",
"syrupy",
"psycopg[binary]",
"psycopg-binary",
]
lint = [
"ruff",
+118 -134
View File
@@ -49,11 +49,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.19"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -253,7 +253,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.6.5"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -267,9 +267,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/ed/ceecd3bfcfc77a94cf30a9f1506e907fdea447458825e01914a72d2d1a6f/langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b", size = 1007041, upload-time = "2026-09-24T18:11:06.487Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/68/447ac7e734990b7ed9dcfc3b6dee9a4cd4d4169e296429fa71a9a47c1c7b/langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556", size = 572136, upload-time = "2026-09-24T18:11:04.837Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/f50dd65673c819aa33d3c34df58c115dbb6ec627d19f93e6e401dd0fc8d7/langchain_core-1.6.1-py3-none-any.whl", hash = "sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a", size = 571478, upload-time = "2026-08-27T19:31:13.34Z" },
]
[[package]]
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.17"
version = "1.2.14"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -418,7 +418,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.2.0"
version = "3.1.2"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -467,7 +467,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.2"
version = "3.1.1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -512,7 +512,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.1"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -527,7 +527,7 @@ dev = [
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -547,7 +547,7 @@ test = [
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -569,7 +569,7 @@ dev = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg", extras = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -589,7 +589,7 @@ test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-postgres", editable = "../checkpoint-postgres" },
{ name = "langgraph-checkpoint-sqlite", editable = "../checkpoint-sqlite" },
{ name = "psycopg", extras = ["binary"] },
{ name = "psycopg-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -614,7 +614,7 @@ requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-protocol", specifier = ">=0.0.15" },
{ name = "orjson", specifier = ">=3.11.5" },
{ name = "websockets", specifier = ">=14" },
{ name = "websockets", specifier = ">=14,<17" },
]
[package.metadata.requires-dev]
@@ -821,93 +821,77 @@ wheels = [
[[package]]
name = "psycopg"
version = "3.3.6"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.6"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/92/00350a66de0af05e41d01aa3134e3970045e816afed3f99d58ec1abe15b2/psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc", size = 4728682, upload-time = "2026-09-18T13:15:36.605Z" },
{ url = "https://files.pythonhosted.org/packages/91/fc/afa9c7fd316a469af7ede6ebb020eac482f5d827fae57d5310c9bc0c41ae/psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e", size = 4777111, upload-time = "2026-09-18T13:15:46.566Z" },
{ url = "https://files.pythonhosted.org/packages/f2/44/7c1e015f1bc56b36ff1369f09e852b2d83ccefd5a669a42633a916cdedc4/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff", size = 5592755, upload-time = "2026-09-18T13:15:52.886Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ae/314a251ca918cdac380bce1b87839ade9355382ea749e6ef3ba75ba0c09f/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299", size = 5267949, upload-time = "2026-09-18T13:16:00.53Z" },
{ url = "https://files.pythonhosted.org/packages/b6/9f/3bb0cfe9bb0f31ca57cf486ddc8c9ac51251aed8181bf88ff870b2623105/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2", size = 6862403, upload-time = "2026-09-18T13:16:10.385Z" },
{ url = "https://files.pythonhosted.org/packages/c4/d6/7032c10309c3155e9b24300fdcc9a1afa539cfd20ce52fdef74a46f10161/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2", size = 5107271, upload-time = "2026-09-18T13:16:16.843Z" },
{ url = "https://files.pythonhosted.org/packages/61/cc/79add2cf92684cf1a81da134b32caa662c25c72d0cc905d181ef4455f834/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03", size = 4627984, upload-time = "2026-09-18T13:16:23.889Z" },
{ url = "https://files.pythonhosted.org/packages/c9/48/6dfb14f9350c14af6a2edb3c31262051b8cd94e2186e4b831e46dbbe8cd9/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4", size = 4322054, upload-time = "2026-09-18T13:16:29.33Z" },
{ url = "https://files.pythonhosted.org/packages/29/35/2982338716a91cbb4dfc866be015be4457ee8106a445aabf3d1fb6a270e0/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2", size = 4048471, upload-time = "2026-09-18T13:16:34.119Z" },
{ url = "https://files.pythonhosted.org/packages/24/e1/171b1db1542c5f76a678b7ee0a7800bebc9735a0a03417c76cf948bfd63c/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30", size = 4353931, upload-time = "2026-09-18T13:16:38.692Z" },
{ url = "https://files.pythonhosted.org/packages/08/89/4424e62a944eef40bd9326ada4ae23802b28eab6502af91e84ef7bba74fb/psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18", size = 3674362, upload-time = "2026-09-18T13:16:44.454Z" },
{ url = "https://files.pythonhosted.org/packages/70/86/b71166048974d49c6d136b2ed1c0e5bec0b974d8c4de5cbce7e86a9e412a/psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874", size = 4728002, upload-time = "2026-09-18T13:16:53.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/1d/1e06c0de7ed5aed898acb87544eac6ef0bc7d752a67ec6e5d6b835e9b40c/psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492", size = 4775403, upload-time = "2026-09-18T13:16:58.939Z" },
{ url = "https://files.pythonhosted.org/packages/84/02/2ffcbc43f8e4bbc38e5286a22013bcac01898d13cd38325f60dd5428a8af/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf", size = 5594145, upload-time = "2026-09-18T13:17:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/e1/25/031dae2c7d2e7e77dcf5b1962c1e0684fa548d7af0ff6707b6b5e6054ca7/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f", size = 5267985, upload-time = "2026-09-18T13:17:16.24Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e5/94c89ada3c003a4d858178f3bba49a35e0297ef2aad659b80eb5e380e690/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300", size = 6860986, upload-time = "2026-09-18T13:17:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/9d/a0/81bf499d095adee8413bd19822a6872fbfa21663ec78014a68d83a8db83c/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a", size = 5106702, upload-time = "2026-09-18T13:17:28.847Z" },
{ url = "https://files.pythonhosted.org/packages/00/75/99d56da64c27bd985fd82c6ecbf7976b724ac638fdd1654ef995323a1a26/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f", size = 4627090, upload-time = "2026-09-18T13:17:36.668Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0c/0222171d11233332c6a24b1cef1578215f0ffddf3642eb8dd8c4448ad69f/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e", size = 4320353, upload-time = "2026-09-18T13:17:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/62/6f/e1cc2a28dd1228c67c969ba6fd37cd8726b312e2ff51380f847ddb38ccde/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba", size = 4047253, upload-time = "2026-09-18T13:17:47.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/fd/38b64790ce7a515b1dbd2bab3d119637a858aeb22c380cf4859bc4ce0e42/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7", size = 4353208, upload-time = "2026-09-18T13:17:52.41Z" },
{ url = "https://files.pythonhosted.org/packages/f7/dc/45386530ceb2a8c789a226de9b9b34eca8fccf1feba2e4ef68a6aca50c56/psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac", size = 3675638, upload-time = "2026-09-18T13:17:58.112Z" },
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" },
{ url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" },
{ url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" },
{ url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" },
{ url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" },
{ url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" },
{ url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" },
{ url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" },
{ url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" },
{ url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" },
{ url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" },
{ url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" },
{ url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" },
{ url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" },
{ url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" },
{ url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" },
{ url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" },
{ url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" },
{ url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" },
{ url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" },
{ url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" },
{ url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" },
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
@@ -1096,14 +1080,14 @@ wheels = [
[[package]]
name = "pytest-mock"
version = "3.16.0"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
@@ -1212,27 +1196,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.9"
version = "0.16.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
]
[[package]]
@@ -1249,14 +1233,14 @@ wheels = [
[[package]]
name = "syrupy"
version = "6.1.1"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/2c/8ffc1414d1951895d0d5b7dc83c0b079ebc48c3bf27ffbb0064646367500/syrupy-6.1.1.tar.gz", hash = "sha256:7e584a8df4ede6dd3c1c44a77f9edc46186b6767a73dea6f0083044946839a59", size = 109705, upload-time = "2026-09-13T20:25:21.599Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/3c797c76994dc817582abdda90ee31867a9883567b0add47dbc0ccb12ce4/syrupy-6.0.0.tar.gz", hash = "sha256:bf4f5f662a7b23a9e5711bd5597e301cd09c00f31be5fa4095f79da7b0585f3d", size = 99435, upload-time = "2026-08-22T20:48:39.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/b5/bf2a8a335a6a813869dd482cedbcde252f582b5afbfe50eb78ec09962800/syrupy-6.1.1-py3-none-any.whl", hash = "sha256:6ac9ac6a42d70ae44156f5f157a9563b9bb8cc96dcf9364327f330463bff889a", size = 63436, upload-time = "2026-09-13T20:25:20.294Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ff/4d8ae6d6070ea73ee9d466701584a75db9586ce09de98f8f1b3276efad5f/syrupy-6.0.0-py3-none-any.whl", hash = "sha256:3efa16d767bd8614413cc20057552e4aec14428c6e5ea788c1f7e03047e8a5e4", size = 58047, upload-time = "2026-08-22T20:48:38.213Z" },
]
[[package]]
@@ -1324,27 +1308,27 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.84"
version = "0.0.75"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
{ url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" },
{ url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" },
{ url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" },
{ url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" },
{ url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" },
{ url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" },
{ url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" },
{ url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" },
{ url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" },
{ url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" },
{ url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" },
{ url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" },
]
[[package]]
-8
View File
@@ -34,14 +34,6 @@
- **`thread.output`** — awaitable that resolves to the terminal thread state
`values` dict after the run lifecycle completes.
- **`Priority` (beta)** — register an async function with `@priority.assign`
and name the object in the `priority` section of `langgraph.json`. The
server calls the function with a `PriorityContext` at the first run on a
thread with no settings and stores the `PrioritySettings` it returns
(`priority`, `fairness_group`, `fairness_weight`) on the thread. Requires
`langgraph-api` 0.18.0 or later and a `langgraph-cli` that supports the
section; older versions of either ignore it.
### Changed
- `client.threads.stream()` now accepts `transport="sse"` (default) or
+1 -6
View File
@@ -2,19 +2,14 @@ from langgraph_sdk.auth import Auth
from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
__version__ = "0.4.9"
__version__ = "0.4.6"
__all__ = [
"Auth",
"DecryptResult",
"Encryption",
"EncryptionContext",
"Priority",
"PriorityContext",
"PrioritySettings",
"get_client",
"get_sync_client",
]
+2 -2
View File
@@ -179,7 +179,7 @@ class HttpClient:
return await self.request_reconnect(
loc,
"GET",
headers=headers,
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
@@ -217,7 +217,7 @@ class HttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id:
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
-10
View File
@@ -99,7 +99,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> AsyncIterator[StreamPart]: ...
@@ -132,7 +131,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> AsyncIterator[StreamPartV2]: ...
@@ -149,7 +147,6 @@ class RunsClient:
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -163,7 +160,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> AsyncIterator[StreamPart]: ...
@@ -180,7 +176,6 @@ class RunsClient:
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -194,7 +189,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> AsyncIterator[StreamPartV2]: ...
@@ -391,7 +385,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
@overload
@@ -421,7 +414,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
async def create(
@@ -655,7 +647,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
@@ -682,7 +673,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
async def wait(
+26 -65
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
@@ -37,11 +36,6 @@ from langgraph_sdk.stream.decoders import (
ToolCallsDecoder,
validate_interleave_channels,
)
from langgraph_sdk.stream.errors import (
is_non_retryable_stream_error,
protocol_error,
stream_error_frame,
)
from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel
from langgraph_sdk.stream.transport import (
AsyncProtocolTransport,
@@ -50,8 +44,6 @@ from langgraph_sdk.stream.transport import (
ProtocolWebSocketTransport,
)
logger = logging.getLogger(__name__)
class InterruptPayload(TypedDict):
"""Payload surfaced when the server requests human input for a thread."""
@@ -201,7 +193,6 @@ class RunModule:
if not gate.done():
gate.set_result(None)
self._owner._run_seen = True
self._owner._ensure_lifecycle_watcher_running()
return result
except BaseException as err:
# Why: gate MUST reject on any exit type, including CancelledError,
@@ -1212,7 +1203,6 @@ class AsyncThreadStream:
# observes a `completed`/`errored` event.
self._interrupts_lock = asyncio.Lock()
self._lifecycle_watcher_task: asyncio.Task[None] | None = None
self._lifecycle_watcher_rejected = False
self._lifecycle_watcher_handle: EventStreamHandle | None = None
self._lifecycle_cursor: int | None = None
self._lifecycle_max_reconnect_attempts = 5
@@ -1271,11 +1261,8 @@ class AsyncThreadStream:
# Create the run-done future here (async context guarantees a running loop).
self._run_done = asyncio.get_running_loop().create_future()
# Start the lifecycle watcher immediately so reattach and thread.output
# work without a preceding run.start call. A minted thread_id names no
# thread until run.start creates it, and with custom auth a stream for a
# thread that doesn't exist yet fails, so run.start starts the watcher.
if self._explicit_thread_id:
self._ensure_lifecycle_watcher_running()
# work without a preceding run.start call.
self._ensure_lifecycle_watcher_running()
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
@@ -1643,13 +1630,6 @@ class AsyncThreadStream:
async for event in self._dedup_iter(shared.events):
if self._closed:
break
if (frame_error := stream_error_frame(event)) is not None:
logger.warning(
"Thread %s event stream failed: %s",
self.thread_id,
frame_error,
)
continue
self._observe_event(event)
for sub in list(self._subscriptions.values()):
if matches_subscription(event, sub.params):
@@ -1675,7 +1655,6 @@ class AsyncThreadStream:
if (
err is not None
and not isinstance(err, asyncio.CancelledError)
and not is_non_retryable_stream_error(err)
and not self._closed
):
with contextlib.suppress(Exception):
@@ -1702,8 +1681,7 @@ class AsyncThreadStream:
Returns:
`True` if a new stream was opened (caller should resume fanout),
`False` if all reconnect attempts were exhausted, the server
rejected the stream with a non-retryable 4xx, or the controller
`False` if all reconnect attempts were exhausted or the controller
was closed in the meantime.
"""
if self._transport is None:
@@ -1724,9 +1702,7 @@ class AsyncThreadStream:
await new_stream.ready
except asyncio.CancelledError:
raise
except Exception as err:
if is_non_retryable_stream_error(err):
return False
except Exception:
await self._reconnect_sleep(attempt)
continue
self._shared_stream = new_stream
@@ -1824,7 +1800,9 @@ class AsyncThreadStream:
# 202/204 — no body. Caller gets an empty result.
return {}
if response.get("type") == "error":
raise protocol_error(response)
code = response.get("error", "unknown")
message = response.get("message", "")
raise RuntimeError(f"Protocol error [{code}]: {message}")
meta = response.get("meta")
if isinstance(meta, dict):
applied_through_seq = meta.get("applied_through_seq")
@@ -1848,15 +1826,8 @@ class AsyncThreadStream:
await asyncio.wait_for(asyncio.shield(gate), timeout=timeout)
def _ensure_lifecycle_watcher_running(self) -> None:
task = self._lifecycle_watcher_task
if task is not None and not self._lifecycle_watcher_rejected:
if self._lifecycle_watcher_task is not None:
return
if task is not None:
# A 4xx stopped the watcher before this run started, typically the
# 404 for a thread this run.start just created, so the result it
# left isn't this run's.
self._run_done = asyncio.get_running_loop().create_future()
self._lifecycle_watcher_rejected = False
self._lifecycle_watcher_task = asyncio.create_task(
self._run_lifecycle_watcher()
)
@@ -1879,11 +1850,7 @@ class AsyncThreadStream:
if self._transport is None:
return
reconnect_attempts = 0
frame_error: RuntimeError | None = None
while not self._closed:
# A stream opened before run.start can 404 for the thread that
# run.start creates, so it gets one more try once a run has started.
opened_before_run = not self._run_seen
try:
handle = self._transport.open_event_stream(
self._lifecycle_stream_params()
@@ -1893,7 +1860,6 @@ class AsyncThreadStream:
async for event in handle.events:
if self._closed:
return
frame_error = stream_error_frame(event) or frame_error
self._observe_lifecycle_event(event)
await self._apply_lifecycle_event(event)
err = await handle.done
@@ -1902,43 +1868,38 @@ class AsyncThreadStream:
# event. Resolve `_run_done` as errored so awaiters of
# `thread.output` don't hang.
if err is None:
self._fail_run_done(
frame_error
or RuntimeError(
"lifecycle stream ended before terminal event"
run_done = self._run_done
if run_done is not None and not run_done.done():
run_done.set_result(
_RunTerminal(
status="errored",
error=RuntimeError(
"lifecycle stream ended before terminal event"
),
)
)
)
return
reconnect_attempts += 1
if (
is_non_retryable_stream_error(err)
or reconnect_attempts > self._lifecycle_max_reconnect_attempts
):
if reconnect_attempts > self._lifecycle_max_reconnect_attempts:
raise err
await asyncio.sleep(0.05)
except asyncio.CancelledError:
raise
except Exception as exc:
if is_non_retryable_stream_error(exc):
if opened_before_run and self._run_seen:
continue
self._lifecycle_watcher_rejected = True
self._fail_run_done(exc)
return
reconnect_attempts += 1
if reconnect_attempts <= self._lifecycle_max_reconnect_attempts:
await asyncio.sleep(0.05)
continue
self._fail_run_done(
frame_error or RuntimeError(f"Lifecycle transport failed: {exc}")
)
run_done = self._run_done
if run_done is not None and not run_done.done():
run_done.set_result(
_RunTerminal(
status="errored",
error=RuntimeError(f"Lifecycle transport failed: {exc}"),
)
)
return
def _fail_run_done(self, error: BaseException) -> None:
run_done = self._run_done
if run_done is not None and not run_done.done():
run_done.set_result(_RunTerminal(status="errored", error=error))
async def _fetch_state(self) -> dict[str, Any]:
"""Fetch the current thread state from the REST endpoint."""
return await self._http.get(
+3 -5
View File
@@ -179,7 +179,7 @@ class SyncHttpClient:
return self.request_reconnect(
loc,
"GET",
headers=headers,
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
@@ -219,7 +219,7 @@ class SyncHttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id:
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
@@ -286,9 +286,7 @@ class SyncHttpClient:
break
def _encode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
if json is None:
return {}, None
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
body = orjson.dumps(
json,
_orjson_default,
+2 -21
View File
@@ -80,7 +80,6 @@ class SyncRunsClient:
command: Command | None = None,
stream_mode: StreamMode | Sequence[StreamMode] = "values",
stream_subgraphs: bool = False,
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
@@ -99,7 +98,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> Iterator[StreamPart]: ...
@@ -113,7 +111,6 @@ class SyncRunsClient:
command: Command | None = None,
stream_mode: StreamMode | Sequence[StreamMode] = "values",
stream_subgraphs: bool = False,
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
@@ -132,7 +129,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> Iterator[StreamPartV2]: ...
@@ -163,7 +159,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> Iterator[StreamPart]: ...
@@ -194,7 +189,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> Iterator[StreamPartV2]: ...
@@ -386,7 +380,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
@overload
@@ -416,7 +409,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
def create(
@@ -650,7 +642,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
@@ -677,7 +668,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
def wait(
@@ -825,6 +815,7 @@ class SyncRunsClient:
"checkpoint_during": checkpoint_during,
"on_completion": on_completion,
"after_seconds": after_seconds,
"raise_error": raise_error,
"durability": durability,
"langsmith_tracer": langsmith_tracing,
}
@@ -839,7 +830,7 @@ class SyncRunsClient:
if thread_id is not None
else "/runs/wait"
)
response = self.http.request_reconnect(
return self.http.request_reconnect(
endpoint,
"POST",
json={k: v for k, v in payload.items() if v is not None},
@@ -847,16 +838,6 @@ class SyncRunsClient:
headers=headers,
on_response=on_response if on_run_created else None,
)
if (
raise_error
and isinstance(response, dict)
and "__error__" in response
and isinstance(response["__error__"], dict)
):
raise Exception(
f"{response['__error__'].get('error')}: {response['__error__'].get('message')}"
)
return response
def list(
self,
+21 -76
View File
@@ -34,11 +34,6 @@ from langgraph_sdk.stream.decoders import (
ToolCallsDecoder,
validate_interleave_channels,
)
from langgraph_sdk.stream.errors import (
is_non_retryable_stream_error,
protocol_error,
stream_error_frame,
)
from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel
from langgraph_sdk.stream.sync_controller import (
SyncStreamController,
@@ -242,7 +237,6 @@ class SyncRunModule:
controller = self._owner._controller
if controller is not None and controller._run_start_gate is not None:
controller._run_start_gate.set()
self._owner._ensure_lifecycle_watcher_running()
return result
def respond(
@@ -1117,7 +1111,6 @@ class SyncThreadStream:
self.interrupted: bool = False
self.interrupts: list[InterruptPayload] = []
self._lifecycle_watcher_thread: threading.Thread | None = None
self._lifecycle_watcher_lock = threading.Lock()
self._lifecycle_watcher_handle: SyncEventStreamHandle | None = None
self._lifecycle_cursor: int | None = None
self._lifecycle_max_reconnect_attempts = 5
@@ -1156,11 +1149,7 @@ class SyncThreadStream:
self._transport, run_start_gate=run_start_gate
)
self._run_done = _BlockingResult()
# A minted thread_id names no thread until run.start creates it, and
# with custom auth a stream for a thread that doesn't exist yet fails,
# so run.start starts the watcher instead.
if self._explicit_thread_id:
self._ensure_lifecycle_watcher_running()
self._ensure_lifecycle_watcher_running()
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
@@ -1496,7 +1485,9 @@ class SyncThreadStream:
if response is None:
return {}
if response.get("type") == "error":
raise protocol_error(response)
code = response.get("error", "unknown")
message = response.get("message", "")
raise RuntimeError(f"Protocol error [{code}]: {message}")
meta = response.get("meta")
if isinstance(meta, dict):
applied_through_seq = meta.get("applied_through_seq")
@@ -1505,20 +1496,14 @@ class SyncThreadStream:
return response.get("result", {})
def _ensure_lifecycle_watcher_running(self) -> None:
with self._lifecycle_watcher_lock:
if self._lifecycle_watcher_thread is not None:
return
if self._run_done is not None and self._run_done.done():
# A 4xx stopped the watcher before this run started, typically
# the 404 for a thread this run.start just created, so the
# result it left isn't this run's.
self._run_done = _BlockingResult()
self._lifecycle_watcher_thread = threading.Thread(
target=self._run_lifecycle_watcher,
name="langgraph-sdk-sync-lifecycle",
daemon=True,
)
self._lifecycle_watcher_thread.start()
if self._lifecycle_watcher_thread is not None:
return
self._lifecycle_watcher_thread = threading.Thread(
target=self._run_lifecycle_watcher,
name="langgraph-sdk-sync-lifecycle",
daemon=True,
)
self._lifecycle_watcher_thread.start()
def _observe_lifecycle_event(self, event: Event) -> None:
seq = event.get("seq")
@@ -1538,11 +1523,7 @@ class SyncThreadStream:
if self._transport is None:
return
reconnect_attempts = 0
frame_error: RuntimeError | None = None
while not self._closed:
# A stream opened before run.start can 404 for the thread that
# run.start creates, so it gets one more try once a run has started.
opened_before_run = not self._run_seen
try:
handle = self._transport.open_event_stream(
self._lifecycle_stream_params()
@@ -1551,64 +1532,28 @@ class SyncThreadStream:
for event in handle.events:
if self._closed:
return
frame_error = stream_error_frame(event) or frame_error
self._observe_lifecycle_event(event)
self._apply_lifecycle_event(event)
err = handle.error()
if err is None:
# Clean EOF: stream ended without a terminal lifecycle
# event. Resolve `_run_done` as errored so `thread.output`
# doesn't block forever.
if not self._closed:
self._fail_run_done(
frame_error
or RuntimeError(
"lifecycle stream ended before terminal event"
)
)
return
reconnect_attempts += 1
if (
is_non_retryable_stream_error(err)
or reconnect_attempts > self._lifecycle_max_reconnect_attempts
):
if reconnect_attempts > self._lifecycle_max_reconnect_attempts:
raise err
except Exception as exc:
if is_non_retryable_stream_error(exc):
if self._stop_rejected_lifecycle_watcher(
exc, unless_run_started=opened_before_run
):
return
continue
reconnect_attempts += 1
if reconnect_attempts <= self._lifecycle_max_reconnect_attempts:
continue
self._fail_run_done(
frame_error or RuntimeError(f"Lifecycle transport failed: {exc}")
)
run_done = self._run_done
if run_done is not None and not run_done.done():
run_done.set_result(
_RunTerminal(
status="errored",
error=RuntimeError(f"Lifecycle transport failed: {exc}"),
)
)
return
def _stop_rejected_lifecycle_watcher(
self, error: BaseException, *, unless_run_started: bool = False
) -> bool:
"""Fail the run with a 4xx `error` and clear the watcher, so run.start starts another.
Returns False, changing nothing, when `unless_run_started` is set and a
run has started. Holding the lock keeps a run.start from seeing this
watcher as still running once it has decided to stop.
"""
with self._lifecycle_watcher_lock:
if unless_run_started and self._run_seen:
return False
self._fail_run_done(error)
self._lifecycle_watcher_thread = None
return True
def _fail_run_done(self, error: BaseException) -> None:
run_done = self._run_done
if run_done is not None and not run_done.done():
run_done.set_result(_RunTerminal(status="errored", error=error))
def _fetch_state(self) -> dict[str, Any]:
"""Fetch the current thread state from the REST endpoint."""
return self._http.get(
@@ -1,155 +0,0 @@
"""Custom run priority for LangGraph.
.. warning::
This API is in beta and may change in future versions.
This module lets developers decide the priority settings of each thread with
an async function that the server calls at the first run on a thread with no
settings. The function is registered on an object that `langgraph.json` names,
and runs server-side.
"""
from __future__ import annotations
import functools
import inspect
import typing
import warnings
from collections.abc import Awaitable, Callable
from langgraph_sdk.encryption import DuplicateHandlerError, LangGraphBetaWarning
from langgraph_sdk.priority import types
_AssignHandler = Callable[
[types.PriorityContext], Awaitable[types.PrioritySettings | None]
]
_AssignHandlerT = typing.TypeVar("_AssignHandlerT", bound=_AssignHandler)
@functools.lru_cache(maxsize=1)
def _warn_priority_beta() -> None:
warnings.warn(
"The Priority API is in beta and may change in future versions.",
LangGraphBetaWarning,
stacklevel=4,
)
def _validate_handler(fn: typing.Callable) -> None:
"""Validate that the assign handler is async and takes one parameter.
Raises:
TypeError: If the handler is not an async function or has wrong parameter count
"""
if not inspect.iscoroutinefunction(fn):
raise TypeError(
f"Priority assign handler must be an async function, got {type(fn)}"
)
sig = inspect.signature(fn)
params = [
p
for p in sig.parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
]
if len(params) != 1:
raise TypeError(
"Priority assign handler must accept exactly 1 parameter (ctx), "
f"got {len(params)}"
)
class Priority:
"""Decide the priority settings of your LangGraph application's threads.
.. warning::
This API is in beta and may change in future versions.
To use, create a separate Python file and add the path to the file to your
LangGraph API configuration file (`langgraph.json`). Within that file, create
an instance of the Priority class and register an assign function with
`@priority.assign`.
Example `langgraph.json` file:
```json
{
"dependencies": ["."],
"graphs": {
"agent": "./my_agent/agent.py:graph"
},
"env": ".env",
"priority": {
"path": "./priority.py:priority"
}
}
```
The server calls the assign function at the first run on a thread with no
settings, which can come long after the thread was created, and stores the
settings it returns on the thread. Later runs on the thread use the stored
settings. The function can be called more than once for one thread, for
example after it raises, and only one result is stored, so keep it free of
side effects.
???+ example "Basic Usage"
```python
from langgraph_sdk import Priority, PriorityContext, PrioritySettings
priority = Priority()
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
return {
"priority": 3 if "premium" in ctx.permissions else 0,
# Stored unencrypted, so an opaque ID rather than personal data.
"fairness_group": ctx.user["org_id"],
}
```
"""
__slots__ = ("_assign_handler",)
types = types
"""Reference to priority type definitions.
Provides access to the type definitions used by the priority system:
PriorityContext and PrioritySettings.
"""
def __init__(self) -> None:
"""Initialize the Priority instance."""
_warn_priority_beta()
self._assign_handler: _AssignHandler | None = None
def assign(self, fn: _AssignHandlerT) -> _AssignHandlerT:
"""Register the function that assigns a thread's priority settings.
The function receives a `PriorityContext` and returns `PrioritySettings`,
or `None` for the defaults.
Example:
```python
@priority.assign
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
if ctx.cron_id is not None:
return {"priority": -2}
return None
```
Args:
fn: The assign handler function
Returns:
The registered handler function
Raises:
DuplicateHandlerError: If an assign handler is already registered
TypeError: If the handler has an invalid signature
"""
if self._assign_handler is not None:
raise DuplicateHandlerError("Priority assign handler already registered")
_validate_handler(fn)
self._assign_handler = fn
return fn
@@ -1,67 +0,0 @@
"""Priority types for LangGraph.
This module defines the context the server passes to the function registered
with `@priority.assign`, and the settings that function returns.
"""
from __future__ import annotations
import typing
from collections.abc import Sequence
from dataclasses import dataclass
from langgraph_sdk.auth.types import BaseUser
@dataclass(frozen=True, slots=True)
class PriorityContext:
"""Context passed to the `@priority.assign` function at the first run on a thread with no settings."""
user: BaseUser
"""The user from the deployment's auth, custom auth or LangSmith auth. Without auth, `user.identity` is empty."""
permissions: Sequence[str]
"""The user's permissions from the same auth. Without auth, it's empty."""
thread_id: str | None
"""The thread's ID, or `None` when the run creates its thread without being given an ID."""
thread_metadata: dict[str, typing.Any]
"""The thread's metadata."""
assistant_id: str
"""The ID of the assistant the run uses."""
graph_id: str
"""The ID of the graph the assistant runs."""
assistant_metadata: dict[str, typing.Any]
"""The assistant's metadata."""
run_metadata: dict[str, typing.Any]
"""The run's metadata."""
cron_id: str | None
"""The ID of the cron that started the run, or `None` when no cron started it."""
class PrioritySettings(typing.TypedDict, total=False):
"""Priority settings returned by the `@priority.assign` function.
Every key is optional: a missing key or a key set to `None` takes its
default, and returning `None` instead of a dict gives every key its default.
A value outside its range, or an empty `fairness_group`, is an error.
"""
priority: int | None
"""An integer from -5 to 5, where 5 runs first and -5 runs last. Defaults to 0."""
fairness_group: str | None
"""The thread's fairness group, a non-empty string. Defaults to `"default"`.
The server stores it unencrypted, even when the deployment uses custom
encryption, so use an opaque ID rather than personal data.
"""
fairness_weight: int | None
"""The weight of the thread's fairness group, an integer from 1 to 100. Defaults to 1."""
+4 -15
View File
@@ -79,27 +79,18 @@ class SSEDecoder:
def __init__(self) -> None:
self._event = ""
self._data = bytearray()
self._last_event_id: str | None = None
self._last_event_id = ""
self._retry: int | None = None
self._at_stream_start = True
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen.
"""Return the last event identifier that was seen."""
`None` until the stream sends an `id` field, and `""` after an empty
one, which resets the reconnect cursor.
"""
return self._last_event_id
return self._last_event_id or None
def decode(self, line: bytes) -> StreamPart | None:
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
if self._at_stream_start:
self._at_stream_start = False
line = line.removeprefix(b"\xef\xbb\xbf")
if not line:
if (
not self._event
@@ -112,7 +103,7 @@ class SSEDecoder:
sse = StreamPart(
event=self._event,
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
id=self._last_event_id or None,
id=self.last_event_id,
)
# NOTE: as per the SSE spec, do not reset last_event_id.
@@ -133,8 +124,6 @@ class SSEDecoder:
if fieldname == b"event":
self._event = value.decode()
elif fieldname == b"data":
if self._data:
self._data.extend(b"\n")
self._data.extend(value)
elif fieldname == b"id":
if b"\0" in value:
@@ -1,46 +0,0 @@
"""Thread-stream failures, shared by the async and sync clients."""
from __future__ import annotations
from typing import Any
import httpx
from websockets.exceptions import ConnectionClosed, InvalidStatus
def protocol_error(response: dict[str, Any]) -> RuntimeError:
"""Build the error for a protocol `{"type": "error"}` envelope."""
code = response.get("error", "unknown")
message = response.get("message", "")
return RuntimeError(f"Protocol error [{code}]: {message}")
def stream_error_frame(event: Any) -> RuntimeError | None:
"""Return the error in an unsolicited `{"type": "error", "id": null}` frame.
The server sends one just before it closes a thread stream whose read failed.
"""
if (
isinstance(event, dict)
and event.get("type") == "error"
and event.get("id") is None
):
return protocol_error(event)
return None
def is_non_retryable_stream_error(error: BaseException | None) -> bool:
"""Whether reopening the stream would fail the same way.
True for a 4xx other than 408 and 429: the status of a refused stream request
or WebSocket upgrade, or a WebSocket close code of 4000 plus that status. A
server that rejects a WebSocket after accepting it closes with such a code,
because a browser can't read the status of a refused upgrade.
"""
if isinstance(error, (httpx.HTTPStatusError, InvalidStatus)):
status = error.response.status_code
elif isinstance(error, ConnectionClosed) and error.rcvd is not None:
status = error.rcvd.code - 4000
else:
return False
return 400 <= status < 500 and status not in (408, 429)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more