langchain: Use Blockbuster to detect blocking calls in asyncio during tests (#29616)

Same as https://github.com/langchain-ai/langchain/pull/29043 for the
langchain package.

**Dependencies:**
- blockbuster (test)

**Twitter handle:** cbornet_

---------

Co-authored-by: Erick Friis <erick@langchain.dev>
This commit is contained in:
Christophe Bornet 2025-02-08 02:08:15 +01:00 committed by GitHub
parent c67d473397
commit 3a57a28daa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 84 additions and 22 deletions

View File

@ -4,7 +4,7 @@ build-backend = "pdm.backend"
[project] [project]
authors = [] authors = []
license = {text = "MIT"} license = { text = "MIT" }
requires-python = "<4.0,>=3.9" requires-python = "<4.0,>=3.9"
dependencies = [ dependencies = [
"langchain-core<1.0.0,>=0.3.34", "langchain-core<1.0.0,>=0.3.34",
@ -66,6 +66,7 @@ test = [
"syrupy<5.0.0,>=4.0.2", "syrupy<5.0.0,>=4.0.2",
"requests-mock<2.0.0,>=1.11.0", "requests-mock<2.0.0,>=1.11.0",
"pytest-xdist<4.0.0,>=3.6.1", "pytest-xdist<4.0.0,>=3.6.1",
"blockbuster<1.6,>=1.5.14",
"cffi<1.17.1; python_version < \"3.10\"", "cffi<1.17.1; python_version < \"3.10\"",
"cffi; python_version >= \"3.10\"", "cffi; python_version >= \"3.10\"",
"langchain-tests @ file:///${PROJECT_ROOT}/../standard-tests", "langchain-tests @ file:///${PROJECT_ROOT}/../standard-tests",
@ -75,9 +76,7 @@ test = [
"toml>=0.10.2", "toml>=0.10.2",
"packaging>=24.2", "packaging>=24.2",
] ]
codespell = [ codespell = ["codespell<3.0.0,>=2.2.0"]
"codespell<3.0.0,>=2.2.0",
]
test_integration = [ test_integration = [
"pytest-vcr<2.0.0,>=1.0.2", "pytest-vcr<2.0.0,>=1.0.2",
"urllib3<2; python_version < \"3.10\"", "urllib3<2; python_version < \"3.10\"",
@ -116,12 +115,12 @@ dev = [
[tool.ruff] [tool.ruff]
target-version = "py39" target-version = "py39"
exclude = [ "tests/integration_tests/examples/non-utf8-encoding.py",] exclude = ["tests/integration_tests/examples/non-utf8-encoding.py"]
[tool.mypy] [tool.mypy]
ignore_missing_imports = "True" ignore_missing_imports = "True"
disallow_untyped_defs = "True" disallow_untyped_defs = "True"
exclude = [ "notebooks", "examples", "example_data",] exclude = ["notebooks", "examples", "example_data"]
[tool.codespell] [tool.codespell]
skip = ".git,*.pdf,*.svg,*.pdf,*.yaml,*.ipynb,poetry.lock,*.min.js,*.css,package-lock.json,example_data,_dist,examples,*.trig" skip = ".git,*.pdf,*.svg,*.pdf,*.yaml,*.ipynb,poetry.lock,*.min.js,*.css,package-lock.json,example_data,_dist,examples,*.trig"
@ -129,7 +128,7 @@ ignore-regex = ".*(Stati Uniti|Tense=Pres).*"
ignore-words-list = "momento,collison,ned,foor,reworkd,parth,whats,aapply,mysogyny,unsecure,damon,crate,aadd,symbl,precesses,accademia,nin" ignore-words-list = "momento,collison,ned,foor,reworkd,parth,whats,aapply,mysogyny,unsecure,damon,crate,aadd,symbl,precesses,accademia,nin"
[tool.ruff.lint] [tool.ruff.lint]
select = [ "E", "F", "I", "T201", "D",] select = ["E", "F", "I", "T201", "D"]
pydocstyle = { convention = "google" } pydocstyle = { convention = "google" }
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
@ -137,10 +136,18 @@ pydocstyle = { convention = "google" }
"!langchain/indexes/vectorstore.py" = ["D"] "!langchain/indexes/vectorstore.py" = ["D"]
[tool.coverage.run] [tool.coverage.run]
omit = [ "tests/*",] omit = ["tests/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--strict-markers --strict-config --durations=5 --snapshot-warn-unused -vv" addopts = "--strict-markers --strict-config --durations=5 --snapshot-warn-unused -vv"
markers = [ "requires: mark tests as requiring a specific library", "scheduled: mark tests to run in scheduled testing", "compile: mark placeholder test used to compile integration tests without running them",] markers = [
"requires: mark tests as requiring a specific library",
"scheduled: mark tests to run in scheduled testing",
"compile: mark placeholder test used to compile integration tests without running them",
]
asyncio_mode = "auto" asyncio_mode = "auto"
filterwarnings = [ "ignore::langchain_core._api.beta_decorator.LangChainBetaWarning", "ignore::langchain_core._api.deprecation.LangChainDeprecationWarning:tests", "ignore::langchain_core._api.deprecation.LangChainPendingDeprecationWarning:tests",] filterwarnings = [
"ignore::langchain_core._api.beta_decorator.LangChainBetaWarning",
"ignore::langchain_core._api.deprecation.LangChainDeprecationWarning:tests",
"ignore::langchain_core._api.deprecation.LangChainPendingDeprecationWarning:tests",
]

View File

@ -1,5 +1,6 @@
"""Unit tests for agents.""" """Unit tests for agents."""
import asyncio
import json import json
from itertools import cycle from itertools import cycle
from typing import Any, Dict, List, Optional, Union, cast from typing import Any, Dict, List, Optional, Union, cast
@ -465,7 +466,7 @@ async def test_runnable_agent() -> None:
executor = AgentExecutor(agent=agent, tools=[]) # type: ignore[arg-type] executor = AgentExecutor(agent=agent, tools=[]) # type: ignore[arg-type]
# Invoke # Invoke
result = executor.invoke({"question": "hello"}) result: Any = await asyncio.to_thread(executor.invoke, {"question": "hello"})
assert result == {"foo": "meow", "question": "hello"} assert result == {"foo": "meow", "question": "hello"}
# ainvoke # ainvoke
@ -473,8 +474,8 @@ async def test_runnable_agent() -> None:
assert result == {"foo": "meow", "question": "hello"} assert result == {"foo": "meow", "question": "hello"}
# Batch # Batch
result = executor.batch( # type: ignore[assignment] result = await asyncio.to_thread(
[{"question": "hello"}, {"question": "hello"}] executor.batch, [{"question": "hello"}, {"question": "hello"}]
) )
assert result == [ assert result == [
{"foo": "meow", "question": "hello"}, {"foo": "meow", "question": "hello"},
@ -482,16 +483,14 @@ async def test_runnable_agent() -> None:
] ]
# abatch # abatch
result = await executor.abatch( # type: ignore[assignment] result = await executor.abatch([{"question": "hello"}, {"question": "hello"}])
[{"question": "hello"}, {"question": "hello"}]
)
assert result == [ assert result == [
{"foo": "meow", "question": "hello"}, {"foo": "meow", "question": "hello"},
{"foo": "meow", "question": "hello"}, {"foo": "meow", "question": "hello"},
] ]
# Stream # Stream
results = list(executor.stream({"question": "hello"})) results = await asyncio.to_thread(list, executor.stream({"question": "hello"}))
assert results == [ assert results == [
{"foo": "meow", "messages": [AIMessage(content="hard-coded-message")]} {"foo": "meow", "messages": [AIMessage(content="hard-coded-message")]}
] ]
@ -587,7 +586,7 @@ async def test_runnable_agent_with_function_calls() -> None:
executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item] executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item]
# Invoke # Invoke
result = executor.invoke({"question": "hello"}) result = await asyncio.to_thread(executor.invoke, {"question": "hello"})
assert result == {"foo": "meow", "question": "hello"} assert result == {"foo": "meow", "question": "hello"}
# ainvoke # ainvoke
@ -705,7 +704,7 @@ async def test_runnable_with_multi_action_per_step() -> None:
executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item] executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item]
# Invoke # Invoke
result = executor.invoke({"question": "hello"}) result = await asyncio.to_thread(executor.invoke, {"question": "hello"})
assert result == {"foo": "meow", "question": "hello"} assert result == {"foo": "meow", "question": "hello"}
# ainvoke # ainvoke
@ -859,7 +858,7 @@ async def test_openai_agent_with_streaming() -> None:
executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item] executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item]
# Invoke # Invoke
result = executor.invoke({"question": "hello"}) result = await asyncio.to_thread(executor.invoke, {"question": "hello"})
assert result == { assert result == {
"output": "The cat is spying from under the bed.", "output": "The cat is spying from under the bed.",
"question": "hello", "question": "hello",
@ -1066,7 +1065,7 @@ async def test_openai_agent_tools_agent() -> None:
executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item] executor = AgentExecutor(agent=agent, tools=[find_pet]) # type: ignore[arg-type, list-item]
# Invoke # Invoke
result = executor.invoke({"question": "hello"}) result = await asyncio.to_thread(executor.invoke, {"question": "hello"})
assert result == { assert result == {
"output": "The cat is spying from under the bed.", "output": "The cat is spying from under the bed.",
"question": "hello", "question": "hello",

View File

@ -1,12 +1,42 @@
"""Configuration for unit tests.""" """Configuration for unit tests."""
from collections.abc import Iterator
from importlib import util from importlib import util
from typing import Dict, Sequence from typing import Dict, Sequence
import pytest import pytest
from blockbuster import blockbuster_ctx
from pytest import Config, Function, Parser from pytest import Config, Function, Parser
@pytest.fixture(autouse=True)
def blockbuster() -> Iterator[None]:
with blockbuster_ctx("langchain") as bb:
bb.functions["io.TextIOWrapper.read"].can_block_in(
"langchain/__init__.py", "<module>"
)
for func in ["os.stat", "os.path.abspath"]:
(
bb.functions[func]
.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"]:
bb.functions[func].can_block_in(
"langsmith/client.py", "_default_retry_config"
)
for bb_function in bb.functions.values():
bb_function.can_block_in(
"freezegun/api.py", "_get_cached_module_attributes"
)
yield
def pytest_addoption(parser: Parser) -> None: def pytest_addoption(parser: Parser) -> None:
"""Add custom command line options to pytest.""" """Add custom command line options to pytest."""
parser.addoption( parser.addoption(

View File

@ -184,7 +184,12 @@ async def test_callback_handlers() -> None:
model = GenericFakeChatModel(messages=infinite_cycle) model = GenericFakeChatModel(messages=infinite_cycle)
tokens: List[str] = [] tokens: List[str] = []
# New model # New model
results = list(model.stream("meow", {"callbacks": [MyCustomAsyncHandler(tokens)]})) results = [
chunk
async for chunk in model.astream(
"meow", {"callbacks": [MyCustomAsyncHandler(tokens)]}
)
]
assert results == [ assert results == [
_AnyIdAIMessageChunk(content="hello"), _AnyIdAIMessageChunk(content="hello"),
_AnyIdAIMessageChunk(content=" "), _AnyIdAIMessageChunk(content=" "),

View File

@ -77,6 +77,7 @@ def test_test_group_dependencies(uv_conf: Mapping[str, Any]) -> None:
"pytest-socket", "pytest-socket",
"pytest-watcher", "pytest-watcher",
"pytest-xdist", "pytest-xdist",
"blockbuster",
"responses", "responses",
"syrupy", "syrupy",
"toml", "toml",

View File

@ -305,6 +305,18 @@ css = [
{ name = "tinycss2" }, { name = "tinycss2" },
] ]
[[package]]
name = "blockbuster"
version = "1.5.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "forbiddenfruit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/77/a46b97dc6807c88c864a134793d7c7b915dea45c7e44da6c3adebac90501/blockbuster-1.5.14.tar.gz", hash = "sha256:d77ed3b931b058b4e746f65e32ea21e8ed21a4ef0ca88b7bb046bdb057e1adb0", size = 50191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c2/1515ea61aa08f3b44882aa59a0c03be667a6fec2a4026aad76944b40b030/blockbuster-1.5.14-py3-none-any.whl", hash = "sha256:5b5e46ac4b5f5d2a7a599944d83bee0c9eb46509868acb6d8fbc7c8058769aaf", size = 12372 },
]
[[package]] [[package]]
name = "boto3" name = "boto3"
version = "1.36.12" version = "1.36.12"
@ -1053,6 +1065,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/47/db86fba6ef53da08488917a18cbe55b913c8b60275a7f20484ffc73a969c/fireworks_ai-0.15.12-py3-none-any.whl", hash = "sha256:3fbf3f89e65ccfc46c88b71246b9d4fdf3301955ac4050193d8f4b4058cb193a", size = 111129 }, { url = "https://files.pythonhosted.org/packages/0d/47/db86fba6ef53da08488917a18cbe55b913c8b60275a7f20484ffc73a969c/fireworks_ai-0.15.12-py3-none-any.whl", hash = "sha256:3fbf3f89e65ccfc46c88b71246b9d4fdf3301955ac4050193d8f4b4058cb193a", size = 111129 },
] ]
[[package]]
name = "forbiddenfruit"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size = 43756 }
[[package]] [[package]]
name = "fqdn" name = "fqdn"
version = "1.5.1" version = "1.5.1"
@ -2289,6 +2307,7 @@ lint = [
{ name = "ruff" }, { name = "ruff" },
] ]
test = [ test = [
{ name = "blockbuster" },
{ name = "cffi", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "cffi", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "duckdb-engine" }, { name = "duckdb-engine" },
@ -2382,6 +2401,7 @@ lint = [
{ name = "ruff", specifier = ">=0.9.2,<1.0.0" }, { name = "ruff", specifier = ">=0.9.2,<1.0.0" },
] ]
test = [ test = [
{ name = "blockbuster", specifier = ">=1.5.14,<1.6" },
{ name = "cffi", marker = "python_full_version < '3.10'", specifier = "<1.17.1" }, { name = "cffi", marker = "python_full_version < '3.10'", specifier = "<1.17.1" },
{ name = "cffi", marker = "python_full_version >= '3.10'" }, { name = "cffi", marker = "python_full_version >= '3.10'" },
{ name = "duckdb-engine", specifier = ">=0.9.2,<1.0.0" }, { name = "duckdb-engine", specifier = ">=0.9.2,<1.0.0" },