From 658541c4960f329864a2523fc7d52427e8190bed Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Wed, 5 Aug 2026 21:23:28 -0400 Subject: [PATCH 1/5] chore(checkpoint-postgres,checkpoint-sqlite): enable PLC0415 lint rule (#8540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review on #8537: turn on ruff's `PLC0415` (`import-outside-top-level`) so deferred imports in tests stop accumulating. Scoped to `checkpoint-postgres` and `checkpoint-sqlite` rather than repo-wide, because the sweep turns up three different things and only one of them is a style problem. ### What the rule finds today ``` package tests src files checkpoint 13 10 11 checkpoint-conformance 0 10 4 checkpoint-postgres 6 0 2 checkpoint-sqlite 9 0 3 langgraph 130 23 32 prebuilt 14 3 7 cli 9 14 10 sdk-py 189 23 38 ──────────────────── 370 83 107 ``` 453 violations across 107 files, and ruff has no autofix for this rule. ### Three categories, not one **Style — hoist.** `checkpoint-sqlite/tests/test_store.py` deferred `math`, `random`, `time`, `Counter` and `defaultdict` inside methods for no reason. **Deliberate — keep, annotate.** `checkpoint-postgres/tests/test_async.py` defers behind `pytest.importorskip("langgraph.channels.delta")` because langgraph core is *not* a test dependency of that package. Hoisting would break the skip. Those get `# noqa: PLC0415` and a comment. **Redundant guard — hoist.** `checkpoint-sqlite/tests/test_conformance_delta.py` deferred imports only to get past its own `importorskip`. Imports move up; the `aiosqlite` guard stays, since that dependency genuinely can be absent. The second category is why I did not enable this everywhere in one go. Most of the 83 source-level violations look like the same pattern — optional-dependency handling and circular-import avoidance in `jsonplus.py`, `embed.py`, `encrypted.py` and friends. Blanket-enabling would mean `# noqa` on a lot of correct code, and each one wants an owner's eye rather than a mechanical pass. These two packages are clean to enforce today because both have **zero** source-level violations. ### Suggested rollout for the rest Either extend package by package as owners confirm which deferrals are intentional, or enable everywhere at once with `per-file-ignores` grandfathering the current 107 files so new code is blocked immediately and the debt burns down. Happy to do either — the second is a smaller diff but leaves a long ignore list. ### Verified `checkpoint-sqlite` 118 passed, `checkpoint-postgres` 264 passed on PG 15 and 16, `make lint` clean in both. One overlap worth flagging: `checkpoint-sqlite/tests/test_conformance_delta.py` is also touched by #8537. The change is identical in both, so it should merge cleanly either way. --- libs/checkpoint-postgres/pyproject.toml | 1 + libs/checkpoint-postgres/tests/test_async.py | 14 ++++++++------ libs/checkpoint-sqlite/pyproject.toml | 1 + .../tests/test_conformance_delta.py | 10 +++++----- libs/checkpoint-sqlite/tests/test_store.py | 13 ++++--------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml index 180166b86..864ba2c00 100644 --- a/libs/checkpoint-postgres/pyproject.toml +++ b/libs/checkpoint-postgres/pyproject.toml @@ -64,6 +64,7 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "PLC0415", # import-outside-top-level "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/checkpoint-postgres/tests/test_async.py b/libs/checkpoint-postgres/tests/test_async.py index fd42146cf..ec941da90 100644 --- a/libs/checkpoint-postgres/tests/test_async.py +++ b/libs/checkpoint-postgres/tests/test_async.py @@ -380,13 +380,15 @@ async def test_delta_channel_chain_reconstruction(saver_name: str) -> None: "langgraph.channels.delta", reason="langgraph core not installed" ) - from typing import Annotated + # Deferred on purpose: langgraph core is not a test dependency of this + # package, so these must stay behind the importorskip above. + from typing import Annotated # noqa: PLC0415 - from langchain_core.messages import AIMessage, HumanMessage - from langgraph.channels.delta import DeltaChannel - from langgraph.graph import START, StateGraph - from langgraph.graph.message import _messages_delta_reducer - from typing_extensions import TypedDict + from langchain_core.messages import AIMessage, HumanMessage # noqa: PLC0415 + from langgraph.channels.delta import DeltaChannel # noqa: PLC0415 + from langgraph.graph import START, StateGraph # noqa: PLC0415 + from langgraph.graph.message import _messages_delta_reducer # noqa: PLC0415 + from typing_extensions import TypedDict # noqa: PLC0415 class State(TypedDict): messages: Annotated[list, DeltaChannel(_messages_delta_reducer)] diff --git a/libs/checkpoint-sqlite/pyproject.toml b/libs/checkpoint-sqlite/pyproject.toml index c0a040655..a9d6c945f 100644 --- a/libs/checkpoint-sqlite/pyproject.toml +++ b/libs/checkpoint-sqlite/pyproject.toml @@ -62,6 +62,7 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "PLC0415", # import-outside-top-level "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/checkpoint-sqlite/tests/test_conformance_delta.py b/libs/checkpoint-sqlite/tests/test_conformance_delta.py index ba0e90f18..d900855d1 100644 --- a/libs/checkpoint-sqlite/tests/test_conformance_delta.py +++ b/libs/checkpoint-sqlite/tests/test_conformance_delta.py @@ -10,14 +10,14 @@ pytest.importorskip( ) pytest.importorskip("aiosqlite", reason="aiosqlite not installed") +from langgraph.checkpoint.conformance import validate # noqa: E402 +from langgraph.checkpoint.conformance.initializer import checkpointer_test # noqa: E402 + +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402 + @pytest.mark.asyncio async def test_delta_channel_conformance(): - from langgraph.checkpoint.conformance import validate - from langgraph.checkpoint.conformance.initializer import checkpointer_test - - from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver - @checkpointer_test(name="AsyncSqliteSaver") async def sqlite_saver(): async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: diff --git a/libs/checkpoint-sqlite/tests/test_store.py b/libs/checkpoint-sqlite/tests/test_store.py index e930208f1..d4b06847e 100644 --- a/libs/checkpoint-sqlite/tests/test_store.py +++ b/libs/checkpoint-sqlite/tests/test_store.py @@ -1,7 +1,11 @@ +import math import os +import random import re import tempfile +import time import uuid +from collections import Counter, defaultdict from collections.abc import Generator, Iterable from contextlib import contextmanager from typing import Any, Literal, cast @@ -33,10 +37,6 @@ class CharacterEmbeddings(Embeddings): def __init__(self, dims: int = 50, seed: int = 42): """Initialize with embedding dimensions and random seed.""" - import math - import random - from collections import defaultdict - self._rng = random.Random(seed) self.dims = dims # Create projection vector for each character lazily @@ -48,9 +48,6 @@ class CharacterEmbeddings(Embeddings): def _embed_one(self, text: str) -> list[float]: """Embed a single text.""" - import math - from collections import Counter - counts = Counter(text) total = sum(counts.values()) @@ -338,8 +335,6 @@ class TestSqliteStore: # Test update # Small delay to ensure the updated timestamp is different - import time - time.sleep(0.01) updated_value = {"title": "Updated Document", "content": "Hello, Updated!"} From f22af6248c93df08b553e9a264f6367797e0fddb Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Thu, 6 Aug 2026 17:38:31 -0400 Subject: [PATCH 2/5] chore: enable RUF100 and clear unused noqa directives (#8546) Follow-up to review on #8540, where a stale `# noqa: E402` slipped past me and Sydney spotted it by eye. This turns on the rule that catches that automatically. `RUF100` flags a `noqa` that suppresses nothing. `sdk-py` already had it through its blanket `RUF` selection; this adds it to the other seven packages and clears what it finds. ### The 33 it flags, all autofixed **Blanket `# noqa` on docstring-closing lines** (4, in `checkpoint-postgres` and `checkpoint-sqlite`). `E501` is in `lint.ignore` for those packages, so nothing was being suppressed: ```diff - """ # noqa + """ ``` **`# noqa: F821` on `anext(aiter_)`** (2). Left over from Python 3.9 support. `anext` became a builtin in 3.10, which is the floor now, so `F821` no longer fires: ```diff - anext(aiter_), # type: ignore[arg-type] # noqa: F821 + anext(aiter_), # type: ignore[arg-type] ``` **Suppressions naming rules the package does not enable** (27), across `langgraph`, `prebuilt` and `checkpoint-sqlite`: `FBT001`, `FBT002`, `TC002`, `BLE001`, `ANN001`, `ANN002`, `ANN003`, `E501`, `F401`. Mostly copied between packages whose rule sets differ. ### One measurement note If you check these numbers yourself, use `--extend-select`: ``` ruff check --select RUF100 . # 81, misleading ruff check --extend-select RUF100 . # 33, real ``` With a bare `--select`, ruff treats every other rule as disabled, so every suppression for another rule looks unused. I quoted 81 before catching that. ### Verified `checkpoint-sqlite` 118 passed, `prebuilt` 284 passed, `langgraph` 1968 passed, `checkpoint-postgres` 264 passed on PG 15 and 16. `make lint` clean in every package. Independent of #8540 and #8537, so it can land in any order. --- libs/checkpoint-conformance/pyproject.toml | 1 + .../langgraph/checkpoint/postgres/__init__.py | 2 +- .../langgraph/checkpoint/postgres/aio.py | 2 +- .../langgraph/checkpoint/postgres/shallow.py | 4 ++-- libs/checkpoint-postgres/pyproject.toml | 1 + .../langgraph/checkpoint/sqlite/__init__.py | 4 ++-- .../langgraph/checkpoint/sqlite/aio.py | 2 +- libs/checkpoint-sqlite/pyproject.toml | 1 + .../tests/test_conformance_delta.py | 6 +++--- .../tests/test_delta_channel_migration.py | 12 ++++++------ .../tests/test_get_delta_channel_history.py | 12 ++++++------ libs/checkpoint/pyproject.toml | 1 + libs/cli/pyproject.toml | 1 + libs/langgraph/langgraph/_internal/_pydantic.py | 4 ++-- libs/langgraph/langgraph/_internal/_serde.py | 2 +- libs/langgraph/langgraph/errors.py | 2 +- libs/langgraph/langgraph/graph/state.py | 2 +- libs/langgraph/pyproject.toml | 2 +- libs/langgraph/tests/test_config_async.py | 8 ++++---- libs/prebuilt/langgraph/prebuilt/tool_node.py | 8 ++++---- libs/prebuilt/pyproject.toml | 2 +- libs/prebuilt/tests/test_on_tool_call.py | 2 +- 22 files changed, 43 insertions(+), 38 deletions(-) diff --git a/libs/checkpoint-conformance/pyproject.toml b/libs/checkpoint-conformance/pyproject.toml index 2d9a0701f..1a57cec5b 100644 --- a/libs/checkpoint-conformance/pyproject.toml +++ b/libs/checkpoint-conformance/pyproject.toml @@ -58,6 +58,7 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "RUF100", # unused noqa directive ] lint.ignore = ["E501", "B008"] target-version = "py310" diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index 18186d89b..d519fa772 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -223,7 +223,7 @@ class PostgresSaver(BasePostgresSaver): >>> checkpoint_tuple = memory.get_tuple(config) >>> print(checkpoint_tuple) CheckpointTuple(...) - """ # noqa + """ thread_id = config["configurable"]["thread_id"] checkpoint_id = get_checkpoint_id(config) checkpoint_ns = config["configurable"].get("checkpoint_ns", "") diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index 09fb964d5..b02e0b164 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -573,7 +573,7 @@ class AsyncPostgresSaver(BasePostgresSaver): while True: try: yield asyncio.run_coroutine_threadsafe( - anext(aiter_), # type: ignore[arg-type] # noqa: F821 + anext(aiter_), # type: ignore[arg-type] self.loop, ).result() except StopAsyncIteration: diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py index 90fc95e74..350b20bb5 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py @@ -334,7 +334,7 @@ class ShallowPostgresSaver(BasePostgresSaver): >>> checkpoint_tuple = memory.get_tuple(config) >>> print(checkpoint_tuple) CheckpointTuple(...) - """ # noqa + """ thread_id = config["configurable"]["thread_id"] checkpoint_ns = config["configurable"].get("checkpoint_ns", "") args = (thread_id, checkpoint_ns) @@ -885,7 +885,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver): while True: try: yield asyncio.run_coroutine_threadsafe( - anext(aiter_), # type: ignore[arg-type] # noqa: F821 + anext(aiter_), # type: ignore[arg-type] self.loop, ).result() except StopAsyncIteration: diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml index 864ba2c00..964882621 100644 --- a/libs/checkpoint-postgres/pyproject.toml +++ b/libs/checkpoint-postgres/pyproject.toml @@ -65,6 +65,7 @@ lint.select = [ "B", # flake8-bugbear "I", # isort "PLC0415", # import-outside-top-level + "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py index 6ca2448d0..3259ff150 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py @@ -77,7 +77,7 @@ class SqliteSaver(BaseCheckpointSaver[str]): >>> result = graph.invoke(3, config) >>> graph.get_state(config) StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None) - """ # noqa + """ conn: sqlite3.Connection is_setup: bool @@ -222,7 +222,7 @@ class SqliteSaver(BaseCheckpointSaver[str]): >>> checkpoint_tuple = memory.get_tuple(config) >>> print(checkpoint_tuple) CheckpointTuple(...) - """ # noqa + """ checkpoint_ns = config["configurable"].get("checkpoint_ns", "") with self.cursor(transaction=False) as cur: # find the latest checkpoint for the thread_id diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py index 368428c68..1ad0777c1 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py @@ -212,7 +212,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]): while True: try: yield asyncio.run_coroutine_threadsafe( - anext(aiter_), # type: ignore[arg-type] # noqa: F821 + anext(aiter_), # type: ignore[arg-type] self.loop, ).result() except StopAsyncIteration: diff --git a/libs/checkpoint-sqlite/pyproject.toml b/libs/checkpoint-sqlite/pyproject.toml index a9d6c945f..cd3d5ac26 100644 --- a/libs/checkpoint-sqlite/pyproject.toml +++ b/libs/checkpoint-sqlite/pyproject.toml @@ -63,6 +63,7 @@ lint.select = [ "B", # flake8-bugbear "I", # isort "PLC0415", # import-outside-top-level + "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/checkpoint-sqlite/tests/test_conformance_delta.py b/libs/checkpoint-sqlite/tests/test_conformance_delta.py index d900855d1..6171e7440 100644 --- a/libs/checkpoint-sqlite/tests/test_conformance_delta.py +++ b/libs/checkpoint-sqlite/tests/test_conformance_delta.py @@ -10,10 +10,10 @@ pytest.importorskip( ) pytest.importorskip("aiosqlite", reason="aiosqlite not installed") -from langgraph.checkpoint.conformance import validate # noqa: E402 -from langgraph.checkpoint.conformance.initializer import checkpointer_test # noqa: E402 +from langgraph.checkpoint.conformance import validate +from langgraph.checkpoint.conformance.initializer import checkpointer_test -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402 +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver @pytest.mark.asyncio diff --git a/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py b/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py index c71f3b12c..f9f511c1e 100644 --- a/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py +++ b/libs/checkpoint-sqlite/tests/test_delta_channel_migration.py @@ -29,13 +29,13 @@ pytest.importorskip("langgraph.channels.delta", reason="langgraph core not insta pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed") pytest.importorskip("langgraph.graph", reason="langgraph core not installed") -from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001 -from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402 -from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402 -from typing_extensions import TypedDict # noqa: E402 +from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: I001 +from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] +from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] +from typing_extensions import TypedDict -from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402 -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402 +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver pytestmark = pytest.mark.anyio diff --git a/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py b/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py index 8e19c8b85..20c694223 100644 --- a/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py +++ b/libs/checkpoint-sqlite/tests/test_get_delta_channel_history.py @@ -32,13 +32,13 @@ from langchain_core.runnables import RunnableConfig pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed") pytest.importorskip("langgraph.graph", reason="langgraph core not installed") -from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402,I001 -from langgraph.checkpoint.serde.types import _DeltaSnapshot # noqa: E402 -from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402 -from typing_extensions import TypedDict # noqa: E402 +from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: I001 +from langgraph.checkpoint.serde.types import _DeltaSnapshot +from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] +from typing_extensions import TypedDict -from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402 -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402 +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver pytestmark = pytest.mark.anyio diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 8e03c391c..7ce16fbdc 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -59,6 +59,7 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 7012daa22..6a2cd94fa 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -72,6 +72,7 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] diff --git a/libs/langgraph/langgraph/_internal/_pydantic.py b/libs/langgraph/langgraph/_internal/_pydantic.py index 0d93f085e..33319d0ee 100644 --- a/libs/langgraph/langgraph/_internal/_pydantic.py +++ b/libs/langgraph/langgraph/_internal/_pydantic.py @@ -68,7 +68,7 @@ def _create_root_model( def schema( cls: type[BaseModel], - by_alias: bool = True, # noqa: FBT001,FBT002 + by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLATE, ) -> dict[str, Any]: # Complains about schema not being defined in superclass @@ -80,7 +80,7 @@ def _create_root_model( def model_json_schema( cls: type[BaseModel], - by_alias: bool = True, # noqa: FBT001,FBT002 + by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLATE, schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, mode: JsonSchemaMode = "validation", diff --git a/libs/langgraph/langgraph/_internal/_serde.py b/libs/langgraph/langgraph/_internal/_serde.py index 775242a87..933f76ec5 100644 --- a/libs/langgraph/langgraph/_internal/_serde.py +++ b/libs/langgraph/langgraph/_internal/_serde.py @@ -22,7 +22,7 @@ from pydantic import BaseModel from typing_extensions import NotRequired, Required, is_typeddict try: - from langgraph.checkpoint.serde._msgpack import ( # noqa: F401 + from langgraph.checkpoint.serde._msgpack import ( STRICT_MSGPACK_ENABLED, ) except ImportError: diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index 47d30de08..65b57d61e 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -7,7 +7,7 @@ from typing import Any, Literal from warnings import warn # EmptyChannelError is re-exported from langgraph.channels.base -from langgraph.checkpoint.base import EmptyChannelError # noqa: F401 +from langgraph.checkpoint.base import EmptyChannelError from typing_extensions import deprecated from langgraph.types import Command, Interrupt diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index deb0e9e94..ca9c5be9e 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -995,7 +995,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]): Without type hints on the `path` function's return value (e.g., `-> Literal["foo", "__end__"]:`) or a path_map, the graph visualization assumes the edge could transition to any node in the graph. - """ # noqa: E501 + """ if self.compiled: logger.warning( "Adding an edge to a graph that has already been compiled. This will " diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index fba4ddeed..b579c7ebd 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -89,7 +89,7 @@ langgraph-sdk = { path = "../sdk-py", editable = true } langgraph-cli = { path = "../cli", editable = true } [tool.ruff] -lint.select = [ "E", "F", "I", "TID251", "UP" ] +lint.select = [ "E", "F", "I", "RUF100", "TID251", "UP" ] lint.ignore = [ "E501" ] line-length = 88 indent-width = 4 diff --git a/libs/langgraph/tests/test_config_async.py b/libs/langgraph/tests/test_config_async.py index 6ad51c5b2..13f922534 100644 --- a/libs/langgraph/tests/test_config_async.py +++ b/libs/langgraph/tests/test_config_async.py @@ -24,7 +24,7 @@ class _TrackingCallback(BaseCallbackHandler): def __init__(self) -> None: self.called = False - def on_chain_start(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + def on_chain_start(self, *args, **kwargs) -> None: self.called = True @@ -55,7 +55,7 @@ async def test_with_config_configurable_preserved_on_invoke() -> None: builder = StateGraph(dict) captured: dict = {} - def node(state, config): # noqa: ANN001 + def node(state, config): captured.update(config.get("configurable") or {}) return state @@ -79,7 +79,7 @@ async def test_with_config_metadata_preserved_on_invoke() -> None: builder = StateGraph(dict) captured: dict = {} - def node(state, config): # noqa: ANN001 + def node(state, config): captured.update(config.get("metadata") or {}) return state @@ -104,7 +104,7 @@ async def test_with_config_tags_preserved_on_invoke() -> None: builder = StateGraph(dict) captured: list = [] - def node(state, config): # noqa: ANN001 + def node(state, config): captured.extend(config.get("tags") or []) return state diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 25d2425f9..95e161b90 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -87,8 +87,8 @@ from langgraph._internal._runnable import RunnableCallable from langgraph.errors import GraphBubbleUp from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.pregel._tools import _tool_call_writer -from langgraph.runtime import ExecutionInfo, ServerInfo # noqa: TC002 -from langgraph.store.base import BaseStore # noqa: TC002 +from langgraph.runtime import ExecutionInfo, ServerInfo +from langgraph.store.base import BaseStore from langgraph.types import Command, Send, StreamWriter from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar, Unpack @@ -332,7 +332,7 @@ def msg_content_output(output: Any) -> str | list[dict]: # any existing ToolNode usage. try: return json.dumps(output, ensure_ascii=False) - except Exception: # noqa: BLE001 + except Exception: return str(output) @@ -736,7 +736,7 @@ class ToolNode(RunnableCallable): tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors) ``` - """ # noqa: E501 + """ name: str = "tools" diff --git a/libs/prebuilt/pyproject.toml b/libs/prebuilt/pyproject.toml index b7a100db2..97558ae6a 100644 --- a/libs/prebuilt/pyproject.toml +++ b/libs/prebuilt/pyproject.toml @@ -75,7 +75,7 @@ addopts = "--strict-markers --strict-config --durations=5 -vv" asyncio_mode = "auto" [tool.ruff] -lint.select = [ "E", "F", "I", "TID251", "UP" ] +lint.select = [ "E", "F", "I", "RUF100", "TID251", "UP" ] lint.ignore = [ "E501" ] target-version = "py310" diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py index f1143c96b..987369f95 100644 --- a/libs/prebuilt/tests/test_on_tool_call.py +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -1338,7 +1338,7 @@ def _config_with_channel_read( # Shape matches pregel's real partial: # functools.partial(local_read, scratchpad, channels, managed, task) - def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001 + def _read(scratchpad, channels, managed, task, select, fresh): if isinstance(select, str): return channel_values[select] return {k: channel_values[k] for k in select if k in channel_values} From d569e18f4bd78b7652cb88c84b8dd098057e4f7d Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Fri, 7 Aug 2026 09:07:04 -0400 Subject: [PATCH 3/5] fix(checkpoint-postgres): find plain-value seeds when walking delta history (#8535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes langchain-ai/langgraph#8534 `put` splits stored values in two: primitives stay inline in the checkpoint's `channel_values`, everything else moves to `checkpoint_blobs`, and only `_DeltaSnapshot` leaves an inline marker behind when it moves. Stage-1 seed detection tested for that marker, so a plain value — what a thread migrated from `BinaryOperatorAggregate` leaves behind — was invisible to the walk. ### Effect Migrated threads found no seed, walked to the root, and replayed every write on every read. Values still came out correct, because replaying an additive reducer from empty rebuilds the same list, which is why nothing looked wrong. What was lost is early termination — the entire point of `DeltaChannel`: | thread length | writes replayed, before | after | | -- | -- | -- | | 2 turns | 3 | 1 | | 6 turns | 7 | 1 | | 20 turns | 21 | 1 | Read latency is flat at \~0.6ms across all three after the change. ### Approach Stage 1 now checks both places a value can live rather than trusting the marker. It probes `checkpoint_blobs`: ```sql EXISTS (SELECT 1 FROM checkpoint_blobs b0 WHERE b0.thread_id = checkpoints.thread_id AND b0.checkpoint_ns = checkpoints.checkpoint_ns AND b0.channel = %s AND b0.version = checkpoint -> 'channel_versions' ->> %s AND b0.type <> 'empty') AS hb_0 ``` and selects the inline value alongside it, since `None`, `str`, `int`, `float` and `bool` stay in `channel_values` with no blob row: ```sql checkpoint -> 'channel_values' -> %s AS inline_0 ``` The blob predicate matches `checkpoint_blobs`' primary key `(thread_id, checkpoint_ns, channel, version)` exactly, so it is one index lookup per row per channel, bounded by the 1024-row page. I picked reading storage over the cheaper alternative — also writing the marker for plain values — because **that would not fix any thread already on disk.** Existing checkpoints have no marker and there is nowhere to add one retroactively. The seed resolves to the blob when one exists and the inline value otherwise. That ordering is also what keeps a genuine inline `true` — a `bool` channel holding `True` — distinguishable from the literal `true` marker `put` inlines for a `_DeltaSnapshot`: only the snapshot has a blob. `None` is deliberately not treated as a seed; a JSON null is indistinguishable from "nothing stored" at this layer, so the walk continues and replay from empty is correct. Params go from two to four per channel; both callers updated. The inline half came out of review on this PR — a blob-only probe would have left scalar-aggregate migrations (an integer sum, say) still replaying their full history. ### On the `type <> 'empty'` predicate Being upfront since it isn't demonstrable with a test: `put` does not currently produce `empty` rows on this path — `blob_versions` is filtered to keys present in `channel_values`, so `_dump_blobs`' empty branch is unreachable from it. I confirmed there are no `empty` rows in a populated test database. I kept it because stage 2 already applies the same check when resolving the seed blob. Without it the two stages could disagree: stage 1 terminates the walk on a row stage 2 then discards, producing no seed *and* a truncated write chain — the same failure shape this function exists to avoid. Rationale is in the docstring so the next reader doesn't have to ask. Happy to drop it if you'd rather not carry an unexercised predicate. ### Tests `libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py` — blob-stored plain-value seed, `_DeltaSnapshot` seed, a version bump with nothing stored (which must not stop the walk short of an older real value), inline primitives (`int`, `str`, `float`, `None`), and inline `True` versus the snapshot marker. Each fails against the behaviour it fixes. Verified: postgres suite 269 passed on PG 15 and 16; delta-channel conformance against `AsyncPostgresSaver` went from 6 of 8 to 8 of 8, including the pre-existing `test_history_migration_plain_value_as_seed` failure this was causing; `make lint` clean. ### Not included I wanted a Postgres conformance runner alongside `checkpoint-sqlite`'s, but it needs `langgraph-checkpoint-conformance` as a dev dependency and the contributing guide asks for maintainer sign-off before adding one. The direct tests above cover the same ground without it. Worth flagging separately: **conformance effectively runs against** `InMemorySaver` **only today.** `libs/checkpoint-conformance/tests/` contains just `test_validate_memory.py`, and `checkpoint-sqlite`'s `test_conformance_delta.py` silently skips because the package isn't installed in its test environment (`importorskip`). Wiring it up for sqlite and postgres is what would have caught this bug, and langchain-ai/langgraph#8534 notes it. Sqlite is unaffected by the bug itself — it stores `channel_values` inline and inspects them directly. `langgraph-api` already resolves seeds by version rather than by marker. --- libs/checkpoint-postgres/README.md | 40 +--- .../langgraph/checkpoint/postgres/__init__.py | 15 +- .../langgraph/checkpoint/postgres/aio.py | 15 +- .../langgraph/checkpoint/postgres/base.py | 112 ++++++++-- .../tests/test_delta_plain_value_seed.py | 204 ++++++++++++++++++ 5 files changed, 325 insertions(+), 61 deletions(-) create mode 100644 libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py diff --git a/libs/checkpoint-postgres/README.md b/libs/checkpoint-postgres/README.md index f3ccc0da2..048d11882 100644 --- a/libs/checkpoint-postgres/README.md +++ b/libs/checkpoint-postgres/README.md @@ -67,24 +67,12 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer: "v": 4, "ts": "2024-07-31T20:14:19.804150+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", - "channel_values": { - "my_key": "meow", - "node": "node" - }, - "channel_versions": { - "__start__": 2, - "my_key": 3, - "start:node": 3, - "node": 3 - }, + "channel_values": {"my_key": "meow", "node": "node"}, + "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3}, "versions_seen": { "__input__": {}, - "__start__": { - "__start__": 1 - }, - "node": { - "start:node": 2 - } + "__start__": {"__start__": 1}, + "node": {"start:node": 2}, }, } @@ -108,24 +96,12 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer: "v": 4, "ts": "2024-07-31T20:14:19.804150+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", - "channel_values": { - "my_key": "meow", - "node": "node" - }, - "channel_versions": { - "__start__": 2, - "my_key": 3, - "start:node": 3, - "node": 3 - }, + "channel_values": {"my_key": "meow", "node": "node"}, + "channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3}, "versions_seen": { "__input__": {}, - "__start__": { - "__start__": 1 - }, - "node": { - "start:node": 2 - } + "__start__": {"__start__": 1}, + "node": {"start:node": 2}, }, } diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index d519fa772..c2a8ea985 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -478,9 +478,11 @@ class PostgresSaver(BasePostgresSaver): 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] - hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels] + hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels] + inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels] chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels} seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels} + seed_inline_by_ch: dict[str, Any] = {} walk_cursor_by_ch: dict[str, str | None] = {} seeded: set[str] = set() cursor: str | None = None @@ -489,7 +491,8 @@ class PostgresSaver(BasePostgresSaver): while True: stage1_params: list[Any] = [] for ch in channels: - stage1_params.extend([ch, ch]) + # ver_i, blob channel, blob version, inline_i + stage1_params.extend([ch, ch, ch, ch]) stage1_params.extend( [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE] ) @@ -502,16 +505,19 @@ class PostgresSaver(BasePostgresSaver): channels, parent_of, ver_by_i_by_cid, - hs_by_i_by_cid, + hb_by_i_by_cid, + inline_by_i_by_cid, ) self._try_advance_walks( checkpoint_id, channels, parent_of, ver_by_i_by_cid, - hs_by_i_by_cid, + hb_by_i_by_cid, + inline_by_i_by_cid, chain_by_ch, seed_ver_by_ch, + seed_inline_by_ch, walk_cursor_by_ch, seeded, ) @@ -546,6 +552,7 @@ class PostgresSaver(BasePostgresSaver): channels=channels, chain_by_ch=chain_by_ch, seed_ver_by_ch=seed_ver_by_ch, + seed_inline_by_ch=seed_inline_by_ch, stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows), ) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index b02e0b164..ab48f6670 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -426,9 +426,11 @@ class AsyncPostgresSaver(BasePostgresSaver): 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] - hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels] + hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels] + inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels] chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels} seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels} + seed_inline_by_ch: dict[str, Any] = {} walk_cursor_by_ch: dict[str, str | None] = {} seeded: set[str] = set() cursor: str | None = None @@ -437,7 +439,8 @@ class AsyncPostgresSaver(BasePostgresSaver): while True: stage1_params: list[Any] = [] for ch in channels: - stage1_params.extend([ch, ch]) + # ver_i, blob channel, blob version, inline_i + stage1_params.extend([ch, ch, ch, ch]) stage1_params.extend( [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE] ) @@ -450,16 +453,19 @@ class AsyncPostgresSaver(BasePostgresSaver): channels, parent_of, ver_by_i_by_cid, - hs_by_i_by_cid, + hb_by_i_by_cid, + inline_by_i_by_cid, ) self._try_advance_walks( checkpoint_id, channels, parent_of, ver_by_i_by_cid, - hs_by_i_by_cid, + hb_by_i_by_cid, + inline_by_i_by_cid, chain_by_ch, seed_ver_by_ch, + seed_inline_by_ch, walk_cursor_by_ch, seeded, ) @@ -490,6 +496,7 @@ class AsyncPostgresSaver(BasePostgresSaver): channels=channels, chain_by_ch=chain_by_ch, seed_ver_by_ch=seed_ver_by_ch, + seed_inline_by_ch=seed_inline_by_ch, stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows), ) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py index beb1e9972..d58d2cc38 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -199,27 +199,68 @@ class _DeltaStage2Row(TypedDict, total=False): def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str: - """Build stage 1 SQL with 2K parallel JSONB key lookups. + """Build stage 1 SQL with K parallel version lookups + seed probes. For channels=["messages", "files"] (with `paged=True`) the result is:: SELECT checkpoint_id, parent_checkpoint_id, checkpoint -> 'channel_versions' ->> %s AS ver_0, - (checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_0, + EXISTS (SELECT 1 FROM checkpoint_blobs b0 + WHERE b0.thread_id = checkpoints.thread_id + AND b0.checkpoint_ns = checkpoints.checkpoint_ns + AND b0.channel = %s + AND b0.version = checkpoint -> 'channel_versions' ->> %s + AND b0.type <> 'empty') AS hb_0, + checkpoint -> 'channel_values' -> %s AS inline_0, checkpoint -> 'channel_versions' ->> %s AS ver_1, - (checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_1 + EXISTS (...) AS hb_1, + checkpoint -> 'channel_values' -> %s AS inline_1 FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = %s AND (%s::text IS NULL OR checkpoint_id < %s) ORDER BY checkpoint_id DESC LIMIT %s - Channel names are passed as `%s` parameters (safe from SQL injection). - Only the column aliases `ver_i` / `hs_i` are interpolated into the - SQL string (i is bounded by len(channels) and uses safe identifiers). + A stored value for a channel lives in one of two places, because `put` + splits them: - Caller must extend params with `[ch_0, ch_0, ch_1, ch_1, ..., - thread_id, ns, cursor, cursor, page_size]` when `paged=True`. + * **blob** — non-primitive values (and `_DeltaSnapshot`) are moved to + `checkpoint_blobs`. `hb_i` ("has blob") probes for one. The probe hits + that table's primary key `(thread_id, checkpoint_ns, channel, version)` + exactly, so it is an index lookup per row per channel. + * **inline** — `None`, `str`, `int`, `float` and `bool` stay in the + checkpoint's own `channel_values` and get no blob row at all. `inline_i` + returns that value. + + Testing only for a key in `channel_values` (the previous approach) missed + blob-stored plain values, since `put` leaves an inline marker there for + `_DeltaSnapshot` but not for a plain value — which is what a thread + migrated from a pre-delta channel type leaves behind. Probing only the + blobs table would conversely miss inline primitives. Both are needed, and + the caller treats "either present" as the seed. + + `hb_i` also disambiguates the two: for a `_DeltaSnapshot`, `inline_i` is the + literal `true` marker rather than the value, so a blob must win over an + inline reading whenever one exists. That ordering is what makes a genuine + inline `true` (a bool channel) distinguishable from the marker. + + The `type <> 'empty'` predicate mirrors the check stage 2 already applies + when resolving the seed blob. `put` does not currently produce `empty` rows + on this path — `blob_versions` is filtered to keys present in + `channel_values`, so `_dump_blobs`' empty branch is unreachable from it — + but without the predicate the two stages could disagree: stage 1 would + terminate the walk on a row stage 2 then discards, yielding no seed *and* a + truncated write chain, which is the failure this function exists to avoid. + + Channel names are passed as `%s` parameters (safe from SQL injection). + Only the column aliases `ver_i` / `hb_i` / `inline_i` and the subquery alias + `b{i}` are interpolated into the SQL string (i is bounded by len(channels) + and uses safe identifiers). + + Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns, + 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. @@ -228,7 +269,13 @@ def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str: for i in range(len(channels)): cols.append( f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, " - f"(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_{i}" + f"EXISTS (SELECT 1 FROM checkpoint_blobs b{i} " + f"WHERE b{i}.thread_id = checkpoints.thread_id " + f"AND b{i}.checkpoint_ns = checkpoints.checkpoint_ns " + f"AND b{i}.channel = %s " + f"AND b{i}.version = checkpoint -> 'channel_versions' ->> %s " + f"AND b{i}.type <> 'empty') AS hb_{i}, " + f"checkpoint -> 'channel_values' -> %s AS inline_{i}" ) sql = ( "SELECT checkpoint_id, parent_checkpoint_id, " @@ -342,7 +389,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): channels: Sequence[str], parent_of: dict[str, str | None], ver_by_i_by_cid: list[dict[str, str | None]], - hs_by_i_by_cid: list[dict[str, bool]], + hb_by_i_by_cid: list[dict[str, bool]], + inline_by_i_by_cid: list[dict[str, Any]], ) -> str | None: """Fold one stage-1 page into the running walk-state mappings. @@ -356,7 +404,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): parent_of[cid] = cast("str | None", r["parent_checkpoint_id"]) for i in range(len(channels)): ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}")) - hs_by_i_by_cid[i][cid] = bool(r.get(f"hs_{i}")) + hb_by_i_by_cid[i][cid] = bool(r.get(f"hb_{i}")) + inline_by_i_by_cid[i][cid] = r.get(f"inline_{i}") # Rows are DESC; the last one is the smallest cid in the page. oldest = cid return oldest @@ -367,9 +416,11 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): channels: Sequence[str], parent_of: Mapping[str, str | None], ver_by_i_by_cid: Sequence[Mapping[str, str | None]], - hs_by_i_by_cid: Sequence[Mapping[str, bool]], + hb_by_i_by_cid: Sequence[Mapping[str, bool]], + inline_by_i_by_cid: Sequence[Mapping[str, Any]], chain_by_ch: dict[str, list[str]], seed_ver_by_ch: dict[str, str | None], + seed_inline_by_ch: dict[str, Any], walk_cursor_by_ch: dict[str, str | None], seeded: set[str], ) -> None: @@ -377,14 +428,15 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): Uses the partial `parent_of` map accumulated so far. A walk stops either because: - (a) it found a snapshot for its channel (channel becomes seeded), + (a) it found a stored value for its channel — a blob or an inline + primitive (channel becomes seeded), (b) it reached a real root (parent_of[cid] is None — fully materialized at this point), or (c) the next ancestor cid isn't in `parent_of` yet (waiting for a later page; the cursor stays put). - Mutates `chain_by_ch`, `seed_ver_by_ch`, `walk_cursor_by_ch`, and - `seeded` in place. + Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`, + `walk_cursor_by_ch`, and `seeded` in place. """ for i, ch in enumerate(channels): if ch in seeded: @@ -394,15 +446,22 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): walk_cursor_by_ch[ch] = parent_of.get(target_id) cur_cid = walk_cursor_by_ch[ch] ch_chain = chain_by_ch[ch] - hs_i = hs_by_i_by_cid[i] + hb_i = hb_by_i_by_cid[i] + inline_i = inline_by_i_by_cid[i] ver_i = ver_by_i_by_cid[i] while cur_cid is not None: if cur_cid not in parent_of: # Need more pages to continue this walk. break ch_chain.append(cur_cid) - if hs_i.get(cur_cid, False): + has_blob = hb_i.get(cur_cid, False) + inline = inline_i.get(cur_cid) + if has_blob or inline is not None: + # A blob wins: for a `_DeltaSnapshot` the inline reading is + # the `true` marker, not the value. seed_ver_by_ch[ch] = ver_i.get(cur_cid) + if not has_blob: + seed_inline_by_ch[ch] = inline seeded.add(ch) cur_cid = None break @@ -415,16 +474,23 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): channels: Sequence[str], chain_by_ch: Mapping[str, list[str]], seed_ver_by_ch: Mapping[str, str | None], + seed_inline_by_ch: Mapping[str, Any], stage2_rows: Sequence[_DeltaStage2Row], ) -> dict[str, DeltaChannelHistory]: """Demux stage 2 rows per channel; produce per-channel histories. stage2_rows carry `channel` on every row. We build per-channel `writes_by_cid` and per-channel `seed_blob` dicts, then assemble - a `DeltaChannelHistory` per requested channel. The `seed` key is omitted - when the walk reached root with no snapshot found, or when the - seed blob is sentinel "empty" — in both cases the consumer treats - absence as "start empty". + a `DeltaChannelHistory` per requested channel. + + A seed comes from the blobs table when the walk found one there, and + otherwise from `seed_inline_by_ch` — `put` keeps `None`, `str`, `int`, + `float` and `bool` values in the checkpoint's own `channel_values` with + no blob row, so those never appear in `stage2_rows`. + + The `seed` key is omitted when the walk reached root without finding a + stored value, or when the seed blob is sentinel "empty" — in both cases + the consumer treats absence as "start empty". """ # writes_by_ch_by_cid[channel][cid] = list of (type, blob, task_id, idx) writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = { @@ -473,6 +539,10 @@ class BasePostgresSaver(BaseCheckpointSaver[str]): blob = seed_blob_by_ver.get((ch, seed_version)) if blob is not None and blob[0] != "empty": entry["seed"] = self.serde.loads_typed(blob) + elif ch in seed_inline_by_ch: + # Inline primitive: stored in the checkpoint, not the blobs + # table, so stage 2 never returned a row for it. + entry["seed"] = seed_inline_by_ch[ch] result[ch] = entry return result diff --git a/libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py b/libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py new file mode 100644 index 000000000..f441f9160 --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py @@ -0,0 +1,204 @@ +"""Seed detection for `DeltaChannel` histories on Postgres. + +`put` splits stored values in two: primitives stay inline in the checkpoint's +`channel_values`, everything else moves to `checkpoint_blobs`. Only +`_DeltaSnapshot` leaves an inline marker behind when it moves, so the stage-1 +walk has to check both places — a blob probe alone misses inline primitives, and +an inline-key check alone missed blob-stored plain values, which is what a thread +migrated from a pre-delta channel type leaves behind. See #8534. +""" + +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import pytest +from langgraph.checkpoint.base import Checkpoint, empty_checkpoint +from langgraph.checkpoint.base.id import uuid6 +from langgraph.checkpoint.serde.types import _DeltaSnapshot + +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from tests.conftest import DEFAULT_URI + +CHANNEL = "items" + + +async def _build_chain(saver: AsyncPostgresSaver, seed_value: Any) -> tuple[str, dict]: + """Store `seed_value` at step 1, then two steps that store nothing. + + Every step carries a write so the walk has something to collect. + Returns `(thread_id, head_config)`. + """ + thread_id = str(uuid4()) + parent: dict | None = None + for step in range(4): + config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} + if parent is not None: + config["configurable"]["checkpoint_id"] = parent["configurable"][ + "checkpoint_id" + ] + cp: Checkpoint = empty_checkpoint() + cp["id"] = str(uuid6(clock_seq=step)) + new_versions: dict[str, Any] = {} + if step == 1: + cp["channel_values"][CHANNEL] = seed_value + cp["channel_versions"][CHANNEL] = "v1" + new_versions[CHANNEL] = "v1" + else: + cp["channel_versions"][CHANNEL] = f"v{step}" + parent = await saver.aput( + config, cp, {"source": "loop", "step": step, "parents": {}}, new_versions + ) + await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4())) + assert parent is not None + return thread_id, parent + + +@pytest.mark.asyncio +async def test_plain_value_seed_is_found() -> None: + """A pre-delta plain value must be located as the seed. + + Before #8534 the walk ran to the root and returned no seed, which happens + to reconstruct correctly for additive reducers while costing an + O(thread length) replay on every read. + """ + async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver: + await saver.setup() + _, head = await _build_chain(saver, [10, 20]) + + result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL]) + entry = result[CHANNEL] + + assert entry.get("seed") == [10, 20], ( + f"expected the plain value as seed, got {entry.get('seed', '')}" + ) + # Only the writes between the seed and the head's parent replay: step 1 + # (the seed's own) and step 2. Step 0 is older than the seed, step 3 is + # pending at the head. + assert [w[2] for w in entry["writes"]] == ["w1", "w2"] + + +@pytest.mark.asyncio +async def test_delta_snapshot_seed_is_found() -> None: + """The `_DeltaSnapshot` path keeps working, so both seed kinds agree.""" + async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver: + await saver.setup() + _, head = await _build_chain(saver, _DeltaSnapshot([10, 20])) + + result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL]) + entry = result[CHANNEL] + + seed = entry.get("seed") + assert isinstance(seed, _DeltaSnapshot), f"expected a snapshot, got {seed!r}" + assert seed.value == [10, 20] + assert [w[2] for w in entry["writes"]] == ["w1", "w2"] + + +@pytest.mark.asyncio +async def test_version_bump_without_a_value_does_not_hide_an_older_seed() -> None: + """A delta-era step bumps `channel_versions` without storing a value, so no + blob exists for that version. The probe must report no seed there and keep + walking rather than stopping at a version it cannot resolve. + + Step 0 holds the real value; step 1 bumps the version with nothing stored. + Walking back from the head has to pass step 1 to reach step 0. + """ + async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver: + await saver.setup() + thread_id = str(uuid4()) + parent: dict | None = None + for step in range(4): + config: dict = { + "configurable": {"thread_id": thread_id, "checkpoint_ns": ""} + } + if parent is not None: + config["configurable"]["checkpoint_id"] = parent["configurable"][ + "checkpoint_id" + ] + cp: Checkpoint = empty_checkpoint() + cp["id"] = str(uuid6(clock_seq=step)) + new_versions: dict[str, Any] = {} + if step == 0: + cp["channel_values"][CHANNEL] = [10, 20] + cp["channel_versions"][CHANNEL] = "v0" + new_versions[CHANNEL] = "v0" + elif step == 1: + # Version bumped, value absent -> no blob row written. + cp["channel_versions"][CHANNEL] = "v1" + new_versions[CHANNEL] = "v1" + else: + cp["channel_versions"][CHANNEL] = "v1" + parent = await saver.aput( + config, + cp, + {"source": "loop", "step": step, "parents": {}}, + new_versions, + ) + await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4())) + assert parent is not None + + result = await saver.aget_delta_channel_history( + config=parent, channels=[CHANNEL] + ) + entry = result[CHANNEL] + + assert entry.get("seed") == [10, 20], ( + "the walk stopped at the empty blob instead of reaching the real " + f"value at step 0; got {entry.get('seed', '')}" + ) + assert [w[2] for w in entry["writes"]] == ["w0", "w1", "w2"] + + +@pytest.mark.asyncio +async def test_inline_primitive_seed_is_found() -> None: + """`put` keeps `None`, `str`, `int`, `float` and `bool` in the checkpoint's + own `channel_values` with no blob row, so a blob probe alone cannot see + them. Stage 1 reads the inline value too and uses it when there is no blob. + """ + async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver: + await saver.setup() + for seed_value in (42, "x", 3.5, None): + _, head = await _build_chain(saver, seed_value) + entry = ( + await saver.aget_delta_channel_history(config=head, channels=[CHANNEL]) + )[CHANNEL] + if seed_value is None: + # A JSON null is indistinguishable from "no value stored", so + # the walk keeps going; replay from empty is the correct result. + assert "seed" not in entry + else: + assert entry.get("seed") == seed_value, ( + f"inline {type(seed_value).__name__} seed not found: " + f"{entry.get('seed', '')!r}" + ) + assert [w[2] for w in entry["writes"]] == ["w1", "w2"] + + +@pytest.mark.asyncio +async def test_inline_true_is_not_read_as_a_snapshot_marker() -> None: + """`put` inlines a literal `true` in `channel_values` as the marker for a + `_DeltaSnapshot`, which is also what a genuine `bool` channel holding + `True` looks like. A blob exists only in the snapshot case, so preferring + the blob keeps the two apart. + """ + async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver: + await saver.setup() + + _, head = await _build_chain(saver, True) + entry = ( + await saver.aget_delta_channel_history(config=head, channels=[CHANNEL]) + )[CHANNEL] + assert entry.get("seed") is True, ( + f"a real inline True must survive, got {entry.get('seed', '')!r}" + ) + + _, snap_head = await _build_chain(saver, _DeltaSnapshot(True)) + snap_entry = ( + await saver.aget_delta_channel_history(config=snap_head, channels=[CHANNEL]) + )[CHANNEL] + seed = snap_entry.get("seed") + assert isinstance(seed, _DeltaSnapshot), ( + f"the marker must resolve to the blob, not inline true; got {seed!r}" + ) + assert seed.value is True From 36a505ac65e956da09e93cc753609635a511047e Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Fri, 7 Aug 2026 09:39:20 -0400 Subject: [PATCH 4/5] test(checkpoint-postgres,checkpoint-sqlite): run the conformance suite (#8537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on #8535 `libs/checkpoint-conformance/tests/` only validates `InMemorySaver`. `checkpoint-sqlite` has had a `test_conformance_delta.py` for a while, but it guards on `importorskip("langgraph.checkpoint.conformance")` and the package was never in its test environment — so it has been skipping silently every run. `checkpoint-postgres` had no runner at all. Net effect: the shared checkpointer contract was effectively unenforced everywhere except in-memory. ### Change Adds `langgraph-checkpoint-conformance` to the `test` dependency group of both packages, with a path source like the existing `langgraph-checkpoint` entry. That alone is what makes sqlite's runner start executing. Postgres gets the equivalent runner. Both pass the `delta_channel_history` capability. ### Why it's stacked Against `main`'s Postgres, the new runner fails: ``` Capability delta_channel_history failed: test_history_migration_plain_value_as_seed ``` That is exactly the bug #8535 fixes, and it had been failing unnoticed precisely because nothing ran the suite there. So this is based on that branch rather than `main` — the diff here is the one conformance commit, and it will retarget once #8535 lands. Reasonable to read that as the change justifying itself: the first thing turning the suite on did was catch a real bug that had been sitting in `main`. ### Verified `checkpoint-postgres` 270 passed on PG 15 and 16, `checkpoint-sqlite` 118 passed, lint and `ty` clean in both. The `uv.lock` updates are the conformance package entry only. ### Note The sync `PostgresSaver` and `SqliteSaver` aren't covered — the conformance harness reports every capability as `detected=False` for them, so only the async savers are exercised. Pre-existing and not addressed here, but worth knowing the coverage isn't total. --- libs/checkpoint-postgres/pyproject.toml | 2 ++ .../tests/test_conformance_delta.py | 30 +++++++++++++++++ libs/checkpoint-postgres/uv.lock | 33 ++++++++++++++++++- libs/checkpoint-sqlite/pyproject.toml | 2 ++ .../tests/test_conformance_delta.py | 7 ---- libs/checkpoint-sqlite/uv.lock | 33 ++++++++++++++++++- libs/langgraph/uv.lock | 4 +++ libs/prebuilt/uv.lock | 6 +++- 8 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 libs/checkpoint-postgres/tests/test_conformance_delta.py diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml index 964882621..805892418 100644 --- a/libs/checkpoint-postgres/pyproject.toml +++ b/libs/checkpoint-postgres/pyproject.toml @@ -32,6 +32,7 @@ test = [ "pytest-mock", "psycopg[binary]", "langgraph-checkpoint", + "langgraph-checkpoint-conformance", "pytest-watcher", ] lint = [ @@ -49,6 +50,7 @@ default-groups = ['dev'] [tool.uv.sources] langgraph-checkpoint = { path = "../checkpoint", editable = true } +langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true } [tool.hatch.build.targets.wheel] include = ["langgraph"] diff --git a/libs/checkpoint-postgres/tests/test_conformance_delta.py b/libs/checkpoint-postgres/tests/test_conformance_delta.py new file mode 100644 index 000000000..b641d345e --- /dev/null +++ b/libs/checkpoint-postgres/tests/test_conformance_delta.py @@ -0,0 +1,30 @@ +"""Run delta-channel conformance capabilities against AsyncPostgresSaver.""" + +from __future__ import annotations + +import pytest +from langgraph.checkpoint.conformance import validate +from langgraph.checkpoint.conformance.initializer import checkpointer_test + +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from tests.conftest import DEFAULT_URI + + +@pytest.mark.asyncio +async def test_delta_channel_conformance(): + @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={ + "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}") diff --git a/libs/checkpoint-postgres/uv.lock b/libs/checkpoint-postgres/uv.lock index 839dc8bdf..88d051243 100644 --- a/libs/checkpoint-postgres/uv.lock +++ b/libs/checkpoint-postgres/uv.lock @@ -159,7 +159,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -322,6 +322,33 @@ test = [ { name = "redis" }, ] +[[package]] +name = "langgraph-checkpoint-conformance" +version = "0.0.2" +source = { editable = "../checkpoint-conformance" } +dependencies = [ + { name = "langgraph-checkpoint" }, +] + +[package.metadata] +requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [[package]] name = "langgraph-checkpoint-postgres" version = "3.1.1" @@ -338,6 +365,7 @@ dev = [ { name = "anyio" }, { name = "codespell" }, { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-conformance" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -354,6 +382,7 @@ lint = [ test = [ { name = "anyio" }, { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-conformance" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -374,6 +403,7 @@ dev = [ { name = "anyio" }, { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -390,6 +420,7 @@ lint = [ test = [ { name = "anyio" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, diff --git a/libs/checkpoint-sqlite/pyproject.toml b/libs/checkpoint-sqlite/pyproject.toml index cd3d5ac26..fe0f42b32 100644 --- a/libs/checkpoint-sqlite/pyproject.toml +++ b/libs/checkpoint-sqlite/pyproject.toml @@ -30,6 +30,7 @@ test = [ "pytest-mock", "pytest-watcher", "langgraph-checkpoint", + "langgraph-checkpoint-conformance", "pytest-retry>=1.7.0", ] lint = [ @@ -47,6 +48,7 @@ default-groups = ['dev'] [tool.uv.sources] langgraph-checkpoint = { path = "../checkpoint", editable = true } +langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true } [tool.hatch.build.targets.wheel] include = ["langgraph"] diff --git a/libs/checkpoint-sqlite/tests/test_conformance_delta.py b/libs/checkpoint-sqlite/tests/test_conformance_delta.py index 6171e7440..2e0b58dba 100644 --- a/libs/checkpoint-sqlite/tests/test_conformance_delta.py +++ b/libs/checkpoint-sqlite/tests/test_conformance_delta.py @@ -3,13 +3,6 @@ from __future__ import annotations import pytest - -pytest.importorskip( - "langgraph.checkpoint.conformance", - reason="langgraph-checkpoint-conformance not installed", -) -pytest.importorskip("aiosqlite", reason="aiosqlite not installed") - from langgraph.checkpoint.conformance import validate from langgraph.checkpoint.conformance.initializer import checkpointer_test diff --git a/libs/checkpoint-sqlite/uv.lock b/libs/checkpoint-sqlite/uv.lock index 4b8839aee..5db200b4f 100644 --- a/libs/checkpoint-sqlite/uv.lock +++ b/libs/checkpoint-sqlite/uv.lock @@ -168,7 +168,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -331,6 +331,33 @@ test = [ { name = "redis" }, ] +[[package]] +name = "langgraph-checkpoint-conformance" +version = "0.0.2" +source = { editable = "../checkpoint-conformance" } +dependencies = [ + { name = "langgraph-checkpoint" }, +] + +[package.metadata] +requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] +lint = [ + { name = "ruff" }, + { name = "ty" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [[package]] name = "langgraph-checkpoint-sqlite" version = "3.1.1" @@ -345,6 +372,7 @@ dependencies = [ dev = [ { name = "codespell" }, { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -360,6 +388,7 @@ lint = [ ] test = [ { name = "langgraph-checkpoint" }, + { name = "langgraph-checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -378,6 +407,7 @@ requires-dist = [ dev = [ { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -393,6 +423,7 @@ lint = [ ] test = [ { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index 67a74dd00..6a3fa4727 100644 --- a/libs/langgraph/uv.lock +++ b/libs/langgraph/uv.lock @@ -1692,6 +1692,7 @@ dev = [ { name = "anyio" }, { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1708,6 +1709,7 @@ lint = [ test = [ { name = "anyio" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1736,6 +1738,7 @@ requires-dist = [ dev = [ { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -1751,6 +1754,7 @@ lint = [ ] test = [ { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index 236e552e4..6bb7777c0 100644 --- a/libs/prebuilt/uv.lock +++ b/libs/prebuilt/uv.lock @@ -168,7 +168,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -439,6 +439,7 @@ dev = [ { name = "anyio" }, { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -455,6 +456,7 @@ lint = [ test = [ { name = "anyio" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -483,6 +485,7 @@ requires-dist = [ dev = [ { name = "codespell" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -498,6 +501,7 @@ lint = [ ] test = [ { name = "langgraph-checkpoint", editable = "../checkpoint" }, + { name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, From ea5f9cc9fb8c4b123769daab4753af34de29b1e9 Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Fri, 7 Aug 2026 09:40:18 -0400 Subject: [PATCH 5/5] chore: enforce PLC0415 in tests for the remaining packages (#8547) Follow-up to #8540, which turned on `PLC0415` (import-outside-top-level) for checkpoint-postgres and checkpoint-sqlite. This does the remaining six packages: checkpoint, checkpoint-conformance, langgraph, prebuilt, cli, sdk-py. Scoped to tests, per @sydney-runkle's call on #8540: library code is exempted with `per-file-ignores`, since it still has deferred imports nobody has reviewed and mixing that in would make this hard to read. ## What changed Function-level imports across 56 test files moved to module level. Nine could not move and carry an explicit `# noqa: PLC0415` with a reason: | File | Why it stays local | |---|---| | `libs/langgraph/tests/test_deprecation.py` (4) | the import has to run inside `pytest.warns` for the warning to be observed | | `libs/langgraph/tests/test_serde_allowlist.py` | try/except guard, skips when langchain_core is absent | | `libs/langgraph/tests/test_delta_channel_benchmark.py` | optional psycopg probe | | `libs/checkpoint/tests/test_conformance_delta.py` (3) | protected by a module-level `pytest.importorskip`; hoisting past the guard turns a skip into a collection error | That last one is the trap: an import moved above `pytest.importorskip` silently defeats the guard. I hit it locally and it turned the skip into a `ModuleNotFoundError` at collection. Every file with an `importorskip` or `except ImportError` was checked by hand for this. ## Verification `make lint` and `make test` in each of the six: | Package | Tests | |---|---| | checkpoint | 156 passed, 17 skipped | | checkpoint-conformance | 1 passed | | langgraph | 1968 passed, 4 skipped | | prebuilt | 284 passed | | cli | 336 passed | | sdk-py | 493 passed | Also confirmed the rule actually fires: a throwaway test file with a function-level import is flagged in all six packages, and the source exemption holds. --- libs/checkpoint-conformance/pyproject.toml | 5 + libs/checkpoint/pyproject.toml | 5 + .../tests/test_conformance_delta.py | 10 +- libs/checkpoint/tests/test_encrypted.py | 2 - libs/checkpoint/tests/test_jsonplus.py | 13 +-- libs/checkpoint/tests/test_memory.py | 2 +- libs/cli/pyproject.toml | 5 + libs/cli/tests/unit_tests/test_archive.py | 7 +- .../tests/unit_tests/test_deploy_helpers.py | 3 - libs/langgraph/pyproject.toml | 6 +- libs/langgraph/tests/memory_assert.py | 3 +- libs/langgraph/tests/test_channels.py | 11 +- .../tests/test_delta_channel_benchmark.py | 2 +- .../tests/test_delta_channel_update_state.py | 2 +- libs/langgraph/tests/test_deprecation.py | 8 +- libs/langgraph/tests/test_graph_callbacks.py | 2 +- libs/langgraph/tests/test_large_cases.py | 61 +++------- .../langgraph/tests/test_large_cases_async.py | 45 +++----- libs/langgraph/tests/test_pregel.py | 45 +++----- libs/langgraph/tests/test_pregel_async.py | 47 +++----- libs/langgraph/tests/test_pydantic.py | 7 +- libs/langgraph/tests/test_remote_graph.py | 8 +- libs/langgraph/tests/test_remote_graph_v3.py | 2 +- libs/langgraph/tests/test_retry.py | 8 +- libs/langgraph/tests/test_runtime.py | 5 +- libs/langgraph/tests/test_serde_allowlist.py | 2 +- .../tests/test_stream_data_transformers.py | 5 +- .../tests/test_stream_messages_transformer.py | 18 +-- libs/langgraph/tests/test_utils.py | 14 +-- libs/prebuilt/pyproject.toml | 6 +- libs/prebuilt/tests/memory_assert.py | 3 +- .../tests/test_injected_state_not_required.py | 7 +- libs/prebuilt/tests/test_on_tool_call.py | 2 +- libs/prebuilt/tests/test_tool_node.py | 14 +-- libs/sdk-py/pyproject.toml | 6 +- libs/sdk-py/tests/integration/conftest.py | 9 +- .../tests/integration/test_assistants.py | 9 +- libs/sdk-py/tests/integration/test_cancel.py | 9 +- libs/sdk-py/tests/integration/test_crons.py | 9 +- .../tests/integration/test_factory_graph.py | 9 +- libs/sdk-py/tests/integration/test_runs.py | 9 +- libs/sdk-py/tests/integration/test_store.py | 9 +- .../tests/integration/test_websocket.py | 9 +- .../sdk-py/tests/streaming/test_controller.py | 24 ++-- libs/sdk-py/tests/streaming/test_decoders.py | 3 +- .../streaming/test_extensions_projection.py | 3 +- .../tests/streaming/test_lifecycle_watcher.py | 4 +- .../tests/streaming/test_scoped_handles.py | 14 +-- .../tests/streaming/test_shared_stream.py | 14 +-- .../test_sync_extensions_projection.py | 3 +- .../tests/streaming/test_sync_projections.py | 10 +- .../streaming/test_sync_scoped_handles.py | 4 +- .../streaming/test_sync_thread_stream.py | 109 ++++-------------- .../tests/streaming/test_sync_transport_ws.py | 5 +- .../tests/streaming/test_thread_stream.py | 73 ++---------- .../streaming/test_tool_calls_projection.py | 8 +- .../tests/streaming/test_transport_http.py | 52 ++------- .../tests/streaming/test_transport_ws.py | 6 +- libs/sdk-py/tests/test_client_stream.py | 4 +- libs/sdk-py/tests/test_langsmith_tracing.py | 7 +- libs/sdk-py/tests/test_path_encoding.py | 3 +- libs/sdk-py/tests/test_serde.py | 2 +- 62 files changed, 274 insertions(+), 537 deletions(-) diff --git a/libs/checkpoint-conformance/pyproject.toml b/libs/checkpoint-conformance/pyproject.toml index 1a57cec5b..4278393cc 100644 --- a/libs/checkpoint-conformance/pyproject.toml +++ b/libs/checkpoint-conformance/pyproject.toml @@ -58,9 +58,14 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "PLC0415", # import-outside-top-level "RUF100", # unused noqa directive ] lint.ignore = ["E501", "B008"] +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] } target-version = "py310" [tool.uv.sources] diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 7ce16fbdc..6a22a8ccc 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -59,10 +59,15 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "PLC0415", # import-outside-top-level "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] } target-version = "py310" [tool.ty.rules] diff --git a/libs/checkpoint/tests/test_conformance_delta.py b/libs/checkpoint/tests/test_conformance_delta.py index 82a52a93e..5443601cf 100644 --- a/libs/checkpoint/tests/test_conformance_delta.py +++ b/libs/checkpoint/tests/test_conformance_delta.py @@ -12,10 +12,14 @@ conformance = pytest.importorskip( @pytest.mark.asyncio async def test_delta_channel_conformance(): - from langgraph.checkpoint.conformance import validate - from langgraph.checkpoint.conformance.initializer import checkpointer_test + # 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 + from langgraph.checkpoint.memory import InMemorySaver # noqa: PLC0415 @checkpointer_test(name="InMemorySaver") async def mem_saver(): diff --git a/libs/checkpoint/tests/test_encrypted.py b/libs/checkpoint/tests/test_encrypted.py index f1fc14e94..43f342485 100644 --- a/libs/checkpoint/tests/test_encrypted.py +++ b/libs/checkpoint/tests/test_encrypted.py @@ -307,8 +307,6 @@ class TestWithMsgpackAllowlistEncrypted: def loads_typed(self, data: tuple[str, bytes]) -> None: return None - from langgraph.checkpoint.serde.base import CipherProtocol - class DummyCipher(CipherProtocol): def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: return "dummy", plaintext diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index b0999b0ed..c7e7cc995 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -1,9 +1,12 @@ import dataclasses import json import logging +import os import pathlib +import pickle import re import sys +import tempfile import uuid from collections import deque from datetime import date, datetime, time, timezone @@ -18,7 +21,7 @@ import ormsgpack import pandas as pd import pytest from langchain_core.documents.base import Document -from langchain_core.messages import HumanMessage +from langchain_core.messages import AIMessage, HumanMessage from pydantic import BaseModel, SecretStr from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import SecretStr as SecretStrV1 @@ -341,7 +344,6 @@ def test_lc2_json_safe_type_revives_without_allowlist() -> None: constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages. """ - from langchain_core.messages import AIMessage serde = JsonPlusSerializer() # default: _allowed_json_modules=None @@ -410,7 +412,6 @@ def test_lc2_json_method_field_is_ignored() -> None: to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would produce, which proves the default constructor ran instead of ``parse_raw``. """ - from langchain_core.messages import AIMessage serde = JsonPlusSerializer() load = { @@ -436,7 +437,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None: method dispatch as a side effect. Revival is restricted to the default constructor regardless of how the class reached the revival path. """ - from langchain_core.messages import AIMessage serde = JsonPlusSerializer( allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")] @@ -455,7 +455,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None: def test_lc2_json_safe_type_init_still_works() -> None: """SAFE-type lc=2 revival without a `method` field still constructs the class.""" - from langchain_core.messages import AIMessage serde = JsonPlusSerializer() load = { @@ -479,7 +478,6 @@ def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None: this shape continue to revive correctly as long as the default constructor accepts the serialized kwargs. """ - from langchain_core.messages import AIMessage serde = JsonPlusSerializer() load = { @@ -551,9 +549,6 @@ def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None: With method dispatch removed from `_revive_lc2`, the gadget bytes are never passed to `parse_raw` and therefore never reach `pickle.loads`. """ - import os - import pickle - import tempfile marker = tempfile.NamedTemporaryFile( prefix="lc2_block_proof_", suffix=".out", delete=False diff --git a/libs/checkpoint/tests/test_memory.py b/libs/checkpoint/tests/test_memory.py index 70e22e0d8..3bce8bd42 100644 --- a/libs/checkpoint/tests/test_memory.py +++ b/libs/checkpoint/tests/test_memory.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Any @@ -523,7 +524,6 @@ class TestBaseFallbackGetChannelWrites: `threading.local()` guard would let whichever task set it first short-circuit the other to `writes=[]`. """ - import asyncio saver, thread_id, ns = self._build_saver_with_chain() diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 6a2cd94fa..8a301f8a8 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -72,10 +72,15 @@ lint.select = [ "UP", # pyupgrade "B", # flake8-bugbear "I", # isort + "PLC0415", # import-outside-top-level "RUF100", # unused noqa directive "UP", # pyupgrade ] lint.ignore = ["E501", "B008"] +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +lint.per-file-ignores = { "langgraph_cli/**" = ["PLC0415"], "generate_schema.py" = ["PLC0415"] } target-version = "py310" [tool.ty.rules] diff --git a/libs/cli/tests/unit_tests/test_archive.py b/libs/cli/tests/unit_tests/test_archive.py index 5b8ce8223..8f4dafc7d 100644 --- a/libs/cli/tests/unit_tests/test_archive.py +++ b/libs/cli/tests/unit_tests/test_archive.py @@ -11,6 +11,7 @@ from langgraph_cli.archive import ( _tar_filter, create_archive, ) +from langgraph_cli.config import LocalDeps # --------------------------------------------------------------------------- # _tar_filter @@ -198,7 +199,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") def test_yields_archive_with_config(self, mock_deps, tmp_path): - from langgraph_cli.config import LocalDeps config_file = self._make_project(tmp_path) mock_deps.return_value = LocalDeps( @@ -218,7 +218,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") def test_excludes_pycache(self, mock_deps, tmp_path): - from langgraph_cli.config import LocalDeps config_file = self._make_project(tmp_path) mock_deps.return_value = LocalDeps( @@ -232,7 +231,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") def test_cleans_up_tmp_dir_on_normal_exit(self, mock_deps, tmp_path): - from langgraph_cli.config import LocalDeps config_file = self._make_project(tmp_path) mock_deps.return_value = LocalDeps( @@ -247,7 +245,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") def test_cleans_up_tmp_dir_on_exception(self, mock_deps, tmp_path): - from langgraph_cli.config import LocalDeps config_file = self._make_project(tmp_path) mock_deps.return_value = LocalDeps( @@ -264,7 +261,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") @patch("langgraph_cli.archive._MAX_SIZE", 10) def test_raises_on_oversized_archive(self, mock_deps, tmp_path): - from langgraph_cli.config import LocalDeps config_file = self._make_project(tmp_path) mock_deps.return_value = LocalDeps( @@ -278,7 +274,6 @@ class TestCreateArchive: @patch("langgraph_cli.archive._assemble_local_deps") def test_handles_extra_contexts(self, mock_deps, tmp_path): """Monorepo case: project + sibling dependency directory.""" - from langgraph_cli.config import LocalDeps project = tmp_path / "myproject" project.mkdir() diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index cdeb0d3f5..5112450f8 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -347,7 +347,6 @@ class TestCallHostBackendWithOptionalTenant: def test_workspace_prompt_blocked_by_no_input(self, monkeypatch): """With _no_input=True, 403 requiring workspace should raise ClickException.""" - import langgraph_cli.deploy as deploy_mod monkeypatch.setattr(deploy_mod, "_no_input", True) @@ -515,7 +514,6 @@ class TestEmitterTextMode: class TestCreateHostBackendClientNoInput: def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path): - import langgraph_cli.deploy as deploy_mod monkeypatch.setattr(deploy_mod, "_no_input", True) monkeypatch.delenv("LANGSMITH_API_KEY", raising=False) @@ -530,7 +528,6 @@ class TestCreateHostBackendClientNoInput: ) def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path): - import langgraph_cli.deploy as deploy_mod monkeypatch.setattr(deploy_mod, "_no_input", True) monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index b579c7ebd..ad9c98ea2 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -89,8 +89,12 @@ langgraph-sdk = { path = "../sdk-py", editable = true } langgraph-cli = { path = "../cli", editable = true } [tool.ruff] -lint.select = [ "E", "F", "I", "RUF100", "TID251", "UP" ] +lint.select = [ "E", "F", "I", "PLC0415", "RUF100", "TID251", "UP" ] lint.ignore = [ "E501" ] +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] } line-length = 88 indent-width = 4 extend-include = ["*.ipynb"] diff --git a/libs/langgraph/tests/memory_assert.py b/libs/langgraph/tests/memory_assert.py index d9ca4904c..3987d422b 100644 --- a/libs/langgraph/tests/memory_assert.py +++ b/libs/langgraph/tests/memory_assert.py @@ -1,5 +1,6 @@ import os import tempfile +import time from collections import defaultdict from functools import partial from typing import Any @@ -73,8 +74,6 @@ class MemorySaverAssertImmutable(InMemorySaver): new_versions: ChannelVersions, ) -> None: if self.put_sleep: - import time - time.sleep(self.put_sleep) # assert checkpoint hasn't been modified since last written thread_id = config["configurable"]["thread_id"] diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py index 0e7e512d1..1e4066c4c 100644 --- a/libs/langgraph/tests/test_channels.py +++ b/libs/langgraph/tests/test_channels.py @@ -2,14 +2,16 @@ import operator from collections.abc import Sequence from typing import Annotated +import orjson import pytest from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.serde.types import _DeltaSnapshot from typing_extensions import NotRequired, TypedDict +from langgraph._internal._constants import OVERWRITE from langgraph._internal._typing import MISSING -from langgraph.channels.binop import BinaryOperatorAggregate +from langgraph.channels.binop import BinaryOperatorAggregate, _get_overwrite from langgraph.channels.delta import DeltaChannel from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic @@ -194,10 +196,6 @@ def test_overwrite_dataclass_form_survives_json_roundtrip() -> None: ...}`) is indistinguishable from a literal channel value, and downstream reducers raise `MESSAGE_COERCION_FAILURE` (or similar) on read. """ - import orjson - - from langgraph._internal._constants import OVERWRITE - from langgraph.channels.binop import _get_overwrite ow = Overwrite(value=[HumanMessage(content="new", id="h2")]) erased = orjson.loads(orjson.dumps(ow, default=lambda o: o.model_dump())) @@ -213,8 +211,6 @@ def test_overwrite_sentinel_dict_still_recognised() -> None: """The pre-existing `{"__overwrite__": value}` dict form continues to be recognised. This is the canonical sentinel emitted by producers that do not have an `Overwrite` dataclass available.""" - from langgraph._internal._constants import OVERWRITE - from langgraph.channels.binop import _get_overwrite is_overwrite, value = _get_overwrite({OVERWRITE: ["b"]}) assert is_overwrite @@ -224,7 +220,6 @@ def test_overwrite_sentinel_dict_still_recognised() -> None: def test_overwrite_non_matching_dict_not_recognised() -> None: """Dicts that resemble the erased shape but do not carry the `__overwrite__` discriminator must not be misclassified as overwrites.""" - from langgraph.channels.binop import _get_overwrite assert _get_overwrite({"value": ["b"]}) == (False, None) assert _get_overwrite({"type": "human", "value": "hi"}) == (False, None) diff --git a/libs/langgraph/tests/test_delta_channel_benchmark.py b/libs/langgraph/tests/test_delta_channel_benchmark.py index 0e45ffdaf..41ae6b7d2 100644 --- a/libs/langgraph/tests/test_delta_channel_benchmark.py +++ b/libs/langgraph/tests/test_delta_channel_benchmark.py @@ -220,7 +220,7 @@ def _checkpointers() -> list[tuple[str, Any]]: result: list[tuple[str, Any]] = [("InMemory", None)] if _POSTGRES_AVAILABLE: try: - import psycopg + import psycopg # noqa: PLC0415 psycopg.connect(_POSTGRES_URI).close() result.append(("Postgres", "postgres")) diff --git a/libs/langgraph/tests/test_delta_channel_update_state.py b/libs/langgraph/tests/test_delta_channel_update_state.py index a0930496d..da6db40f6 100644 --- a/libs/langgraph/tests/test_delta_channel_update_state.py +++ b/libs/langgraph/tests/test_delta_channel_update_state.py @@ -27,6 +27,7 @@ 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 +from langgraph.types import StateUpdate pytestmark = pytest.mark.anyio @@ -277,7 +278,6 @@ def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None: different `StateUpdate`s targeting the same node — otherwise both share the deterministic interrupt-derived id and collide in the saver. """ - from langgraph.types import StateUpdate saver = InMemorySaver() graph = _build_graph(saver) diff --git a/libs/langgraph/tests/test_deprecation.py b/libs/langgraph/tests/test_deprecation.py index 54e1e3812..e57d57283 100644 --- a/libs/langgraph/tests/test_deprecation.py +++ b/libs/langgraph/tests/test_deprecation.py @@ -88,13 +88,13 @@ def test_constants_deprecation() -> None: LangGraphDeprecatedSinceV10, match="Importing Send from langgraph.constants is deprecated. Please use 'from langgraph.types import Send' instead.", ): - from langgraph.constants import Send # noqa: F401 + from langgraph.constants import Send # noqa: PLC0415, F401 with pytest.warns( LangGraphDeprecatedSinceV10, match="Importing Interrupt from langgraph.constants is deprecated. Please use 'from langgraph.types import Interrupt' instead.", ): - from langgraph.constants import Interrupt # noqa: F401 + from langgraph.constants import Interrupt # noqa: PLC0415, F401 def test_pregel_types_deprecation() -> None: @@ -102,7 +102,7 @@ def test_pregel_types_deprecation() -> None: LangGraphDeprecatedSinceV10, match="Importing from langgraph.pregel.types is deprecated. Please use 'from langgraph.types import ...' instead.", ): - from langgraph.pregel.types import StateSnapshot # noqa: F401 + from langgraph.pregel.types import StateSnapshot # noqa: PLC0415, F401 def test_config_schema_deprecation() -> None: @@ -195,7 +195,7 @@ def test_deprecated_import() -> None: LangGraphDeprecatedSinceV10, match="Importing PREVIOUS from langgraph.constants is deprecated. This constant is now private and should not be used directly.", ): - from langgraph.constants import PREVIOUS # noqa: F401 + from langgraph.constants import PREVIOUS # noqa: PLC0415, F401 @pytest.mark.filterwarnings( diff --git a/libs/langgraph/tests/test_graph_callbacks.py b/libs/langgraph/tests/test_graph_callbacks.py index 09c3ab417..941cf415d 100644 --- a/libs/langgraph/tests/test_graph_callbacks.py +++ b/libs/langgraph/tests/test_graph_callbacks.py @@ -13,6 +13,7 @@ from langgraph.callbacks import ( GraphCallbackHandler, GraphInterruptEvent, GraphResumeEvent, + _GraphCallbackManager, ) from langgraph.graph import START, StateGraph from langgraph.types import Command, Interrupt, interrupt @@ -286,7 +287,6 @@ def test_non_graph_handler_via_add_handler_does_not_crash() -> None: GraphCallbackHandler. They must be silently accepted — graph lifecycle events will simply not be dispatched to them. """ - from langgraph.callbacks import _GraphCallbackManager manager = _GraphCallbackManager() plain_handler = _LangChainCustomEventHandler() diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index 890866924..5d831a071 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -2,11 +2,26 @@ import json import operator import re import time +from copy import deepcopy from dataclasses import replace from typing import Annotated, Any, Literal, cast import pytest -from langchain_core.messages import AIMessage, AnyMessage, ToolCall +from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models.fake import FakeStreamingListLLM +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + HumanMessage, + ToolCall, + ToolMessage, +) +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.prompts import PromptTemplate from langchain_core.runnables import RunnableConfig, RunnableMap, RunnablePick from langchain_core.tools import tool from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION @@ -484,9 +499,6 @@ def test_conditional_state_graph( snapshot: SnapshotAssertion, sync_checkpointer: BaseCheckpointSaver, ) -> None: - from langchain_core.language_models.fake import FakeStreamingListLLM - from langchain_core.prompts import PromptTemplate - from langchain_core.tools import tool class AgentState(TypedDict, total=False): input: Annotated[str, UntrackedValue] @@ -1261,8 +1273,6 @@ def test_conditional_state_graph( def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: - from langchain_core.messages import AIMessage, HumanMessage - from langchain_core.tools import tool @tool() def search_api(query: str) -> str: @@ -1626,17 +1636,6 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: def test_state_graph_packets( sync_checkpointer: BaseCheckpointSaver, mocker: MockerFixture ) -> None: - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import ( - AIMessage, - BaseMessage, - HumanMessage, - ToolCall, - ToolMessage, - ) - from langchain_core.tools import tool class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] @@ -2381,15 +2380,6 @@ def test_message_graph( deterministic_uuids: MockerFixture, sync_checkpointer: BaseCheckpointSaver, ) -> None: - from copy import deepcopy - - from langchain_core.callbacks import CallbackManagerForLLMRun - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import AIMessage, BaseMessage, HumanMessage - from langchain_core.outputs import ChatGeneration, ChatResult - from langchain_core.tools import tool class FakeFunctionChatModel(FakeMessagesListChatModel): def bind_functions(self, functions: list): @@ -3099,20 +3089,6 @@ def test_root_graph( deterministic_uuids: MockerFixture, sync_checkpointer: BaseCheckpointSaver, ) -> None: - from copy import deepcopy - - from langchain_core.callbacks import CallbackManagerForLLMRun - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import ( - AIMessage, - BaseMessage, - HumanMessage, - ToolMessage, - ) - from langchain_core.outputs import ChatGeneration, ChatResult - from langchain_core.tools import tool class FakeFunctionChatModel(FakeMessagesListChatModel): def bind_functions(self, functions: list): @@ -5837,7 +5813,6 @@ def test_send_to_nested_graphs(sync_checkpointer: BaseCheckpointSaver) -> None: def test_send_react_interrupt( sync_checkpointer: BaseCheckpointSaver, ) -> None: - from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage ai_message = AIMessage( "", @@ -6228,7 +6203,6 @@ def test_send_react_interrupt( def test_send_react_interrupt_control( sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion ) -> None: - from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage ai_message = AIMessage( "", @@ -6455,9 +6429,6 @@ def test_send_react_interrupt_control( def test_weather_subgraph( sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion ) -> None: - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) # setup subgraph diff --git a/libs/langgraph/tests/test_large_cases_async.py b/libs/langgraph/tests/test_large_cases_async.py index 1db144aa4..d90ed5b14 100644 --- a/libs/langgraph/tests/test_large_cases_async.py +++ b/libs/langgraph/tests/test_large_cases_async.py @@ -9,8 +9,22 @@ from typing import ( ) import pytest -from langchain_core.messages import AnyMessage, ToolCall +from langchain_core.agents import AgentAction, AgentFinish +from langchain_core.language_models.fake import FakeStreamingListLLM +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + HumanMessage, + ToolCall, + ToolMessage, +) +from langchain_core.prompts import PromptTemplate from langchain_core.runnables import RunnableConfig, RunnablePick +from langchain_core.tools import tool from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.prebuilt.chat_agent_executor import create_react_agent @@ -22,6 +36,7 @@ from langgraph._internal._constants import PULL, PUSH from langgraph.channels.last_value import LastValue from langgraph.channels.untracked_value import UntrackedValue from langgraph.constants import END, START +from langgraph.graph import MessagesState from langgraph.graph.message import add_messages from langgraph.graph.state import StateGraph from langgraph.pregel import NodeBuilder, Pregel @@ -479,10 +494,6 @@ async def test_fork_always_re_runs_nodes( async def test_conditional_graph_state(async_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.agents import AgentAction, AgentFinish - from langchain_core.language_models.fake import FakeStreamingListLLM - from langchain_core.prompts import PromptTemplate - from langchain_core.tools import tool class AgentState(TypedDict): input: Annotated[str, UntrackedValue] @@ -1017,8 +1028,6 @@ async def test_conditional_graph_state(async_checkpointer: BaseCheckpointSaver) async def test_prebuilt_tool_chat() -> None: - from langchain_core.messages import AIMessage, HumanMessage - from langchain_core.tools import tool model = FakeChatModel( messages=[ @@ -1358,16 +1367,6 @@ async def test_prebuilt_tool_chat() -> None: async def test_state_graph_packets(async_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import ( - AIMessage, - BaseMessage, - HumanMessage, - ToolMessage, - ) - from langchain_core.tools import tool class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] @@ -2072,11 +2071,6 @@ async def test_state_graph_packets(async_checkpointer: BaseCheckpointSaver) -> N async def test_message_graph(async_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import AIMessage, HumanMessage - from langchain_core.tools import tool class FakeFunctionChatModel(FakeMessagesListChatModel): def bind_functions(self, functions: list): @@ -3537,13 +3531,6 @@ async def test_send_to_nested_graphs(async_checkpointer: BaseCheckpointSaver) -> async def test_weather_subgraph( async_checkpointer: BaseCheckpointSaver, ) -> None: - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import AIMessage, ToolCall - from langchain_core.tools import tool - - from langgraph.graph import MessagesState # setup subgraph diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 58dd294f4..c166c5837 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -4,25 +4,39 @@ import gc import json import logging import operator +import random import threading import time import uuid -from collections import Counter, deque +from collections import Counter, defaultdict, deque from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from random import randrange from typing import Annotated, Any, Literal, get_type_hints +from unittest.mock import patch import pytest from langchain_core.language_models import GenericFakeChatModel -from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage +from langchain_core.language_models.fake import FakeStreamingListLLM +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + HumanMessage, + RemoveMessage, +) +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate from langchain_core.runnables import ( RunnableConfig, RunnableLambda, RunnablePassthrough, ) from langchain_core.runnables.graph import Edge +from langchain_core.tools import tool from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.cache.base import BaseCache from langgraph.checkpoint.base import ( @@ -56,8 +70,9 @@ from langgraph.pregel import ( NodeBuilder, Pregel, ) -from langgraph.pregel._loop import SyncPregelLoop +from langgraph.pregel._loop import PregelLoop, SyncPregelLoop from langgraph.pregel._runner import PregelRunner +from langgraph.runtime import RunControl from langgraph.types import ( CachePolicy, Command, @@ -125,7 +140,6 @@ def test_graph_validation() -> None: def test_request_drain_allows_inflight_call_scheduling( sync_checkpointer: BaseCheckpointSaver, ) -> None: - from langgraph.runtime import RunControl @task def child(x: int) -> int: @@ -1769,9 +1783,6 @@ def test_conditional_state_graph_with_list_edge_inputs(snapshot: SnapshotAsserti def test_state_graph_w_config_inherited_state_keys(snapshot: SnapshotAssertion) -> None: - from langchain_core.language_models.fake import FakeStreamingListLLM - from langchain_core.prompts import PromptTemplate - from langchain_core.tools import tool class BaseState(TypedDict): input: str @@ -3769,12 +3780,6 @@ def test_checkpoint_metadata(sync_checkpointer: BaseCheckpointSaver) -> None: previous checkpoint config for each step in the run. """ # set up test - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import AIMessage, AnyMessage - from langchain_core.prompts import ChatPromptTemplate - from langchain_core.tools import tool # graph state class BaseState(TypedDict): @@ -3940,7 +3945,6 @@ def test_checkpoint_metadata(sync_checkpointer: BaseCheckpointSaver) -> None: def test_remove_message_via_state_update( sync_checkpointer: BaseCheckpointSaver, ) -> None: - from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] workflow.add_node( @@ -3973,7 +3977,6 @@ def test_remove_message_via_state_update( def test_remove_message_from_node(): - from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type] workflow.add_node( @@ -3999,7 +4002,6 @@ def test_remove_message_from_node(): def test_xray_lance(snapshot: SnapshotAssertion): - from langchain_core.messages import AnyMessage, HumanMessage class Analyst(BaseModel): affiliation: str = Field( @@ -4483,7 +4485,6 @@ def test_debug_subgraphs( def test_debug_nested_subgraphs( sync_checkpointer: BaseCheckpointSaver, durability: Durability ): - from collections import defaultdict class State(TypedDict): messages: Annotated[list[str], operator.add] @@ -4743,8 +4744,6 @@ def test_runnable_passthrough_node_graph() -> None: def test_parent_command( sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool ) -> None: - from langchain_core.messages import BaseMessage - from langchain_core.tools import tool @tool(return_direct=True) def get_user_name() -> Command: @@ -5164,7 +5163,6 @@ def test_command_with_static_breakpoints( def test_multistep_plan(sync_checkpointer: BaseCheckpointSaver): - from langchain_core.messages import AnyMessage class State(TypedDict, total=False): plan: list[str | list[str]] @@ -5910,9 +5908,6 @@ def test_no_redundant_put_writes_for_cached_task( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Cached @tasks on resume must not trigger redundant put_writes.""" - from unittest.mock import patch - - from langgraph.pregel._loop import PregelLoop @task def setup(x: int) -> int: @@ -6975,7 +6970,6 @@ def test_configurable_propagates_to_stream_metadata() -> None: def test_stream_mode_messages_command() -> None: - from langchain_core.messages import HumanMessage def my_node(state): return {"messages": HumanMessage(content="foo")} @@ -7243,7 +7237,6 @@ def test_get_stream_writer() -> None: def test_stream_messages_dedupe_inputs() -> None: - from langchain_core.messages import AIMessage def call_model(state): return {"messages": AIMessage("hi", id="1")} @@ -7281,7 +7274,6 @@ def test_stream_messages_dedupe_inputs() -> None: def test_stream_messages_dedupe_state(sync_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.messages import AIMessage to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")] @@ -8253,7 +8245,6 @@ def test_get_graph_loop(snapshot: SnapshotAssertion) -> None: def test_get_graph_self_loop(snapshot: SnapshotAssertion) -> None: - import random subgraph_builder = StateGraph(MessagesState) subgraph_builder.add_node("agent", lambda x: x) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 1a1a4734b..150801ffc 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7,7 +7,7 @@ import operator import random import sys import uuid -from collections import Counter, deque +from collections import Counter, defaultdict, deque from dataclasses import replace from time import perf_counter from typing import ( @@ -21,8 +21,20 @@ from uuid import UUID import pytest from langchain_core.language_models import GenericFakeChatModel -from langchain_core.messages import HumanMessage +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + BaseMessage, + HumanMessage, + ToolCall, + ToolMessage, +) +from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough +from langchain_core.tools import tool from langchain_core.utils.aiter import aclosing from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.cache.base import BaseCache @@ -45,6 +57,7 @@ from typing_extensions import NotRequired, TypedDict from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL from langgraph._internal._queue import AsyncQueue from langgraph.channels.binop import BinaryOperatorAggregate +from langgraph.channels.delta import DeltaChannel from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.errors import ( @@ -55,10 +68,11 @@ from langgraph.errors import ( ) from langgraph.func import entrypoint, task from langgraph.graph import END, START, StateGraph -from langgraph.graph.message import MessagesState, add_messages +from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages from langgraph.pregel import NodeBuilder, Pregel -from langgraph.pregel._loop import AsyncPregelLoop +from langgraph.pregel._loop import AsyncPregelLoop, PregelLoop from langgraph.pregel._runner import PregelRunner +from langgraph.runtime import RunControl from langgraph.types import ( CachePolicy, Command, @@ -222,7 +236,6 @@ async def test_checkpoint_errors() -> None: async def test_request_drain_allows_inflight_acall_scheduling( async_checkpointer: BaseCheckpointSaver, ) -> None: - from langgraph.runtime import RunControl @task async def child(x: int) -> int: @@ -2868,7 +2881,6 @@ async def test_send_dedupe_on_resume( async def test_send_react_interrupt(async_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage ai_message = AIMessage( "", @@ -3259,7 +3271,6 @@ async def test_send_react_interrupt(async_checkpointer: BaseCheckpointSaver) -> async def test_send_react_interrupt_control( async_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion ) -> None: - from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage ai_message = AIMessage( "", @@ -5538,12 +5549,6 @@ async def test_checkpoint_metadata(async_checkpointer: BaseCheckpointSaver) -> N previous checkpoint config for each step in the run. """ # set up test - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langchain_core.messages import AIMessage, AnyMessage - from langchain_core.prompts import ChatPromptTemplate - from langchain_core.tools import tool # graph state class BaseState(TypedDict): @@ -5944,7 +5949,6 @@ async def test_debug_subgraphs( async def test_debug_nested_subgraphs( async_checkpointer: BaseCheckpointSaver, durability: Durability ) -> None: - from collections import defaultdict class State(TypedDict): messages: Annotated[list[str], operator.add] @@ -6061,8 +6065,6 @@ async def test_debug_nested_subgraphs( async def test_parent_command( async_checkpointer: BaseCheckpointSaver, subgraph_persist: bool ) -> None: - from langchain_core.messages import BaseMessage - from langchain_core.tools import tool @tool(return_direct=True) def get_user_name() -> Command: @@ -6130,10 +6132,6 @@ async def test_parent_command( async def test_delta_channel_durability_exit_stores_snapshot_async() -> None: """DeltaChannel must reload from an async durability='exit' checkpoint.""" - from langchain_core.messages import AIMessage - - from langgraph.channels.delta import DeltaChannel - from langgraph.graph.message import _messages_delta_reducer class State(TypedDict): messages: Annotated[list, DeltaChannel(_messages_delta_reducer)] @@ -6420,7 +6418,6 @@ async def test_command_with_static_breakpoints( async def test_multistep_plan(async_checkpointer: BaseCheckpointSaver) -> None: - from langchain_core.messages import AnyMessage class State(TypedDict, total=False): plan: list[str | list[str]] @@ -6758,7 +6755,6 @@ async def test_multiple_interrupts_functional( async_checkpointer: BaseCheckpointSaver, ) -> None: """Test multiple interrupts with functional API.""" - from langgraph.func import entrypoint, task counter = 0 @@ -7674,7 +7670,6 @@ async def test_configurable_propagates_to_stream_metadata() -> None: async def test_stream_mode_messages_command() -> None: - from langchain_core.messages import HumanMessage async def my_node(state): return {"messages": HumanMessage(content="foo")} @@ -7723,7 +7718,6 @@ async def test_stream_mode_messages_command() -> None: async def test_stream_messages_dedupe_inputs() -> None: - from langchain_core.messages import AIMessage async def call_model(state): return {"messages": AIMessage("hi", id="1")} @@ -7763,7 +7757,6 @@ async def test_stream_messages_dedupe_inputs() -> None: async def test_stream_messages_dedupe_state( async_checkpointer: BaseCheckpointSaver, ) -> None: - from langchain_core.messages import AIMessage to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")] @@ -8142,9 +8135,6 @@ async def test_no_redundant_put_writes_for_cached_task( async_checkpointer: BaseCheckpointSaver, ) -> None: """Cached @tasks on resume must not trigger redundant put_writes.""" - from unittest.mock import patch - - from langgraph.pregel._loop import PregelLoop @task async def setup(x: int) -> int: @@ -8646,7 +8636,6 @@ async def test_batch_update_as_input( async def test_draw_invalid(): - from langchain_core.messages import BaseMessage class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] diff --git a/libs/langgraph/tests/test_pydantic.py b/libs/langgraph/tests/test_pydantic.py index f49a02871..062a450fe 100644 --- a/libs/langgraph/tests/test_pydantic.py +++ b/libs/langgraph/tests/test_pydantic.py @@ -4,10 +4,13 @@ import ipaddress import pathlib import re import sys +import typing import uuid from enum import Enum from typing import Annotated, Literal, Optional +import pydantic +import typing_extensions from langgraph.checkpoint.base import BaseCheckpointSaver from pydantic import ( BaseModel, @@ -32,10 +35,6 @@ from tests.any_str import AnyStr def test_is_supported_by_pydantic() -> None: """Test if types are supported by pydantic.""" - import typing - - import pydantic - import typing_extensions class TypedDictExtensions(typing_extensions.TypedDict): x: int diff --git a/libs/langgraph/tests/test_remote_graph.py b/libs/langgraph/tests/test_remote_graph.py index 4edb9325e..8e10e209e 100644 --- a/libs/langgraph/tests/test_remote_graph.py +++ b/libs/langgraph/tests/test_remote_graph.py @@ -10,12 +10,14 @@ from langchain_core.messages import AnyMessage, BaseMessage from langchain_core.runnables import RunnableConfig from langchain_core.runnables.graph import Edge as DrawableEdge from langchain_core.runnables.graph import Node as DrawableNode +from langgraph.checkpoint.memory import InMemorySaver +from langgraph_sdk.client import get_client, get_sync_client from langgraph_sdk.schema import StreamPart from pydantic import BaseModel from typing_extensions import TypedDict from langgraph.errors import GraphInterrupt -from langgraph.graph import StateGraph, add_messages +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 @@ -1097,10 +1099,6 @@ def test_stream_context_base_model(): ) @pytest.mark.anyio async def test_langgraph_cloud_integration(): - from langgraph.checkpoint.memory import InMemorySaver - from langgraph_sdk.client import get_client, get_sync_client - - from langgraph.graph import END, START, MessagesState, StateGraph # create RemotePregel instance client = get_client(url="http://localhost:8123") diff --git a/libs/langgraph/tests/test_remote_graph_v3.py b/libs/langgraph/tests/test_remote_graph_v3.py index c85594c71..2239b73fb 100644 --- a/libs/langgraph/tests/test_remote_graph_v3.py +++ b/libs/langgraph/tests/test_remote_graph_v3.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from langgraph.pregel import remote as remote_mod from langgraph.pregel._remote_run_stream import ( _AsyncRemoteGraphRunStream, _ChannelProjection, @@ -577,7 +578,6 @@ def test_stream_events_v3_strips_checkpoint_keys_from_configurable(): def test_stream_events_v3_merges_tracing_headers_when_distributed_tracing( monkeypatch, ): - from langgraph.pregel import remote as remote_mod sync_client = MagicMock() sync_client.threads.stream.return_value = MagicMock() diff --git a/libs/langgraph/tests/test_retry.py b/libs/langgraph/tests/test_retry.py index fcdde937b..aa48b0812 100644 --- a/libs/langgraph/tests/test_retry.py +++ b/libs/langgraph/tests/test_retry.py @@ -11,7 +11,9 @@ from typing import Annotated, Any from unittest.mock import Mock, patch from uuid import uuid4 +import httpx import pytest +import requests from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallbackHandler from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage @@ -63,6 +65,7 @@ from langgraph.types import ( RetryPolicy, Send, TimeoutPolicy, + interrupt, ) NEEDS_CONTEXTVARS = pytest.mark.skipif( @@ -171,8 +174,6 @@ def test_checkpoint_ns_for_parent_command() -> None: def test_should_retry_default_retry_on(): """Test the default retry_on function.""" - import httpx - import requests # Create a RetryPolicy with default_retry_on policy = RetryPolicy() @@ -2198,7 +2199,6 @@ def test_graph_error_handler_does_not_swallow_interrupt_concurrent(): """When a graph error handler is configured and a node calls interrupt() concurrently with other nodes, the interrupt must still be raised — not silently swallowed.""" - from langgraph.types import interrupt class State(TypedDict): foo: str @@ -2587,8 +2587,6 @@ async def test_set_node_defaults_timeout(): .compile() ) - from langgraph.errors import NodeTimeoutError - with pytest.raises(NodeTimeoutError): await graph.ainvoke({"foo": ""}) diff --git a/libs/langgraph/tests/test_runtime.py b/libs/langgraph/tests/test_runtime.py index f2f2fcc23..0f8cc944b 100644 --- a/libs/langgraph/tests/test_runtime.py +++ b/libs/langgraph/tests/test_runtime.py @@ -6,9 +6,11 @@ from typing import Any import pytest from langgraph.checkpoint.memory import MemorySaver +from langgraph.store.memory import InMemoryStore from pydantic import BaseModel, ValidationError from typing_extensions import TypedDict +from langgraph._internal._constants import CONFIG_KEY_RUNTIME from langgraph.errors import GraphDrained from langgraph.graph import END, START, StateGraph from langgraph.runtime import ( @@ -1177,9 +1179,6 @@ def test_foreign_object_in_runtime_slot_is_coerced() -> None: `merge` when no per-run `context` is provided. `store` is resolved separately, so it is not read off the foreign object in the coercion. """ - from langgraph.store.memory import InMemoryStore - - from langgraph._internal._constants import CONFIG_KEY_RUNTIME store = InMemoryStore() graph_level_context = {"source": "graph-level"} diff --git a/libs/langgraph/tests/test_serde_allowlist.py b/libs/langgraph/tests/test_serde_allowlist.py index 2a90389da..b6bc93b2f 100644 --- a/libs/langgraph/tests/test_serde_allowlist.py +++ b/libs/langgraph/tests/test_serde_allowlist.py @@ -79,7 +79,7 @@ class DummyChannel: def test_curated_core_allowlist_includes_messages() -> None: try: - from langchain_core.messages import BaseMessage + from langchain_core.messages import BaseMessage # noqa: PLC0415 except Exception: pytest.skip("langchain_core not available") allowlist = curated_core_allowlist() diff --git a/libs/langgraph/tests/test_stream_data_transformers.py b/libs/langgraph/tests/test_stream_data_transformers.py index 5aa37e5e3..ae94c8943 100644 --- a/libs/langgraph/tests/test_stream_data_transformers.py +++ b/libs/langgraph/tests/test_stream_data_transformers.py @@ -13,8 +13,10 @@ import operator import time from typing import Annotated, Any +from langgraph.checkpoint.memory import InMemorySaver from typing_extensions import TypedDict +from langgraph.config import get_stream_writer from langgraph.constants import END, START from langgraph.graph import StateGraph from langgraph.stream._mux import StreamMux @@ -488,7 +490,6 @@ class _State(TypedDict): def _my_node(state: _State) -> dict[str, Any]: - from langgraph.config import get_stream_writer writer = get_stream_writer() writer({"status": "working", "node": "my_node"}) @@ -606,7 +607,6 @@ def test_stream_events_v3_all_transformers_interleaved() -> None: def test_stream_events_v3_all_transformers_with_checkpointer() -> None: """All transformers with a checkpointer — run.checkpoints populated.""" - from langgraph.checkpoint.memory import InMemorySaver builder = StateGraph(_State, input_schema=_State) builder.add_node("my_node", _my_node) @@ -645,7 +645,6 @@ def test_stream_events_v3_all_transformers_with_checkpointer() -> None: def test_stream_events_v3_checkpoints_projection_opt_in() -> None: """run.checkpoints surfaces checkpoint data when opted in with a checkpointer.""" - from langgraph.checkpoint.memory import InMemorySaver builder = StateGraph(_State, input_schema=_State) builder.add_node("my_node", _my_node) diff --git a/libs/langgraph/tests/test_stream_messages_transformer.py b/libs/langgraph/tests/test_stream_messages_transformer.py index 6e13ef47e..30443b773 100644 --- a/libs/langgraph/tests/test_stream_messages_transformer.py +++ b/libs/langgraph/tests/test_stream_messages_transformer.py @@ -3,8 +3,10 @@ legacy v1 chunk filtering, and end-to-end via stream_events(version="v3") / astr from __future__ import annotations +import asyncio import time from typing import Any +from uuid import uuid4 import pytest from langchain_core.language_models import GenericFakeChatModel @@ -13,11 +15,13 @@ from langchain_core.language_models.chat_model_stream import ( ChatModelStream, ) from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult from langchain_core.runnables import RunnableConfig from typing_extensions import TypedDict from langgraph.constants import END, START from langgraph.graph import MessagesState, StateGraph +from langgraph.pregel._messages import StreamMessagesHandlerV2 from langgraph.stream._mux import StreamMux from langgraph.stream.run_stream import GraphRunStream from langgraph.stream.stream_channel import StreamChannel @@ -607,7 +611,6 @@ class TestEndToEnd: @pytest.mark.anyio async def test_nested_async_iteration_yields_text_deltas(self) -> None: """Inner stream.text drives the shared graph pump via the async pump binding.""" - import asyncio model = GenericFakeChatModel(messages=iter(["hello world"])) @@ -870,11 +873,6 @@ class TestDirectMessagesModeStaysV1: class TestStreamMessagesHandlerV2Unit: def test_on_llm_new_token_is_noop(self) -> None: """v2 handler must not emit v1 chunks even when on_llm_new_token fires.""" - from uuid import uuid4 - - from langchain_core.outputs import ChatGenerationChunk - - from langgraph.pregel._messages import StreamMessagesHandlerV2 emitted: list[Any] = [] handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False) @@ -890,9 +888,6 @@ class TestStreamMessagesHandlerV2Unit: assert emitted == [] def test_on_chain_end_does_not_emit_tool_messages(self) -> None: - from uuid import uuid4 - - from langgraph.pregel._messages import StreamMessagesHandlerV2 emitted: list[Any] = [] handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False) @@ -909,11 +904,6 @@ class TestStreamMessagesHandlerV2Unit: def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None: """A streamed v2 message should not be emitted again from the final AIMessage fallback when its final id does not match `message-start`.""" - from uuid import uuid4 - - from langchain_core.outputs import ChatGeneration, LLMResult - - from langgraph.pregel._messages import StreamMessagesHandlerV2 emitted: list[Any] = [] handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False) diff --git a/libs/langgraph/tests/test_utils.py b/libs/langgraph/tests/test_utils.py index 523528561..9efbd0269 100644 --- a/libs/langgraph/tests/test_utils.py +++ b/libs/langgraph/tests/test_utils.py @@ -2,6 +2,7 @@ import functools import sys import uuid from collections.abc import Callable +from dataclasses import dataclass from typing import ( Annotated, Any, @@ -17,7 +18,10 @@ import langsmith import pytest from langchain_core.callbacks import BaseCallbackHandler, CallbackManager from langchain_core.runnables import RunnableConfig +from langchain_core.runnables.config import var_child_runnable_config from langchain_core.tracers import LangChainTracer +from langsmith import get_current_run_tree # type: ignore +from pydantic import BaseModel, Field from typing_extensions import NotRequired, Required, TypedDict from langgraph._internal._config import ( @@ -118,7 +122,6 @@ def rt_graph() -> CompiledStateGraph: node_run_id: int def node(_: State): - from langsmith import get_current_run_tree # type: ignore return {"node_run_id": get_current_run_tree().id} # type: ignore @@ -243,10 +246,6 @@ def test_is_required(): def test_enhanced_type_hints() -> None: - from dataclasses import dataclass - from typing import Annotated - - from pydantic import BaseModel, Field class MyTypedDict(TypedDict): val_1: str @@ -510,7 +509,6 @@ def test_ensure_config_explicit_configurable_replaces_ambient() -> None: # An explicit checkpoint coordinate (here a new thread_id) starts a fresh # lineage and drops the ambient run context (e.g. a parent task's # checkpoint_ns), so a child graph does not inherit it. - from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( {"configurable": {"checkpoint_ns": "p:parent-task", "checkpoint_id": "cid"}} @@ -527,7 +525,6 @@ def test_ensure_config_explicit_configurable_replaces_ambient() -> None: def test_ensure_config_ambient_inherited_when_no_explicit_configurable() -> None: # With no explicit configurable, the ambient run context is inherited # unchanged (stateless subgraph / interrupt-resume pattern). - from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( {"configurable": {"checkpoint_ns": "p:parent-task"}} @@ -543,7 +540,6 @@ def test_ensure_config_explicit_configurables_still_merge_over_ambient() -> None # A new thread_id drops the ambient, but explicit configs still shallow-merge # among themselves, so a with_config(...) value (ls_agent_type) survives # alongside an invoke-time thread_id. - from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( {"configurable": {"checkpoint_ns": "p:parent-task"}} @@ -564,7 +560,6 @@ def test_ensure_config_non_coordinate_config_keeps_ambient_checkpoint_ns() -> No # A nested subagent is invoked with a non-coordinate configurable key # (ls_agent_type) and no thread_id; it must keep the inherited checkpoint_ns # so it stays a discoverable child of the parent run (deepagents `task` tool). - from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( {"configurable": {"thread_id": "parent", "checkpoint_ns": "p:parent-task"}} @@ -582,7 +577,6 @@ def test_ensure_config_same_thread_id_still_clears_ambient() -> None: # A child that reuses the parent's thread_id is still addressing its own root # namespace on that thread, so the parent task's checkpoint_ns must not leak # in; otherwise the child writes state that get_state cannot read back. - from langchain_core.runnables.config import var_child_runnable_config token = var_child_runnable_config.set( {"configurable": {"thread_id": "shared", "checkpoint_ns": "p:parent-task"}} diff --git a/libs/prebuilt/pyproject.toml b/libs/prebuilt/pyproject.toml index 97558ae6a..3c9c57315 100644 --- a/libs/prebuilt/pyproject.toml +++ b/libs/prebuilt/pyproject.toml @@ -75,8 +75,12 @@ addopts = "--strict-markers --strict-config --durations=5 -vv" asyncio_mode = "auto" [tool.ruff] -lint.select = [ "E", "F", "I", "RUF100", "TID251", "UP" ] +lint.select = [ "E", "F", "I", "PLC0415", "RUF100", "TID251", "UP" ] lint.ignore = [ "E501" ] +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] } target-version = "py310" [tool.ty.rules] diff --git a/libs/prebuilt/tests/memory_assert.py b/libs/prebuilt/tests/memory_assert.py index c09c2d78d..3c602c413 100644 --- a/libs/prebuilt/tests/memory_assert.py +++ b/libs/prebuilt/tests/memory_assert.py @@ -1,5 +1,6 @@ import os import tempfile +import time from collections import defaultdict from functools import partial @@ -38,8 +39,6 @@ class MemorySaverAssertImmutable(InMemorySaver): new_versions: ChannelVersions, ) -> None: if self.put_sleep: - import time - time.sleep(self.put_sleep) # assert checkpoint hasn't been modified since last written thread_id = config["configurable"]["thread_id"] diff --git a/libs/prebuilt/tests/test_injected_state_not_required.py b/libs/prebuilt/tests/test_injected_state_not_required.py index 36b9a1d17..58b77eaeb 100644 --- a/libs/prebuilt/tests/test_injected_state_not_required.py +++ b/libs/prebuilt/tests/test_injected_state_not_required.py @@ -9,16 +9,19 @@ handle missing fields by injecting None instead of raising KeyError. import sys from typing import Annotated +from unittest.mock import Mock import pytest from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage from langchain_core.tools import tool from langgraph.graph.message import add_messages +from langgraph.runtime import Runtime from pydantic import BaseModel, Field from typing_extensions import NotRequired from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent from langgraph.prebuilt.chat_agent_executor import AgentState +from langgraph.prebuilt.tool_node import ToolRuntime from .model import FakeToolCallingModel @@ -50,9 +53,6 @@ def _create_mock_runtime( store=None, ): """Create a mock Runtime for testing ToolNode directly.""" - from unittest.mock import Mock - - from langgraph.runtime import Runtime mock_runtime = Mock(spec=Runtime) mock_runtime.context = {} @@ -61,7 +61,6 @@ def _create_mock_runtime( def _create_config_with_runtime(store=None, state=None): """Create a RunnableConfig with mocked runtime for direct ToolNode testing.""" - from langgraph.prebuilt.tool_node import ToolRuntime tool_runtime = ToolRuntime( state=state or {}, diff --git a/libs/prebuilt/tests/test_on_tool_call.py b/libs/prebuilt/tests/test_on_tool_call.py index 987369f95..f2af11b46 100644 --- a/libs/prebuilt/tests/test_on_tool_call.py +++ b/libs/prebuilt/tests/test_on_tool_call.py @@ -1,5 +1,6 @@ """Unit tests for tool call interceptor in ToolNode.""" +import functools from collections.abc import Callable from unittest.mock import Mock @@ -1331,7 +1332,6 @@ def _config_with_channel_read( learn channel names. The stub matches the shape: partial whose second and third positional args are `channels` and `managed` mappings. """ - import functools channels_stub = {k: None for k in channel_values} managed_stub: dict[str, object] = {} diff --git a/libs/prebuilt/tests/test_tool_node.py b/libs/prebuilt/tests/test_tool_node.py index acc5522fa..47ebdcae0 100644 --- a/libs/prebuilt/tests/test_tool_node.py +++ b/libs/prebuilt/tests/test_tool_node.py @@ -2,6 +2,7 @@ import contextlib import dataclasses import json import sys +import warnings from functools import partial from typing import ( Annotated, @@ -23,10 +24,12 @@ from langchain_core.messages import ( from langchain_core.runnables.config import RunnableConfig from langchain_core.tools import BaseTool, InjectedToolArg, ToolException from langchain_core.tools import tool as dec_tool +from langchain_core.tools.base import InjectedToolCallId from langgraph.config import get_stream_writer from langgraph.errors import GraphBubbleUp, GraphInterrupt from langgraph.graph import START, MessagesState, StateGraph from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages +from langgraph.runtime import ExecutionInfo, ServerInfo from langgraph.store.base import BaseStore from langgraph.store.memory import InMemoryStore from langgraph.types import Command, Send @@ -41,6 +44,7 @@ from langgraph.prebuilt import ( ) from langgraph.prebuilt.tool_node import ( TOOL_CALL_ERROR_TEMPLATE, + ToolCallRequest, ToolInvocationError, ToolRuntime, tools_condition, @@ -59,7 +63,6 @@ def _create_mock_runtime(store: BaseStore | None = None) -> Mock: which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"]. When testing ToolNode directly (outside a graph), we need to provide this manually. """ - from langgraph.runtime import ExecutionInfo mock_runtime = Mock() mock_runtime.store = store @@ -625,7 +628,6 @@ def test_tool_node_node_interrupt() -> None: @pytest.mark.parametrize("input_type", ["dict", "tool_calls"]) async def test_tool_node_command(input_type: str) -> None: - from langchain_core.tools.base import InjectedToolCallId @dec_tool def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): @@ -934,7 +936,6 @@ async def test_tool_node_command(input_type: str) -> None: async def test_tool_node_command_list_input() -> None: - from langchain_core.tools.base import InjectedToolCallId @dec_tool def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]): @@ -1194,7 +1195,6 @@ async def test_tool_node_command_list_input() -> None: def test_tool_node_parent_command_with_send() -> None: - from langchain_core.tools.base import InjectedToolCallId @dec_tool def transfer_to_alice(tool_call_id: Annotated[str, InjectedToolCallId]): @@ -1282,7 +1282,6 @@ def test_tool_node_parent_command_with_send() -> None: async def test_tool_node_command_remove_all_messages() -> None: - from langchain_core.tools.base import InjectedToolCallId @dec_tool def remove_all_messages_tool(tool_call_id: Annotated[str, InjectedToolCallId]): @@ -1621,9 +1620,6 @@ def test_tool_node_stream_writer() -> None: def test_tool_call_request_setattr_deprecation_warning(): """Test that ToolCallRequest raises a deprecation warning on direct attribute modification.""" - import warnings - - from langgraph.prebuilt.tool_node import ToolCallRequest # Create a mock ToolCall tool_call = {"name": "test", "args": {"a": 1}, "id": "call_1", "type": "tool_call"} @@ -2031,7 +2027,6 @@ def test_tool_runtime_defaults_tools_to_empty_list() -> None: def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None: """Test that execution_info, server_info, and tools are forwarded from Runtime to ToolRuntime.""" - from langgraph.runtime import ExecutionInfo, ServerInfo exec_info = ExecutionInfo( thread_id="t-1", @@ -2088,7 +2083,6 @@ async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async( None ): """Test that execution_info, server_info, and tools are forwarded in async path.""" - from langgraph.runtime import ExecutionInfo, ServerInfo exec_info = ExecutionInfo( thread_id="t-2", diff --git a/libs/sdk-py/pyproject.toml b/libs/sdk-py/pyproject.toml index b90676ea0..eba9d8124 100644 --- a/libs/sdk-py/pyproject.toml +++ b/libs/sdk-py/pyproject.toml @@ -80,6 +80,7 @@ select = [ "SIM", # flake8-simplify (code simplification) "RUF", # ruff-specific rules "S101", # flake8-bandit: use of assert + "PLC0415", # import-outside-top-level ] ignore = [ "E501", # line too long (handled by formatter) @@ -87,7 +88,10 @@ ignore = [ "B904", # raise without from inside except (sometimes intentional) "SIM102", # nested if statements (sometimes clearer) ] -per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002"] } +# PLC0415 (import-outside-top-level) is enforced in tests only. Library code +# still has deferred imports that have not been reviewed, so it stays exempt +# for now. +per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002", "PLC0415"], "langgraph_sdk/**" = ["PLC0415"] } [tool.ty.src] # The `integration/` graphs run inside the docker image (with `deepagents` diff --git a/libs/sdk-py/tests/integration/conftest.py b/libs/sdk-py/tests/integration/conftest.py index 29eea134e..0fb8bf468 100644 --- a/libs/sdk-py/tests/integration/conftest.py +++ b/libs/sdk-py/tests/integration/conftest.py @@ -18,6 +18,11 @@ from collections.abc import AsyncIterator, Iterator import httpx import pytest +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.threads import SyncThreadsClient + BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024") ASSISTANT_ID = "agent" TOOLS_ASSISTANT_ID = "tools_agent" @@ -47,8 +52,6 @@ def _require_running_api() -> None: @pytest.fixture async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]: """Build an async ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw.""" - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) try: @@ -60,8 +63,6 @@ async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]: @pytest.fixture def sync_threads() -> Iterator[tuple[object, httpx.Client]]: """Build a sync ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw.""" - from langgraph_sdk._sync.http import SyncHttpClient - from langgraph_sdk._sync.threads import SyncThreadsClient raw = httpx.Client(base_url=BASE_URL, timeout=30.0) try: diff --git a/libs/sdk-py/tests/integration/test_assistants.py b/libs/sdk-py/tests/integration/test_assistants.py index d71787732..430f65dff 100644 --- a/libs/sdk-py/tests/integration/test_assistants.py +++ b/libs/sdk-py/tests/integration/test_assistants.py @@ -9,21 +9,22 @@ from __future__ import annotations import pytest +from langgraph_sdk._async.assistants import AssistantsClient +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._sync.assistants import SyncAssistantsClient +from langgraph_sdk._sync.http import SyncHttpClient + from .conftest import ASSISTANT_ID pytestmark = pytest.mark.integration def _async_assistants(raw): - from langgraph_sdk._async.assistants import AssistantsClient - from langgraph_sdk._async.http import HttpClient return AssistantsClient(HttpClient(raw)) def _sync_assistants(raw): - from langgraph_sdk._sync.assistants import SyncAssistantsClient - from langgraph_sdk._sync.http import SyncHttpClient return SyncAssistantsClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_cancel.py b/libs/sdk-py/tests/integration/test_cancel.py index 995f1b26a..822897161 100644 --- a/libs/sdk-py/tests/integration/test_cancel.py +++ b/libs/sdk-py/tests/integration/test_cancel.py @@ -10,6 +10,11 @@ from typing import Any import pytest +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.runs import SyncRunsClient + from .conftest import ASSISTANT_ID pytestmark = pytest.mark.integration @@ -29,8 +34,6 @@ async def _cancel_after_first_event( async def test_cancel_async(async_threads) -> None: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.runs import RunsClient threads, raw = async_threads runs_client = RunsClient(HttpClient(raw)) @@ -88,8 +91,6 @@ def _cancel_after_first_event_sync( def test_cancel_sync(sync_threads) -> None: - from langgraph_sdk._sync.http import SyncHttpClient - from langgraph_sdk._sync.runs import SyncRunsClient threads, raw = sync_threads runs_client = SyncRunsClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_crons.py b/libs/sdk-py/tests/integration/test_crons.py index 90d81b4ca..64b842634 100644 --- a/libs/sdk-py/tests/integration/test_crons.py +++ b/libs/sdk-py/tests/integration/test_crons.py @@ -10,21 +10,22 @@ from __future__ import annotations import pytest +from langgraph_sdk._async.cron import CronClient +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._sync.cron import SyncCronClient +from langgraph_sdk._sync.http import SyncHttpClient + from .conftest import ASSISTANT_ID pytestmark = pytest.mark.integration def _async_crons(raw): - from langgraph_sdk._async.cron import CronClient - from langgraph_sdk._async.http import HttpClient return CronClient(HttpClient(raw)) def _sync_crons(raw): - from langgraph_sdk._sync.cron import SyncCronClient - from langgraph_sdk._sync.http import SyncHttpClient return SyncCronClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_factory_graph.py b/libs/sdk-py/tests/integration/test_factory_graph.py index 5ffd0d957..c45933b9f 100644 --- a/libs/sdk-py/tests/integration/test_factory_graph.py +++ b/libs/sdk-py/tests/integration/test_factory_graph.py @@ -13,21 +13,22 @@ from __future__ import annotations import pytest +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.runs import SyncRunsClient + from .conftest import FACTORY_ASSISTANT_ID pytestmark = pytest.mark.integration def _async_runs(raw): - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.runs import RunsClient return RunsClient(HttpClient(raw)) def _sync_runs(raw): - from langgraph_sdk._sync.http import SyncHttpClient - from langgraph_sdk._sync.runs import SyncRunsClient return SyncRunsClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_runs.py b/libs/sdk-py/tests/integration/test_runs.py index 8a6404530..83dcc32ce 100644 --- a/libs/sdk-py/tests/integration/test_runs.py +++ b/libs/sdk-py/tests/integration/test_runs.py @@ -11,21 +11,22 @@ from __future__ import annotations import pytest +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.runs import SyncRunsClient + from .conftest import ASSISTANT_ID pytestmark = pytest.mark.integration def _async_runs(raw): - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.runs import RunsClient return RunsClient(HttpClient(raw)) def _sync_runs(raw): - from langgraph_sdk._sync.http import SyncHttpClient - from langgraph_sdk._sync.runs import SyncRunsClient return SyncRunsClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_store.py b/libs/sdk-py/tests/integration/test_store.py index bb80be63f..03493ab5a 100644 --- a/libs/sdk-py/tests/integration/test_store.py +++ b/libs/sdk-py/tests/integration/test_store.py @@ -10,19 +10,20 @@ import uuid import pytest +from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.store import StoreClient +from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.store import SyncStoreClient + pytestmark = pytest.mark.integration def _async_store(raw): - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.store import StoreClient return StoreClient(HttpClient(raw)) def _sync_store(raw): - from langgraph_sdk._sync.http import SyncHttpClient - from langgraph_sdk._sync.store import SyncStoreClient return SyncStoreClient(SyncHttpClient(raw)) diff --git a/libs/sdk-py/tests/integration/test_websocket.py b/libs/sdk-py/tests/integration/test_websocket.py index 39d5dfa50..defeb21ed 100644 --- a/libs/sdk-py/tests/integration/test_websocket.py +++ b/libs/sdk-py/tests/integration/test_websocket.py @@ -4,6 +4,11 @@ from __future__ import annotations import pytest +from langgraph_sdk.stream.transport import ( + ProtocolWebSocketTransport, + SyncProtocolWebSocketTransport, +) + from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS pytestmark = pytest.mark.integration @@ -14,8 +19,6 @@ async def test_websocket_async(async_threads) -> None: async with threads.stream( assistant_id=ASSISTANT_ID, transport="websocket" ) as thread: - from langgraph_sdk.stream.transport import ProtocolWebSocketTransport - assert isinstance(thread._transport, ProtocolWebSocketTransport) await thread.run.start(input={"messages": [], "value": "init", "items": []}) @@ -34,8 +37,6 @@ async def test_websocket_async(async_threads) -> None: def test_websocket_sync(sync_threads) -> None: threads, _ = sync_threads with threads.stream(assistant_id=ASSISTANT_ID, transport="websocket") as thread: - from langgraph_sdk.stream.transport import SyncProtocolWebSocketTransport - assert isinstance(thread._transport, SyncProtocolWebSocketTransport) thread.run.start(input={"messages": [], "value": "init", "items": []}) diff --git a/libs/sdk-py/tests/streaming/test_controller.py b/libs/sdk-py/tests/streaming/test_controller.py index e9c71670e..98ec1d35a 100644 --- a/libs/sdk-py/tests/streaming/test_controller.py +++ b/libs/sdk-py/tests/streaming/test_controller.py @@ -3,14 +3,21 @@ from __future__ import annotations import asyncio +import asyncio as _asyncio +import logging from collections.abc import AsyncIterator from typing import Any from unittest.mock import AsyncMock +import httpx import pytest -from langgraph_sdk.stream.controller import StreamController, _SeenEventIds -from langgraph_sdk.stream.transport.http import EventStreamHandle +from langgraph_sdk.stream.controller import ( + StreamController, + _close_after, + _SeenEventIds, +) +from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport # --------------------------------------------------------------------------- # Task 3.1: bounded subscription queues @@ -20,9 +27,6 @@ from langgraph_sdk.stream.transport.http import EventStreamHandle @pytest.mark.asyncio async def test_subscription_queue_bounded_by_max_queue_size(): """`StreamController` must create per-subscription queues bounded by `max_queue_size`.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport transport = ProtocolSseTransport( client=httpx.AsyncClient(base_url="http://test"), @@ -36,9 +40,6 @@ async def test_subscription_queue_bounded_by_max_queue_size(): @pytest.mark.asyncio async def test_subscription_queue_default_max_queue_size_is_1024(): """`StreamController` default `max_queue_size` is 1024.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport transport = ProtocolSseTransport( client=httpx.AsyncClient(base_url="http://test"), @@ -114,12 +115,6 @@ def test_seen_event_ids_iter_returns_keys(): async def test_close_awaits_pending_rotation_closes(): """When a rotation is mid-flight, controller.close() must await the old stream close before returning.""" - import asyncio as _asyncio - - import httpx - - from langgraph_sdk.stream.controller import _close_after - from langgraph_sdk.stream.transport.http import ProtocolSseTransport rotation_close_done = _asyncio.Event() @@ -269,7 +264,6 @@ async def test_reconnect_accepts_backoff_kwargs(): @pytest.mark.anyio async def test_transport_drop_exception_logged_with_type(monkeypatch, caplog): """Bare `pass` discarded exception types; the drop should at least log.""" - import logging monkeypatch.setattr("asyncio.sleep", AsyncMock()) diff --git a/libs/sdk-py/tests/streaming/test_decoders.py b/libs/sdk-py/tests/streaming/test_decoders.py index c1ebcd98d..7b706977d 100644 --- a/libs/sdk-py/tests/streaming/test_decoders.py +++ b/libs/sdk-py/tests/streaming/test_decoders.py @@ -8,6 +8,8 @@ from __future__ import annotations from typing import Any +import pytest + from langgraph_sdk.stream.decoders import ( DataDecoder, ExtensionsDecoder, @@ -454,7 +456,6 @@ def test_extensions_decoder_ignores_non_dict_data(): def test_extensions_decoder_rejects_empty_name(): - import pytest with pytest.raises(ValueError): ExtensionsDecoder(name="") diff --git a/libs/sdk-py/tests/streaming/test_extensions_projection.py b/libs/sdk-py/tests/streaming/test_extensions_projection.py index 16a8fd0c1..05bbb3ac5 100644 --- a/libs/sdk-py/tests/streaming/test_extensions_projection.py +++ b/libs/sdk-py/tests/streaming/test_extensions_projection.py @@ -3,6 +3,7 @@ from __future__ import annotations import httpx from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.stream import ScopedStreamHandle from langgraph_sdk._async.threads import ThreadsClient from streaming._events import custom_event, lifecycle_completed_event from streaming._fake_server import FakeServer @@ -46,8 +47,6 @@ async def test_extension_projection_supports_namespace_scope_on_subgraph_handle( ) transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.stream import ScopedStreamHandle - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: await thread.run.start(input={}) diff --git a/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py b/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py index 2a6200406..2d759361a 100644 --- a/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py +++ b/libs/sdk-py/tests/streaming/test_lifecycle_watcher.py @@ -7,9 +7,11 @@ import contextlib from typing import Any import httpx +import pytest from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.threads import ThreadsClient +from langgraph_sdk.stream.transport import EventStreamHandle, ProtocolSseTransport from streaming._events import ( input_requested_event, lifecycle_completed_event, @@ -153,7 +155,6 @@ async def test_lifecycle_clean_eof_resolves_run_done_with_errored(): """If the lifecycle SSE stream ends cleanly (server closes without a terminal `completed` or `errored` event), `_run_done` must resolve with an errored terminal so awaiters don't hang.""" - import pytest fake = FakeServer() # Emit a non-terminal lifecycle event, then close cleanly without @@ -179,7 +180,6 @@ async def test_lifecycle_mid_iteration_error_resolves_run_done_with_error( """If the transport reports an error via `handle.done` after iteration exits without a terminal lifecycle event, `_run_done` propagates the transport error rather than the generic clean-EOF message.""" - from langgraph_sdk.stream.transport import EventStreamHandle, ProtocolSseTransport def synthetic_handle() -> EventStreamHandle: loop = asyncio.get_running_loop() diff --git a/libs/sdk-py/tests/streaming/test_scoped_handles.py b/libs/sdk-py/tests/streaming/test_scoped_handles.py index 13955b225..8479315db 100644 --- a/libs/sdk-py/tests/streaming/test_scoped_handles.py +++ b/libs/sdk-py/tests/streaming/test_scoped_handles.py @@ -2,12 +2,16 @@ from __future__ import annotations +from unittest.mock import MagicMock + import httpx from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.stream import ScopedStreamHandle from langgraph_sdk._async.threads import ThreadsClient from streaming._events import ( lifecycle_completed_event, + lifecycle_errored_event, lifecycle_started_event, message_finish_event, message_start_event, @@ -430,9 +434,6 @@ async def test_grandchild_events_dispatched_to_correct_sibling_not_first_match() def test_scoped_handle_inboxes_bounded_by_max_queue_size(): """ScopedStreamHandle with max_queue_size=N creates queues with maxsize=N.""" - from unittest.mock import MagicMock - - from langgraph_sdk._async.stream import ScopedStreamHandle fake_thread = MagicMock() handle = ScopedStreamHandle( @@ -484,7 +485,6 @@ async def test_force_complete_uses_failed_when_run_errored(): """If the lifecycle signals an errored run, scoped children that are still 'started' when the subgraphs projection's finally block runs must be force-finished as 'failed', not 'completed'.""" - from streaming._events import lifecycle_errored_event fake = FakeServer() fake.script( @@ -538,9 +538,6 @@ async def test_force_complete_uses_completed_when_run_completed(): def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes(): """_close_inboxes must not push a sentinel on inboxes that had no consumer.""" - from unittest.mock import MagicMock - - from langgraph_sdk._async.stream import ScopedStreamHandle fake_thread = MagicMock() handle = ScopedStreamHandle( @@ -559,9 +556,6 @@ def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes(): def test_close_inboxes_enqueues_sentinel_on_iterated_inboxes(): """_close_inboxes must push a None sentinel only on inboxes that had a consumer, so projection iterators see the EOF signal.""" - from unittest.mock import MagicMock - - from langgraph_sdk._async.stream import ScopedStreamHandle fake_thread = MagicMock() handle = ScopedStreamHandle( diff --git a/libs/sdk-py/tests/streaming/test_shared_stream.py b/libs/sdk-py/tests/streaming/test_shared_stream.py index 1a96a91bd..75835444c 100644 --- a/libs/sdk-py/tests/streaming/test_shared_stream.py +++ b/libs/sdk-py/tests/streaming/test_shared_stream.py @@ -3,14 +3,15 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator from typing import Any, cast +from unittest.mock import MagicMock import httpx from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.threads import ThreadsClient from langgraph_sdk.stream.controller import StreamController -from langgraph_sdk.stream.transport.http import EventStreamHandle -from streaming._events import lifecycle_event, values_event +from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport +from streaming._events import lifecycle_completed_event, lifecycle_event, values_event from streaming._fake_server import FakeServer, _StreamScript @@ -165,7 +166,6 @@ async def test_values_projection_registers_via_delegation_not_controller_directl directly — the subscription count seen through the thread wrapper equals the count inside the controller at the moment the subscription is live. """ - from streaming._events import lifecycle_completed_event fake = FakeServer() fake.script([lifecycle_completed_event(seq=0)]) @@ -255,10 +255,6 @@ async def test_shared_stream_reconnects_with_since_after_transport_drop(): handle2, _ = _make_handle([values_event(seq=2, values={"counter": 2})]) handles = [handle1, handle2] - from unittest.mock import MagicMock - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport - transport = MagicMock(spec=ProtocolSseTransport) def _open(params: dict[str, Any]) -> EventStreamHandle: @@ -303,10 +299,6 @@ async def test_shared_stream_reconnect_dedupes_replayed_overlap(): ) handles = [handle1, handle2] - from unittest.mock import MagicMock - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport - transport = MagicMock(spec=ProtocolSseTransport) transport.open_event_stream.side_effect = lambda _params: handles.pop(0) diff --git a/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py index 808846aee..926dc0a5d 100644 --- a/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py +++ b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py @@ -3,6 +3,7 @@ from __future__ import annotations import httpx from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.stream import SyncScopedStreamHandle from langgraph_sdk._sync.threads import SyncThreadsClient from streaming._events import custom_event, lifecycle_completed_event from streaming._sync_fake_server import SyncFakeServer @@ -41,8 +42,6 @@ def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle() ] ) with httpx.Client(transport=fake.transport, base_url="http://test") as raw: - from langgraph_sdk._sync.stream import SyncScopedStreamHandle - threads = SyncThreadsClient(SyncHttpClient(raw)) with threads.stream(thread_id="t-1", assistant_id="agent") as thread: thread.run.start(input={}) diff --git a/libs/sdk-py/tests/streaming/test_sync_projections.py b/libs/sdk-py/tests/streaming/test_sync_projections.py index 07708793e..f22c529e4 100644 --- a/libs/sdk-py/tests/streaming/test_sync_projections.py +++ b/libs/sdk-py/tests/streaming/test_sync_projections.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time +from collections.abc import Generator from typing import Any, cast import httpx @@ -10,6 +12,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream from langchain_protocol import Event from langgraph_sdk._sync.http import SyncHttpClient +from langgraph_sdk._sync.stream import SyncToolCallHandle from langgraph_sdk._sync.threads import SyncThreadsClient from streaming._events import ( lifecycle_completed_event, @@ -459,11 +462,6 @@ def test_sync_tool_calls_explicit_close_does_not_block_1s(): tool_started_event(seq=1, tool_call_id="call-1"), ] ) - import time - from collections.abc import Generator - from typing import cast - - from langgraph_sdk._sync.stream import SyncToolCallHandle with httpx.Client(transport=fake.transport, base_url="http://test") as raw: threads = SyncThreadsClient(SyncHttpClient(raw)) @@ -528,7 +526,6 @@ def test_sync_tool_call_handle_deltas_queue_is_bounded(): Unbounded queues allow producers to enqueue indefinitely, causing memory growth when consumers are slow. """ - from langgraph_sdk._sync.stream import SyncToolCallHandle handle_default = SyncToolCallHandle(tool_call_id="tc1", name="foo") assert handle_default._deltas.maxsize > 0, ( @@ -552,7 +549,6 @@ def test_sync_tool_call_handle_deltas_single_consumer_guard(): The property must raise before returning the iterator so the caller sees the error even without iterating. """ - from langgraph_sdk._sync.stream import SyncToolCallHandle handle = SyncToolCallHandle(tool_call_id="tc1", name="foo") diff --git a/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py b/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py index 198ee2e8b..8c3fc04e6 100644 --- a/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py +++ b/libs/sdk-py/tests/streaming/test_sync_scoped_handles.py @@ -6,12 +6,14 @@ import threading from concurrent.futures import ThreadPoolExecutor, wait import httpx +from langchain_protocol import Event from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk._sync.stream import SyncScopedStreamHandle from langgraph_sdk._sync.threads import SyncThreadsClient from streaming._events import ( lifecycle_completed_event, + lifecycle_errored_event, lifecycle_started_event, message_finish_event, message_start_event, @@ -356,7 +358,6 @@ def test_sync_register_descendant_forwards_buffered_events_in_order(): """_register_descendant must drain already-buffered events whose namespace matches the new grandchild, push them into the grandchild, and preserve the original arrival order in the parent inbox.""" - from langchain_protocol import Event parent = SyncScopedStreamHandle( thread=None, # ty: ignore[invalid-argument-type] @@ -673,7 +674,6 @@ def test_sync_force_complete_uses_failed_when_run_errored(): """If the lifecycle signals an errored run, scoped children that are still 'started' when the subgraphs iterator's finally block runs must be force-finished as 'failed', not 'completed'.""" - from streaming._events import lifecycle_errored_event fake = SyncFakeServer() fake.script( diff --git a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py index 72221d8d9..59a02da5d 100644 --- a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py @@ -2,21 +2,43 @@ from __future__ import annotations +import queue import re import threading import time import uuid from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from typing import Any import httpx +import orjson import pytest +import langgraph_sdk.stream.sync_controller as _ctrl_mod from langgraph_sdk._sync.http import SyncHttpClient from langgraph_sdk._sync.threads import SyncThreadsClient +from langgraph_sdk.stream.sync_controller import SyncStreamController from langgraph_sdk.stream.transport.sync_http import ( SyncEventStreamHandle, SyncProtocolSseTransport, ) +from streaming._events import ( + checkpoints_event, + custom_event, + lifecycle_completed_event, + lifecycle_event, + lifecycle_started_event, + message_finish_event, + message_start_event, + message_text_delta_event, + message_text_finish_event, + tasks_start_event, + tool_finished_event, + tool_started_event, + updates_event, + values_event, +) from streaming._sync_fake_server import SyncFakeServer, SyncStreamScript # --------------------------------------------------------------------------- @@ -71,14 +93,10 @@ def test_sync_subscribe_before_run_start_waits_on_gate(): def test_sync_reconnect_uses_backoff_between_attempts(monkeypatch): """_reconnect_shared_stream sleeps between retry attempts with exp+jitter backoff, mirroring the async reconnect behavior.""" - import langgraph_sdk.stream.sync_controller as _ctrl_mod sleeps: list[float] = [] monkeypatch.setattr(_ctrl_mod.time, "sleep", lambda d: sleeps.append(d)) - from langgraph_sdk.stream.sync_controller import SyncStreamController - from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport - class _FailingTransport(SyncProtocolSseTransport): """Transport that always raises on open_event_stream.""" @@ -113,15 +131,6 @@ def test_sync_rotation_does_not_lose_buffered_events(): """When the shared stream rotates, old-stream events already in the queue are not dropped. _drain_and_close dispatches remaining events from the old handle to subscribers before closing it.""" - import queue - from typing import Any - - from langgraph_sdk.stream.sync_controller import SyncStreamController - from langgraph_sdk.stream.transport.sync_http import ( - SyncEventStreamHandle, - SyncProtocolSseTransport, - ) - from streaming._events import values_event event_a = values_event(seq=1, counter=1) @@ -188,8 +197,6 @@ def test_sync_rotation_does_not_lose_buffered_events(): def test_sync_concurrent_commands_do_not_share_command_id(): """50 concurrent threads calling _send_command must each get a unique id.""" - from concurrent.futures import ThreadPoolExecutor - from typing import Any captured_ids: list[int] = [] ids_lock = threading.Lock() @@ -246,7 +253,6 @@ def test_sync_events_returns_fresh_iterator_each_access(): """Two accesses of `thread.events` yield independent subscriptions, mirroring the async semantics where each access opens a new subscriber.""" fake = SyncFakeServer() - from streaming._events import values_event event_1 = values_event(seq=1, counter=1) fake.script_sequence( @@ -280,7 +286,6 @@ def test_close_unblocks_active_subscription_before_lifecycle_join(): """close() must send None to active subscriptions BEFORE joining the lifecycle watcher thread, so callers wake quickly even if the watcher thread blocks for up to 1s.""" - import queue # Gate that keeps the lifecycle watcher thread alive for 0.4s. lifecycle_block = threading.Event() @@ -293,8 +298,6 @@ def test_close_unblocks_active_subscription_before_lifecycle_join(): def _handle(self, request: httpx.Request) -> httpx.Response: path = request.url.path if path.endswith("/stream/events"): - import orjson - body = orjson.loads(request.content) channels = body.get("channels", []) if "lifecycle" in channels: @@ -417,7 +420,6 @@ def test_sync_threads_stream_mints_uuid4_when_thread_id_none(): def test_sync_run_start_sends_command(): - from streaming._events import lifecycle_completed_event fake = SyncFakeServer() fake.script([lifecycle_completed_event(seq=1)]) @@ -432,7 +434,6 @@ def test_sync_run_start_sends_command(): def test_sync_events_iterates_raw_events(): - from streaming._events import values_event fake = SyncFakeServer() fake.script([values_event(seq=1, counter=1)]) @@ -446,7 +447,6 @@ def test_sync_events_iterates_raw_events(): def test_sync_lifecycle_watcher_reconnects_with_since_after_transport_drop(): - from streaming._events import lifecycle_completed_event, lifecycle_event fake = SyncFakeServer() fake.set_state({"ok": True}) @@ -481,7 +481,6 @@ def test_sync_threads_stream_accepts_websocket_transport_option(): def test_sync_threads_stream_rejects_unknown_transport_option(): - import pytest with httpx.Client(base_url="http://test") as raw: threads = SyncThreadsClient(SyncHttpClient(raw)) @@ -494,17 +493,6 @@ def test_sync_threads_stream_rejects_unknown_transport_option(): def test_v3_streaming_sync_surface_smoke(): - from streaming._events import ( - custom_event, - lifecycle_completed_event, - message_finish_event, - message_start_event, - message_text_delta_event, - message_text_finish_event, - tool_finished_event, - tool_started_event, - values_event, - ) fake = SyncFakeServer() fake.set_state({"final": True}) @@ -610,11 +598,6 @@ def test_v3_streaming_sync_surface_smoke(): def test_interleave_projections_single_channel_values(): - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - values_event, - ) fake = SyncFakeServer() fake.set_state({"counter": 0}) @@ -639,13 +622,6 @@ def test_interleave_projections_single_channel_values(): def test_interleave_projections_values_and_messages_arrival_order(): - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - message_finish_event, - message_start_event, - values_event, - ) fake = SyncFakeServer() fake.set_state({"counter": 0}) @@ -672,12 +648,6 @@ def test_interleave_projections_values_and_messages_arrival_order(): def test_interleave_projections_mixes_builtin_and_extension(): - from streaming._events import ( - custom_event, - lifecycle_completed_event, - lifecycle_started_event, - values_event, - ) fake = SyncFakeServer() fake.set_state({"counter": 0}) @@ -701,12 +671,6 @@ def test_interleave_projections_mixes_builtin_and_extension(): def test_interleave_projections_tool_calls_uses_public_name(): - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - tool_finished_event, - tool_started_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -735,10 +699,6 @@ def test_interleave_projections_tool_calls_uses_public_name(): def test_interleave_projections_subgraphs_discovers_child(): - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -761,11 +721,6 @@ def test_interleave_projections_subgraphs_discovers_child(): def test_interleave_projections_inflight_tool_call_failed_on_break(): """A tool handle held past an early break is failed in teardown, never left hanging.""" - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - tool_started_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -794,10 +749,6 @@ def test_interleave_projections_inflight_tool_call_failed_on_break(): def test_interleave_projections_inflight_subgraph_finished_on_terminal(): """A discovered subgraph child with no terminal tasks-result is force-completed.""" - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -829,10 +780,6 @@ def test_interleave_projections_rejects_reserved_channel(channel): would subscribe to a channel that never matches and yield nothing. Fail closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.) """ - from streaming._events import ( - lifecycle_completed_event, - lifecycle_started_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -849,13 +796,6 @@ def test_interleave_projections_rejects_reserved_channel(channel): def test_interleave_projections_data_channels_yield_payloads(): """`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads.""" - from streaming._events import ( - checkpoints_event, - lifecycle_completed_event, - lifecycle_started_event, - tasks_start_event, - updates_event, - ) fake = SyncFakeServer() fake.set_state({}) @@ -882,11 +822,6 @@ def test_interleave_projections_data_channels_yield_payloads(): def test_interleave_projections_data_channel_scoped_to_root_namespace(): """A child-namespace checkpoint must not leak into a root interleave.""" - from streaming._events import ( - checkpoints_event, - lifecycle_completed_event, - lifecycle_started_event, - ) fake = SyncFakeServer() fake.set_state({"counter": 0}) diff --git a/libs/sdk-py/tests/streaming/test_sync_transport_ws.py b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py index 72584b325..bf06abbfb 100644 --- a/libs/sdk-py/tests/streaming/test_sync_transport_ws.py +++ b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py @@ -6,8 +6,10 @@ import httpx import orjson import pytest +from langgraph_sdk.stream.sync_controller import SyncStreamController from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport from streaming._events import values_event +from streaming._sync_fake_server import SyncFakeServer class _FakeSyncWebSocket: @@ -102,7 +104,6 @@ def test_sync_websocket_records_post_ready_error(): def test_sync_websocket_send_command_uses_http_commands_endpoint(): - from streaming._sync_fake_server import SyncFakeServer fake = SyncFakeServer() with httpx.Client(transport=fake.transport, base_url="http://test") as client: @@ -124,7 +125,6 @@ def test_sync_websocket_open_event_stream_raises_when_closed(): def test_sync_websocket_transport_feeds_sync_stream_controller(): - from langgraph_sdk.stream.sync_controller import SyncStreamController socket = _FakeSyncWebSocket( [ @@ -164,7 +164,6 @@ def test_sync_websocket_transport_feeds_sync_stream_controller(): def test_sync_websocket_controller_reconnects_with_since_after_drop(): - from langgraph_sdk.stream.sync_controller import SyncStreamController first_socket = _FakeSyncWebSocket( [values_event(seq=1, values={"counter": 1})], diff --git a/libs/sdk-py/tests/streaming/test_thread_stream.py b/libs/sdk-py/tests/streaming/test_thread_stream.py index eb71b3dbc..4617f4690 100644 --- a/libs/sdk-py/tests/streaming/test_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_thread_stream.py @@ -4,10 +4,14 @@ import asyncio import contextlib import re import uuid -from typing import Any +from typing import Any, cast import httpx import pytest +from langchain_protocol import Event +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route from langgraph_sdk._async.http import HttpClient from langgraph_sdk._async.stream import AsyncThreadStream @@ -24,6 +28,8 @@ from streaming._events import ( lifecycle_started_event, message_finish_event, message_start_event, + message_text_delta_event, + message_text_finish_event, tasks_start_event, tool_finished_event, tool_started_event, @@ -225,9 +231,6 @@ async def test_aenter_constructs_transport_with_thread_id(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) stream = threads.stream(thread_id="t-1", assistant_id="agent") async with stream: @@ -237,9 +240,6 @@ async def test_aenter_constructs_transport_with_thread_id(): async def test_aenter_selects_websocket_transport(): async with httpx.AsyncClient(base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) stream = threads.stream( thread_id="t-1", assistant_id="agent", transport="websocket" @@ -252,9 +252,6 @@ async def test_aexit_closes_transport(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) stream = threads.stream(thread_id="t-1", assistant_id="agent") async with stream: @@ -268,9 +265,6 @@ async def test_run_start_sends_command_with_assistant_id(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: result = await thread.run.start(input={"x": 1}) @@ -286,9 +280,6 @@ async def test_command_ids_are_monotonic(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: await thread.run.start(input={"x": 1}) @@ -300,9 +291,6 @@ async def test_run_start_forwards_config_and_metadata(): fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: await thread.run.start( @@ -316,7 +304,6 @@ async def test_run_start_forwards_config_and_metadata(): async def test_run_start_raises_outside_context_manager(): - import pytest async with httpx.AsyncClient(base_url="http://test") as raw: stream = AsyncThreadStream( @@ -327,9 +314,6 @@ async def test_run_start_raises_outside_context_manager(): async def test_run_start_raises_on_error_envelope(): - from starlette.applications import Starlette - from starlette.responses import JSONResponse - from starlette.routing import Route async def commands(_request): return JSONResponse( @@ -346,11 +330,6 @@ async def test_run_start_raises_on_error_envelope(): ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - import pytest - - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: with pytest.raises(RuntimeError, match="invalid_argument"): @@ -367,9 +346,6 @@ async def test_events_yields_raw_events_after_run_start(): ) transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: await thread.run.start(input={}) @@ -383,9 +359,6 @@ async def test_events_subscribes_to_all_channels(): fake.script([]) transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: await thread.run.start(input={}) @@ -405,17 +378,11 @@ async def test_events_subscribes_to_all_channels(): async def test_events_terminates_on_aexit(): - import asyncio - - import pytest fake = FakeServer() fake.script([lifecycle_event(seq=i) for i in range(5)]) transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) stream = threads.stream(thread_id="t-1", assistant_id="agent") async with stream as thread: @@ -462,9 +429,6 @@ async def test_events_property_returns_fresh_iterator_each_access(): fake.script([]) transport = httpx.ASGITransport(app=fake.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw: - from langgraph_sdk._async.http import HttpClient - from langgraph_sdk._async.threads import ThreadsClient - threads = ThreadsClient(HttpClient(raw)) async with threads.stream(thread_id="t-1", assistant_id="agent") as thread: first_iter = thread.events @@ -549,7 +513,6 @@ async def test_unregister_subscription_removes_from_registry(): async def test_await_run_start_gate_honors_timeout(): """Gate must raise asyncio.TimeoutError if run.start never completes within the configured timeout.""" - import asyncio async with httpx.AsyncClient(base_url="http://test") as raw: threads = ThreadsClient(HttpClient(raw)) @@ -568,7 +531,6 @@ async def test_await_run_start_gate_honors_timeout(): async def test_await_run_start_gate_returns_when_gate_resolves_in_time(): """With a generous timeout and a gate that resolves promptly, the gate returns without raising.""" - import asyncio async with httpx.AsyncClient(base_url="http://test") as raw: threads = ThreadsClient(HttpClient(raw)) @@ -583,7 +545,6 @@ async def test_await_run_start_gate_returns_when_gate_resolves_in_time(): async def test_run_start_timeout_constructor_kwarg_forwarded_to_gate(): """`run_start_timeout` constructor kwarg is stored and consulted by `_reconcile_stream` via `_await_run_start_gate`.""" - import asyncio async with httpx.AsyncClient(base_url="http://test") as raw: stream = AsyncThreadStream( @@ -608,7 +569,6 @@ async def test_subscribe_waits_for_run_start_to_commit(): their SSE. Without it, a fast subscribe would 404 against a thread the server hasn't created yet. """ - import asyncio fake = FakeServer() fake.script([]) @@ -699,7 +659,6 @@ async def test_run_respond_snapshots_interrupts_under_lock(): `respond()` blocks until the lock is released — proving it serializes with the terminal-clear path that takes the same lock. """ - import asyncio fake = FakeServer() asgi = httpx.ASGITransport(app=fake.app) @@ -737,7 +696,6 @@ async def test_terminal_lifecycle_clear_acquires_interrupts_lock(): """Terminal lifecycle event clears `interrupts` under the same lock that `respond()` uses, preventing TOCTOU between snapshot and dispatch.""" - import asyncio fake = FakeServer() # No scripted events; we exercise `_apply_lifecycle_event` directly. @@ -754,10 +712,6 @@ async def test_terminal_lifecycle_clear_acquires_interrupts_lock(): # clearing interrupts. await thread._interrupts_lock.acquire() try: - from typing import cast - - from langchain_protocol import Event - terminal_event = cast( Event, { @@ -909,19 +863,6 @@ async def test_threads_stream_rejects_unknown_transport_option(): async def test_v3_streaming_async_surface_smoke(): - import asyncio - - from streaming._events import ( - custom_event, - lifecycle_completed_event, - message_finish_event, - message_start_event, - message_text_delta_event, - message_text_finish_event, - tool_finished_event, - tool_started_event, - values_event, - ) fake = FakeServer() fake.set_state({"final": True}) diff --git a/libs/sdk-py/tests/streaming/test_tool_calls_projection.py b/libs/sdk-py/tests/streaming/test_tool_calls_projection.py index f01e03d81..226eacc39 100644 --- a/libs/sdk-py/tests/streaming/test_tool_calls_projection.py +++ b/libs/sdk-py/tests/streaming/test_tool_calls_projection.py @@ -2,12 +2,15 @@ from __future__ import annotations +import asyncio import time +from collections.abc import AsyncGenerator import httpx import pytest from langgraph_sdk._async.http import HttpClient +from langgraph_sdk._async.stream import ToolCallHandle from langgraph_sdk._async.threads import ThreadsClient from streaming._events import ( lifecycle_completed_event, @@ -214,7 +217,6 @@ async def test_tool_calls_explicit_aclose_does_not_block_1s(): await thread.run.start(input={}) # _tool_calls_iter() is an AsyncGenerator; cast so the type checker # knows aclose() is available without a bare AsyncIterator protocol. - from collections.abc import AsyncGenerator gen: AsyncGenerator = thread.tool_calls._tool_calls_iter() _call = await gen.__anext__() # receive the one tool-started handle @@ -230,11 +232,9 @@ def test_tool_call_handle_deltas_queue_is_bounded(): Unbounded queues allow producers to enqueue indefinitely, causing memory growth when consumers are slow. """ - import asyncio # We need a running loop to create the Future inside ToolCallHandle.__init__. async def _make() -> None: - from langgraph_sdk._async.stream import ToolCallHandle handle_default = ToolCallHandle(tool_call_id="tc1", name="foo") assert handle_default._deltas.maxsize > 0, ( @@ -255,10 +255,8 @@ def test_tool_call_handle_deltas_single_consumer_guard(): The property must raise before returning the iterator so the caller sees the error even without iterating. """ - import asyncio async def _run() -> None: - from langgraph_sdk._async.stream import ToolCallHandle handle = ToolCallHandle(tool_call_id="tc1", name="foo") diff --git a/libs/sdk-py/tests/streaming/test_transport_http.py b/libs/sdk-py/tests/streaming/test_transport_http.py index 597136bc8..e97d66d67 100644 --- a/libs/sdk-py/tests/streaming/test_transport_http.py +++ b/libs/sdk-py/tests/streaming/test_transport_http.py @@ -6,8 +6,17 @@ import contextlib import httpx import orjson import pytest +from starlette.applications import Starlette +from starlette.responses import JSONResponse, Response +from starlette.routing import Route -from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport +from langgraph_sdk.stream.transport.http import ( + EventStreamHandle, + ProtocolSseTransport, + _build_event_stream_body, +) +from streaming._events import lifecycle_event, values_event +from streaming._fake_server import FakeServer async def test_event_stream_handle_constructs_with_open_state(): @@ -35,7 +44,6 @@ async def test_event_stream_handle_constructs_with_open_state(): async def test_send_command_posts_json_and_returns_response(): - from streaming._fake_server import FakeServer fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) @@ -53,9 +61,6 @@ async def test_send_command_posts_json_and_returns_response(): async def test_send_command_returns_none_on_202(): - from starlette.applications import Starlette - from starlette.responses import Response - from starlette.routing import Route received: list[dict] = [] @@ -75,7 +80,6 @@ async def test_send_command_returns_none_on_202(): async def test_send_command_raises_when_closed(): - from streaming._fake_server import FakeServer fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) @@ -87,9 +91,6 @@ async def test_send_command_raises_when_closed(): async def test_send_command_raises_http_error_on_4xx(): - from starlette.applications import Starlette - from starlette.responses import JSONResponse - from starlette.routing import Route async def commands(_request): return JSONResponse({"error": "bad request"}, status_code=400) @@ -105,8 +106,6 @@ async def test_send_command_raises_http_error_on_4xx(): async def test_open_event_stream_yields_scripted_events(): - from streaming._events import lifecycle_event, values_event - from streaming._fake_server import FakeServer fake = FakeServer() fake.script( @@ -129,7 +128,6 @@ async def test_open_event_stream_yields_scripted_events(): async def test_open_event_stream_passes_since_in_body(): - from streaming._fake_server import FakeServer fake = FakeServer() fake.script([]) @@ -145,8 +143,6 @@ async def test_open_event_stream_passes_since_in_body(): async def test_open_event_stream_close_cancels_in_flight_iteration(): - from streaming._events import lifecycle_event - from streaming._fake_server import FakeServer fake = FakeServer() fake.script( @@ -219,9 +215,6 @@ async def test_mid_stream_error_after_ready_surfaces_on_done(): """If the SSE response body iteration raises after headers/ready, the error must be exposed on handle.done so callers can distinguish a clean end from a transport failure.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport def handler(_request: httpx.Request) -> httpx.Response: async def body(): @@ -250,9 +243,6 @@ async def test_mid_stream_error_after_ready_surfaces_on_done(): @pytest.mark.anyio async def test_clean_stream_end_done_resolves_with_none(): """A stream that ends without error must resolve `done` with None.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport def handler(_request: httpx.Request) -> httpx.Response: async def body(): @@ -280,9 +270,6 @@ async def test_clean_stream_end_done_resolves_with_none(): async def test_send_command_empty_200_body_raises_runtime_error_not_decoder_error(): """A 200 response with empty body must raise RuntimeError matching the 'did not return a valid response' contract, not orjson.JSONDecodeError.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport def handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"") @@ -305,9 +292,6 @@ async def test_send_command_empty_200_body_raises_runtime_error_not_decoder_erro async def test_cancel_event_prevents_post_cancel_flush(): """When the consumer cancels the handle mid-stream, the pump's decoder flush MUST NOT emit additional events after the cancel point.""" - import httpx - - from langgraph_sdk.stream.transport.http import ProtocolSseTransport received: list = [] @@ -342,9 +326,6 @@ async def test_cancel_event_prevents_post_cancel_flush(): @pytest.mark.anyio async def test_open_event_stream_ready_rejects_on_5xx(): - from starlette.applications import Starlette - from starlette.responses import JSONResponse - from starlette.routing import Route async def stream_events(_request): return JSONResponse({"error": "boom"}, status_code=500) @@ -371,14 +352,12 @@ async def test_open_event_stream_ready_rejects_on_5xx(): def test_build_event_stream_body_minimal_channels_only(): - from langgraph_sdk.stream.transport.http import _build_event_stream_body body = _build_event_stream_body({"channels": ["values"]}) assert body == {"channels": ["values"]} def test_build_event_stream_body_includes_all_optional_fields(): - from langgraph_sdk.stream.transport.http import _build_event_stream_body body = _build_event_stream_body( { @@ -397,14 +376,12 @@ def test_build_event_stream_body_includes_all_optional_fields(): def test_build_event_stream_body_omits_since_when_not_int(): - from langgraph_sdk.stream.transport.http import _build_event_stream_body body = _build_event_stream_body({"channels": ["values"], "since": None}) assert "since" not in body async def test_open_event_stream_raises_when_closed(): - from streaming._fake_server import FakeServer fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) @@ -416,8 +393,6 @@ async def test_open_event_stream_raises_when_closed(): async def test_transport_close_cancels_open_event_streams(): - from streaming._events import lifecycle_event - from streaming._fake_server import FakeServer fake = FakeServer() fake.script([lifecycle_event(seq=i) for i in range(5)], delay=0.05) @@ -439,7 +414,6 @@ async def test_transport_close_cancels_open_event_streams(): async def test_default_headers_forwarded_to_send_command(): """Headers passed at construction are sent on every command request.""" - from streaming._fake_server import FakeServer fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) @@ -457,7 +431,6 @@ async def test_default_headers_forwarded_to_send_command(): async def test_default_headers_forwarded_to_open_event_stream(): """Headers passed at construction are sent on every SSE stream request.""" - from streaming._fake_server import FakeServer fake = FakeServer() fake.script([]) @@ -479,7 +452,6 @@ async def test_default_headers_forwarded_to_open_event_stream(): async def test_default_headers_cannot_override_sse_fixed_headers(): """Caller-supplied default headers must not override content-type or accept.""" - from streaming._fake_server import FakeServer fake = FakeServer() fake.script([]) @@ -506,7 +478,6 @@ async def test_default_headers_cannot_override_sse_fixed_headers(): async def test_fake_server_state_endpoint(): """State endpoint returns the set state and increments the counter.""" - from streaming._fake_server import FakeServer fake = FakeServer() fake.set_state({"foo": "bar"}, next=["node_a"]) @@ -527,7 +498,6 @@ async def test_fake_server_state_endpoint(): def test_values_event_builder_shape(): """values_event produces the expected shape with params.data as the snapshot.""" - from streaming._events import values_event evt = values_event(seq=1, values={"foo": 1}) assert evt["event_id"] == "evt-1" @@ -537,13 +507,11 @@ def test_values_event_builder_shape(): async def test_open_event_stream_done_records_post_ready_error(): - from streaming._events import values_event event_data = values_event(seq=1) class _FailAfterOneStream(httpx.AsyncByteStream): async def __aiter__(self): - import orjson payload = orjson.dumps(event_data).decode() yield f"id: {event_data.get('event_id', '')}\n".encode() diff --git a/libs/sdk-py/tests/streaming/test_transport_ws.py b/libs/sdk-py/tests/streaming/test_transport_ws.py index 8844b75e0..391284d20 100644 --- a/libs/sdk-py/tests/streaming/test_transport_ws.py +++ b/libs/sdk-py/tests/streaming/test_transport_ws.py @@ -10,8 +10,10 @@ import pytest from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK from websockets.frames import Close +from langgraph_sdk.stream.controller import StreamController from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport from streaming._events import values_event +from streaming._fake_server import FakeServer class _FakeAsyncWebSocket: @@ -334,7 +336,6 @@ async def test_websocket_done_records_post_ready_error(): async def test_websocket_send_command_uses_http_commands_endpoint(): - from streaming._fake_server import FakeServer fake = FakeServer() transport = httpx.ASGITransport(app=fake.app) @@ -357,7 +358,6 @@ async def test_websocket_open_event_stream_raises_when_closed(): async def test_websocket_transport_feeds_async_stream_controller(): - from langgraph_sdk.stream.controller import StreamController socket = _FakeAsyncWebSocket( [ @@ -418,7 +418,6 @@ async def test_ws_transport_default_max_queue_size_is_1024(): async def test_websocket_controller_reconnects_with_since_after_drop(): - from langgraph_sdk.stream.controller import StreamController first_socket = _FakeAsyncWebSocket( [values_event(seq=1, values={"counter": 1})], @@ -465,7 +464,6 @@ async def test_websocket_controller_reconnects_with_since_after_drop(): async def test_async_close_sends_normal_close_frame(): """`handle.close()` sends a WebSocket close frame with code 1000 explicitly.""" - import asyncio # Use an event to distinguish an explicit close(code=1000) call from # the implicit one in __aexit__ when the task is cancelled. diff --git a/libs/sdk-py/tests/test_client_stream.py b/libs/sdk-py/tests/test_client_stream.py index d80d299b5..d2b4c18d5 100644 --- a/libs/sdk-py/tests/test_client_stream.py +++ b/libs/sdk-py/tests/test_client_stream.py @@ -8,7 +8,9 @@ import httpx import pytest from typing_extensions import assert_type +from langgraph_sdk._async.runs import _wrap_stream_v2 from langgraph_sdk._shared.utilities import _sse_to_v2_dict +from langgraph_sdk._sync.runs import _wrap_stream_v2_sync from langgraph_sdk.client import HttpClient, SyncHttpClient from langgraph_sdk.schema import ( CheckpointPayload, @@ -380,7 +382,6 @@ def test_sse_to_v2_dict_values_with_interrupts() -> None: @pytest.mark.asyncio async def test_async_stream_v2_client_side_conversion() -> None: - from langgraph_sdk._async.runs import _wrap_stream_v2 async def mock_stream() -> Any: yield StreamPart(event="metadata", data={"run_id": "r1"}) @@ -415,7 +416,6 @@ async def test_async_stream_v2_client_side_conversion() -> None: def test_sync_stream_v2_client_side_conversion() -> None: - from langgraph_sdk._sync.runs import _wrap_stream_v2_sync def mock_stream() -> Any: yield StreamPart(event="metadata", data={"run_id": "r1"}) diff --git a/libs/sdk-py/tests/test_langsmith_tracing.py b/libs/sdk-py/tests/test_langsmith_tracing.py index db0fc2205..0720039fc 100644 --- a/libs/sdk-py/tests/test_langsmith_tracing.py +++ b/libs/sdk-py/tests/test_langsmith_tracing.py @@ -7,6 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from langgraph_sdk._async.runs import RunsClient +from langgraph_sdk._sync.runs import SyncRunsClient from langgraph_sdk.schema import LangSmithTracing @@ -24,7 +26,6 @@ class TestLangSmithTracingPayload: @pytest.mark.asyncio async def test_async_create_includes_langsmith_tracer(self, tracing_config): """Test that async create sends langsmith_tracer in payload.""" - from langgraph_sdk._async.runs import RunsClient captured: dict[str, Any] = {} @@ -50,7 +51,6 @@ class TestLangSmithTracingPayload: def test_sync_create_includes_langsmith_tracer(self, tracing_config): """Test that sync create sends langsmith_tracer in payload.""" - from langgraph_sdk._sync.runs import SyncRunsClient captured: dict[str, Any] = {} @@ -76,7 +76,6 @@ class TestLangSmithTracingPayload: def test_sync_wait_includes_langsmith_tracer(self, tracing_config): """Test that sync wait sends langsmith_tracer in payload.""" - from langgraph_sdk._sync.runs import SyncRunsClient captured: dict[str, Any] = {} @@ -102,7 +101,6 @@ class TestLangSmithTracingPayload: def test_create_without_langsmith_tracing_excludes_key(self): """Test that langsmith_tracer is not in payload when not provided.""" - from langgraph_sdk._sync.runs import SyncRunsClient captured: dict[str, Any] = {} @@ -123,7 +121,6 @@ class TestLangSmithTracingPayload: def test_langsmith_tracing_project_name_only(self): """Test that langsmith_tracing works with only project_name.""" - from langgraph_sdk._sync.runs import SyncRunsClient captured: dict[str, Any] = {} diff --git a/libs/sdk-py/tests/test_path_encoding.py b/libs/sdk-py/tests/test_path_encoding.py index 4c5fc0d18..8bdf3860c 100644 --- a/libs/sdk-py/tests/test_path_encoding.py +++ b/libs/sdk-py/tests/test_path_encoding.py @@ -8,6 +8,8 @@ URL paths. from __future__ import annotations +import uuid + import httpx import pytest @@ -60,7 +62,6 @@ class TestQuotePathParam: assert "/" not in encoded def test_non_string_values_are_coerced_to_str(self) -> None: - import uuid uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") assert _quote_path_param(uid) == str(uid) diff --git a/libs/sdk-py/tests/test_serde.py b/libs/sdk-py/tests/test_serde.py index 5678e5285..1b8d7a7ef 100644 --- a/libs/sdk-py/tests/test_serde.py +++ b/libs/sdk-py/tests/test_serde.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Any import orjson @@ -36,7 +37,6 @@ async def test_serde_pydantic(): async def test_serde_dataclass(): - from dataclasses import dataclass @dataclass class TestDataClass: