From 9ca6a8a0e3c863191ba248f9b7d8cfbb55a944e9 Mon Sep 17 00:00:00 2001 From: Gautam Datla Date: Sun, 15 Feb 2026 07:36:24 -0500 Subject: [PATCH 1/4] Fix #29530: remove blocking inspect.getsource from deps discovery --- libs/core/langchain_core/runnables/utils.py | 164 ++++++++++++++---- libs/core/tests/unit_tests/conftest.py | 3 - .../tests/unit_tests/runnables/test_utils.py | 29 +++- 3 files changed, 163 insertions(+), 33 deletions(-) diff --git a/libs/core/langchain_core/runnables/utils.py b/libs/core/langchain_core/runnables/utils.py index ce91e44116f84..05f92957b9433 100644 --- a/libs/core/langchain_core/runnables/utils.py +++ b/libs/core/langchain_core/runnables/utils.py @@ -4,6 +4,7 @@ import ast import asyncio +import dis import inspect import sys import textwrap @@ -11,6 +12,7 @@ # Cannot move to TYPE_CHECKING as Mapping and Sequence are needed at runtime by # RunnableConfigurableFields. from collections.abc import Mapping, Sequence # noqa: TC003 +from contextlib import suppress from functools import lru_cache from inspect import signature from itertools import groupby @@ -38,6 +40,7 @@ Iterable, ) from contextvars import Context + from types import CodeType from langchain_core.runnables.schema import StreamEvent @@ -404,6 +407,63 @@ def get_lambda_source(func: Callable) -> str | None: return visitor.source if visitor.count == 1 else name +@lru_cache(maxsize=256) +def _nonlocal_access_plan( + code: CodeType, +) -> tuple[tuple[str, ...], tuple[tuple[str, tuple[str, ...]], ...]]: + """Compute a nonlocal access plan from bytecode. + + Args: + code: Code object to scan. + + Returns: + A tuple ``(plain_roots, chains)``, where: + - plain_roots: Names loaded without any attribute access. + - chains: ``(root, path)`` pairs for attribute-access chains. + """ + root_ops = {"LOAD_GLOBAL", "LOAD_DEREF", "LOAD_NAME"} + attr_ops = {"LOAD_ATTR", "LOAD_METHOD"} + + plain_roots = set() + chains = [] + + base = None + attrs = [] + + def flush() -> None: + nonlocal base, attrs + if base is not None: + if attrs: + chains.append((base, tuple(attrs))) + else: + plain_roots.add(base) + base = None + attrs = [] + + for ins in dis.get_instructions(code): + op = ins.opname + if op in root_ops and isinstance(ins.argval, str): + flush() + base = ins.argval + continue + if op in attr_ops and isinstance(ins.argval, str): + if base is not None: + attrs.append(ins.argval) + continue + flush() + + flush() + + deduped = [] + seen = set() + for c in chains: + if c not in seen: + seen.add(c) + deduped.append(c) + + return tuple(sorted(plain_roots)), tuple(deduped) + + @lru_cache(maxsize=256) def get_function_nonlocals(func: Callable) -> list[Any]: """Get the nonlocal variables accessed by a function. @@ -414,37 +474,83 @@ def get_function_nonlocals(func: Callable) -> list[Any]: Returns: The nonlocal variables accessed by the function. """ - try: - code = inspect.getsource(func) - tree = ast.parse(textwrap.dedent(code)) - visitor = FunctionNonLocals() - visitor.visit(tree) - values: list[Any] = [] - closure = ( - inspect.getclosurevars(func.__wrapped__) - if hasattr(func, "__wrapped__") and callable(func.__wrapped__) - else inspect.getclosurevars(func) - ) - candidates = {**closure.globals, **closure.nonlocals} - for k, v in candidates.items(): - if k in visitor.nonlocals: - values.append(v) - for kk in visitor.nonlocals: - if "." in kk and kk.startswith(k): - vv = v - for part in kk.split(".")[1:]: - if vv is None: - break - try: - vv = getattr(vv, part) - except AttributeError: - break - else: - values.append(vv) - except (SyntaxError, TypeError, OSError, SystemError): + terminal_methods = { + "invoke", + "ainvoke", + "batch", + "abatch", + "stream", + "astream", + "transform", + "atransform", + } + + target = func + seen_wrapped= set() + while True: + w = getattr(target, "__wrapped__", None) + if not callable(w): + break + wid = id(w) + if wid in seen_wrapped: + break + seen_wrapped.add(wid) + target = w + + if getattr(target, "__code__", None) is None: + target = getattr(target, "__func__", target) + + code = getattr(target, "__code__", None) + if code is None: return [] - return values + nonlocals_dict = {} + freevars = code.co_freevars + closure = getattr(target, "__closure__", None) + if closure and freevars: + for name, cell in zip(freevars, closure, strict=False): + with suppress(ValueError): + nonlocals_dict[name] = cell.cell_contents + + globals_dict = getattr(target, "__globals__", {}) + + plain_roots, chains = _nonlocal_access_plan(code) + + out = [] + seen_ids = set() + + def add(v: Any) -> None: + vid = id(v) + if vid not in seen_ids: + seen_ids.add(vid) + out.append(v) + + def resolve_root(name: str) -> Any | None: + if name in nonlocals_dict: + return nonlocals_dict[name] + return globals_dict.get(name) + + for name in plain_roots: + v = resolve_root(name) + if v is not None: + add(v) + + for base, attrs in chains: + if not attrs or attrs[-1] not in terminal_methods: + continue + v = resolve_root(base) + if v is None: + continue + for a in attrs: + try: + v = getattr(v, a) + except Exception: + break + else: + if v is not None: + add(v) + + return out def indent_lines_after_first(text: str, prefix: str) -> str: diff --git a/libs/core/tests/unit_tests/conftest.py b/libs/core/tests/unit_tests/conftest.py index 06962b7f2e1c6..a2ffd40b86da0 100644 --- a/libs/core/tests/unit_tests/conftest.py +++ b/libs/core/tests/unit_tests/conftest.py @@ -17,9 +17,6 @@ def blockbuster() -> Iterator[BlockBuster]: bb.functions[func] .can_block_in("langchain_core/_api/internal.py", "is_caller_internal") .can_block_in("langchain_core/runnables/base.py", "__repr__") - .can_block_in( - "langchain_core/beta/runnables/context.py", "aconfig_with_context" - ) ) for func in ["os.stat", "io.TextIOWrapper.read"]: diff --git a/libs/core/tests/unit_tests/runnables/test_utils.py b/libs/core/tests/unit_tests/runnables/test_utils.py index 37c19ca1a150c..3fd85848cbc0e 100644 --- a/libs/core/tests/unit_tests/runnables/test_utils.py +++ b/libs/core/tests/unit_tests/runnables/test_utils.py @@ -1,5 +1,6 @@ +import inspect from collections.abc import Callable -from typing import Any +from typing import Any, NoReturn import pytest @@ -73,3 +74,29 @@ def my_func6(value: str) -> str: assert RunnableLambda(my_func3).deps == [agent] assert RunnableLambda(my_func4).deps == [global_agent] assert RunnableLambda(func).deps == [nl] + + +def test_deps_does_not_call_inspect_getsource() -> None: + original = inspect.getsource + error_message = "inspect.getsource was called while computing deps" + def explode(*_args: Any, **_kwargs: Any) -> NoReturn: + raise AssertionError(error_message) + inspect.getsource = explode + try: + agent = RunnableLambda(lambda x: x) + class Box: + def __init__(self, a: RunnableLambda) -> None: + self.agent = a + box = Box(agent) + def my_func(x: str) -> str: + return box.agent.invoke(x) + r = RunnableLambda(my_func) + _ = r.deps + finally: + inspect.getsource = original + + +def test_deps_is_cached_on_instance() -> None: + r = RunnableLambda(lambda x: x) + _ = r.deps + assert "deps" in r.__dict__ From 927718e7681e1d2282a96787e3ae72148926b138 Mon Sep 17 00:00:00 2001 From: Gautam Datla Date: Sun, 15 Feb 2026 07:51:32 -0500 Subject: [PATCH 2/4] linting fix --- libs/core/langchain_core/runnables/utils.py | 6 +++--- libs/core/tests/unit_tests/runnables/test_utils.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/libs/core/langchain_core/runnables/utils.py b/libs/core/langchain_core/runnables/utils.py index 05f92957b9433..1dd1565ded33b 100644 --- a/libs/core/langchain_core/runnables/utils.py +++ b/libs/core/langchain_core/runnables/utils.py @@ -486,7 +486,7 @@ def get_function_nonlocals(func: Callable) -> list[Any]: } target = func - seen_wrapped= set() + seen_wrapped = set() while True: w = getattr(target, "__wrapped__", None) if not callable(w): @@ -509,8 +509,8 @@ def get_function_nonlocals(func: Callable) -> list[Any]: closure = getattr(target, "__closure__", None) if closure and freevars: for name, cell in zip(freevars, closure, strict=False): - with suppress(ValueError): - nonlocals_dict[name] = cell.cell_contents + with suppress(ValueError): + nonlocals_dict[name] = cell.cell_contents globals_dict = getattr(target, "__globals__", {}) diff --git a/libs/core/tests/unit_tests/runnables/test_utils.py b/libs/core/tests/unit_tests/runnables/test_utils.py index 3fd85848cbc0e..11143c31014d9 100644 --- a/libs/core/tests/unit_tests/runnables/test_utils.py +++ b/libs/core/tests/unit_tests/runnables/test_utils.py @@ -79,17 +79,23 @@ def my_func6(value: str) -> str: def test_deps_does_not_call_inspect_getsource() -> None: original = inspect.getsource error_message = "inspect.getsource was called while computing deps" + def explode(*_args: Any, **_kwargs: Any) -> NoReturn: raise AssertionError(error_message) + inspect.getsource = explode try: agent = RunnableLambda(lambda x: x) + class Box: def __init__(self, a: RunnableLambda) -> None: self.agent = a + box = Box(agent) + def my_func(x: str) -> str: return box.agent.invoke(x) + r = RunnableLambda(my_func) _ = r.deps finally: From bba266011f96d9e66ebf56c0d495bf9d0b268ddc Mon Sep 17 00:00:00 2001 From: Gautam Datla Date: Sun, 15 Feb 2026 08:09:47 -0500 Subject: [PATCH 3/4] Add annotations --- libs/core/langchain_core/runnables/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/core/langchain_core/runnables/utils.py b/libs/core/langchain_core/runnables/utils.py index 1dd1565ded33b..f0cb299ca3d92 100644 --- a/libs/core/langchain_core/runnables/utils.py +++ b/libs/core/langchain_core/runnables/utils.py @@ -424,11 +424,11 @@ def _nonlocal_access_plan( root_ops = {"LOAD_GLOBAL", "LOAD_DEREF", "LOAD_NAME"} attr_ops = {"LOAD_ATTR", "LOAD_METHOD"} - plain_roots = set() - chains = [] + plain_roots: set[str] = set() + chains: list[tuple[str, tuple[str, ...]]] = [] - base = None - attrs = [] + base: str | None = None + attrs: list[str] = [] def flush() -> None: nonlocal base, attrs From 14c0f73de6ea7afcc0f7ef273f89013afe951fd6 Mon Sep 17 00:00:00 2001 From: Gautam Datla Date: Sun, 15 Feb 2026 08:14:29 -0500 Subject: [PATCH 4/4] update test to handle ruff return type check --- libs/core/tests/unit_tests/runnables/test_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/core/tests/unit_tests/runnables/test_utils.py b/libs/core/tests/unit_tests/runnables/test_utils.py index 11143c31014d9..868ddb9649203 100644 --- a/libs/core/tests/unit_tests/runnables/test_utils.py +++ b/libs/core/tests/unit_tests/runnables/test_utils.py @@ -85,18 +85,18 @@ def explode(*_args: Any, **_kwargs: Any) -> NoReturn: inspect.getsource = explode try: - agent = RunnableLambda(lambda x: x) + agent: RunnableLambda[str, str] = RunnableLambda(lambda x: x) class Box: - def __init__(self, a: RunnableLambda) -> None: - self.agent = a + def __init__(self, a: RunnableLambda[str, str]) -> None: + self.agent: RunnableLambda[str, str] = a box = Box(agent) def my_func(x: str) -> str: return box.agent.invoke(x) - r = RunnableLambda(my_func) + r: RunnableLambda[str, str] = RunnableLambda(my_func) _ = r.deps finally: inspect.getsource = original