Compare commits

..
Author SHA1 Message Date
nick-hollon-lc 93cb1f87e8 fix(langgraph): type undeclared v3 stream projections
`stream_events(version="v3")` returns `GraphRunStream`, which declares only
the four always-registered native projections. Native projections are
attached from a registry, so any other one — opt-in built-ins, and
projections from transformers in other packages like `tool_calls`
(langgraph-prebuilt) or `subagents` (langchain) — was invisible to type
checkers, raising `attr-defined`. These returned `Any` before 1.2.10, so
the errors are new.

- Declare the opt-in native projections this package ships (`updates`,
  `custom`, `checkpoints`, `debug`, `tasks`).
- Add `__getattr__ -> StreamChannel[Any]` to both run streams for
  projections declared outside this package. The body only raises
  `AttributeError`, now listing the registered projections; it reads the
  mux from `__dict__` so a miss before `__init__` can't recurse.

Projections owned by other distributions are deliberately not annotated
here — that would bind this package's typing surface to another's private
module. Precise types for those need a downstream subclass, which composes
with this since declared attributes take precedence over `__getattr__`.

Tradeoff: a misspelled projection name now type-checks and fails at
runtime instead.
2026-08-11 11:59:18 -04:00
7 changed files with 166 additions and 26 deletions
@@ -7,7 +7,7 @@ import logging
import re
import threading
from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from collections.abc import Callable, Iterable, Iterator, Sequence
from contextlib import contextmanager
from datetime import datetime
from typing import (
@@ -354,7 +354,7 @@ class BasePostgresStore(Generic[C]):
(
_namespace_to_text(op.namespace),
op.key,
Jsonb(dict(cast(Mapping[str, Any], op.value))),
Jsonb(cast(dict, op.value)),
)
)
if op.ttl is not None:
@@ -7,7 +7,7 @@ import re
import sqlite3
import threading
from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from collections.abc import Callable, Iterable, Iterator, Sequence
from contextlib import contextmanager
from typing import Any, Literal, NamedTuple, cast
@@ -387,7 +387,7 @@ class BaseSqliteStore:
[
_namespace_to_text(op.namespace),
op.key,
orjson.dumps(dict(cast(Mapping[str, Any], op.value))),
orjson.dumps(cast(dict, op.value)),
expires_at,
op.ttl,
]
@@ -12,7 +12,7 @@ Core types:
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping
from collections.abc import Iterable
from datetime import datetime
from typing import (
Any,
@@ -473,10 +473,10 @@ class PutOp(NamedTuple):
the full path would effectively be `"documents/user123/report1"`
"""
value: Mapping[str, Any] | None
value: dict[str, Any] | None
"""The data to store, or `None` to mark the item for deletion.
The value must be a mapping with string keys and JSON-serializable values.
The value must be a dictionary with string keys and JSON-serializable values.
Setting this to `None` signals that the item should be deleted.
Example:
@@ -857,7 +857,7 @@ class BaseStore(ABC):
self,
namespace: tuple[str, ...],
key: str,
value: Mapping[str, Any],
value: dict[str, Any],
index: Literal[False] | list[str] | None = None,
*,
ttl: float | None | NotProvided = NOT_PROVIDED,
@@ -869,7 +869,7 @@ class BaseStore(ABC):
Example: `("documents", "user123")`
key: Unique identifier within the namespace. Together with namespace forms
the complete path to the item.
value: Mapping containing the item's data. Must contain string keys
value: Dictionary containing the item's data. Must contain string keys
and JSON-serializable values.
index: Controls how the item's fields are indexed for search:
@@ -1110,7 +1110,7 @@ class BaseStore(ABC):
self,
namespace: tuple[str, ...],
key: str,
value: Mapping[str, Any],
value: dict[str, Any],
index: Literal[False] | list[str] | None = None,
*,
ttl: float | None | NotProvided = NOT_PROVIDED,
@@ -1122,7 +1122,7 @@ class BaseStore(ABC):
Example: `("documents", "user123")`
key: Unique identifier within the namespace. Together with namespace forms
the complete path to the item.
value: Mapping containing the item's data. Must contain string keys
value: Dictionary containing the item's data. Must contain string keys
and JSON-serializable values.
index: Controls how the item's fields are indexed for search:
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import functools
import weakref
from collections.abc import Callable, Iterable, Mapping
from collections.abc import Callable, Iterable
from typing import Any, Literal, TypeVar
from langgraph.store.base import (
@@ -132,7 +132,7 @@ class AsyncBatchedBaseStore(BaseStore):
self,
namespace: tuple[str, ...],
key: str,
value: Mapping[str, Any],
value: dict[str, Any],
index: Literal[False] | list[str] | None = None,
*,
ttl: float | None | NotProvided = NOT_PROVIDED,
@@ -231,7 +231,7 @@ class AsyncBatchedBaseStore(BaseStore):
self,
namespace: tuple[str, ...],
key: str,
value: Mapping[str, Any],
value: dict[str, Any],
index: Literal[False] | list[str] | None = None,
*,
ttl: float | None | NotProvided = NOT_PROVIDED,
@@ -408,7 +408,7 @@ class InMemoryStore(BaseStore):
self._vectors[namespace].pop(key, None)
else:
self._data[namespace][key] = Item(
value=dict(op.value),
value=op.value,
key=key,
namespace=namespace,
created_at=datetime.now(timezone.utc),
+52 -11
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
from types import MappingProxyType, TracebackType
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NoReturn
from langchain_core._api import beta
@@ -33,6 +33,21 @@ async def _adrive_until_done(pump: Callable[[], Awaitable[bool]]) -> None:
pass
def _missing_projection(run: object, name: str) -> NoReturn:
"""Raise for a projection name that was never registered.
Reads the mux out of `__dict__` because `__getattr__` also fires before
`__init__` assigns `_mux` and for dunder probes, where `run._mux` would
recurse until the stack is exhausted.
"""
mux = run.__dict__.get("_mux")
registered = sorted(mux.native_keys) if mux is not None else []
raise AttributeError(
f"{type(run).__name__!r} object has no attribute {name!r} "
f"(registered projections: {', '.join(registered) or 'none'})"
)
@beta(message="The v3 streaming protocol on Pregel is experimental.")
class GraphRunStream:
"""Sync run stream with caller-driven pumping.
@@ -54,15 +69,32 @@ class GraphRunStream:
experimental and may change.
"""
# Native projections always registered by `stream_events(version="v3")`.
# Attached dynamically by the `setattr` loop in `__init__`; declared here
# so type checkers see them. Opt-in native projections (`updates`,
# `custom`, `checkpoints`, `debug`, `tasks`) are only present when their
# transformer is registered, so they are reached via `extensions[...]`.
# Native projections, attached dynamically by the `setattr` loop in
# `__init__` and declared here so type checkers see them.
#
# Always registered by `stream_events(version="v3")`:
values: StreamChannel[dict[str, Any]]
messages: StreamChannel[ChatModelStream]
lifecycle: StreamChannel[LifecyclePayload]
subgraphs: StreamChannel[SubgraphRunStream]
# Registered on demand via `compile(transformers=...)` or
# `stream_events(transformers=...)`; reading one whose transformer was not
# registered raises AttributeError. Projections contributed by transformers
# outside this package are covered by `__getattr__` instead.
updates: StreamChannel[dict[str, Any]]
custom: StreamChannel[Any]
checkpoints: StreamChannel[dict[str, Any]]
debug: StreamChannel[dict[str, Any]]
tasks: StreamChannel[dict[str, Any]]
def __getattr__(self, name: str) -> StreamChannel[Any]:
"""Type the projections of transformers declared outside this package.
Projection names come from a registry, so no annotation here can name
them all. The cost is that a misspelling type-checks too, and fails at
runtime instead.
"""
_missing_projection(self, name)
def __init__(
self,
@@ -345,15 +377,24 @@ class AsyncGraphRunStream:
experimental and may change.
"""
# Native projections always registered by `astream_events(version="v3")`.
# Attached dynamically by the `setattr` loop in `__init__`; declared here
# so type checkers see them. Opt-in native projections (`updates`,
# `custom`, `checkpoints`, `debug`, `tasks`) are only present when their
# transformer is registered, so they are reached via `extensions[...]`.
# Native projections, attached dynamically by the `setattr` loop in
# `__init__` and declared here so type checkers see them.
#
# Always registered by `astream_events(version="v3")`:
values: StreamChannel[dict[str, Any]]
messages: StreamChannel[AsyncChatModelStream]
lifecycle: StreamChannel[LifecyclePayload]
subgraphs: StreamChannel[AsyncSubgraphRunStream]
# Registered on demand; see `GraphRunStream`.
updates: StreamChannel[dict[str, Any]]
custom: StreamChannel[Any]
checkpoints: StreamChannel[dict[str, Any]]
debug: StreamChannel[dict[str, Any]]
tasks: StreamChannel[dict[str, Any]]
def __getattr__(self, name: str) -> StreamChannel[Any]:
"""Type projections declared elsewhere. See `GraphRunStream`."""
_missing_projection(self, name)
def __init__(
self,
@@ -6,6 +6,7 @@ Type-narrowing is validated via `assert_type` calls in `_check_type_narrowing`.
from __future__ import annotations
import copy
import operator
import sys
from dataclasses import dataclass
@@ -34,8 +35,10 @@ from langgraph.stream import (
GraphRunStream,
LifecyclePayload,
StreamChannel,
StreamTransformer,
SubgraphRunStream,
)
from langgraph.stream._types import ProtocolEvent
from langgraph.types import (
CheckpointPayload,
CheckpointStreamPart,
@@ -1199,6 +1202,29 @@ def _check_type_narrowing(part: StreamPart[_StateT, _OutputT]) -> None:
# type and the always-registered native projections.
class _MarkerTransformer(StreamTransformer):
"""Native transformer contributing a key this module doesn't declare.
Stands in for any transformer defined outside this package — projections
whose names `GraphRunStream` can't enumerate, so they resolve through
`__getattr__` instead of a class annotation.
"""
_native = True
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[str] = StreamChannel()
def init(self) -> dict[str, Any]:
return {"marker": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] == "values":
self._log.push("saw_values")
return True
def _check_stream_events_v3_typing() -> None:
"""Compile-time checks for sync v3 typing — never called at runtime."""
graph = _make_simple_graph().compile()
@@ -1208,6 +1234,16 @@ def _check_stream_events_v3_typing() -> None:
assert_type(run.messages, StreamChannel[ChatModelStream])
assert_type(run.lifecycle, StreamChannel[LifecyclePayload])
assert_type(run.subgraphs, StreamChannel[SubgraphRunStream])
# Opt-in projections from transformers this package ships carry their real
# item type even though they are only present once registered.
assert_type(run.updates, StreamChannel[dict[str, Any]])
assert_type(run.custom, StreamChannel[Any])
assert_type(run.checkpoints, StreamChannel[dict[str, Any]])
assert_type(run.debug, StreamChannel[dict[str, Any]])
assert_type(run.tasks, StreamChannel[dict[str, Any]])
# Projections this module can't enumerate resolve through `__getattr__`
# as `StreamChannel[Any]` rather than failing with attr-defined.
assert_type(run.marker, StreamChannel[Any])
async def _check_astream_events_v3_typing() -> None:
@@ -1219,3 +1255,66 @@ async def _check_astream_events_v3_typing() -> None:
assert_type(run.messages, StreamChannel[AsyncChatModelStream])
assert_type(run.lifecycle, StreamChannel[LifecyclePayload])
assert_type(run.subgraphs, StreamChannel[AsyncSubgraphRunStream])
assert_type(run.updates, StreamChannel[dict[str, Any]])
assert_type(run.custom, StreamChannel[Any])
assert_type(run.checkpoints, StreamChannel[dict[str, Any]])
assert_type(run.debug, StreamChannel[dict[str, Any]])
assert_type(run.tasks, StreamChannel[dict[str, Any]])
assert_type(run.marker, StreamChannel[Any])
def test_undeclared_native_projection_is_attached() -> None:
"""A native projection this module doesn't declare still works at runtime.
`__getattr__` is a type-checker fallback only — it must not shadow the
`setattr` loop that attaches registered native projections.
"""
graph = _make_simple_graph().compile()
run = graph.stream_events(
_SIMPLE_INPUT, version="v3", transformers=[_MarkerTransformer]
)
marker_iter = iter(run.marker)
assert run.output is not None
assert run.marker is run.extensions["marker"]
assert "saw_values" in list(marker_iter)
def test_unregistered_projection_raises_attribute_error() -> None:
"""An unregistered projection name still fails at runtime.
The `__getattr__` fallback exists to satisfy type checkers; it must not
make unknown names resolve to anything. The message lists what *is*
registered so a typo is diagnosable from the traceback alone.
"""
graph = _make_simple_graph().compile()
run = graph.stream_events(_SIMPLE_INPUT, version="v3")
# `marker` type-checks via `__getattr__` but was never registered here.
with pytest.raises(AttributeError) as exc_info:
run.marker
message = str(exc_info.value)
assert "marker" in message
# The always-registered natives are listed as the alternatives.
assert "messages" in message
# Registered projections still resolve, and the run is unaffected.
assert isinstance(run.messages, StreamChannel)
assert run.output is not None
def test_getattr_fallback_does_not_recurse_before_init() -> None:
"""`__getattr__` reads the mux from `__dict__`, so it is safe pre-init.
`self._mux` would re-enter `__getattr__` and overflow the stack when the
attribute is missing, which is reachable via `hasattr` / `copy` / pickle
probing on a partially constructed instance.
"""
bare = GraphRunStream.__new__(GraphRunStream)
with pytest.raises(AttributeError, match="anything"):
bare.anything
assert hasattr(bare, "anything") is False
assert copy.copy(bare) is not None