From 2e366c63efc934f316d9562b92e8585825f64782 Mon Sep 17 00:00:00 2001 From: Amir Feizpour Date: Sat, 1 Aug 2026 08:38:50 -0400 Subject: [PATCH 1/2] Drop langchain-community, the last item from issue #510 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit langchain-community was down to a single use: GoogleSerperAPIWrapper in tools.py, called only via its own private _google_serper_api_results method — never the documented public API. Upstream now prints its own "being sunset, no longer actively maintained" deprecation warning on import, so it won't receive future security fixes. Replaced it with a small direct call to the Serper.dev API (the wrapper's method was already just a thin requests.post), removing the dependency entirely. Removing langchain-community also dropped langchain-classic, and with it sqlalchemy/greenlet, which were only ever present transitively. sqlalchemy is a real, direct need — database/user_usage_tracker.py imports it for the usage-tracking DB — so it's now declared explicitly in the main dependency group instead of riding in on a package that's going away. This is the same "core code path depends on a package the published package doesn't install" trap flagged in issue #510's review for word2number/pypdf/en_core_web_sm; caught here by actually uninstalling langchain-community and running the real suite rather than just checking imports in sherpa_ai/. Added a focused unit test for the new _google_serper_search helper (there was no direct test of the old wrapper call either), and verified it by mutating the request headers and confirming the test catches it, then reverting. Full suite (385 passed, 2 skipped) run for real with langchain-community and langchain-classic uninstalled from the environment, not just removed from pyproject.toml. Co-Authored-By: Claude Sonnet 5 --- src/poetry.lock | 87 ++----------------- src/pyproject.toml | 6 +- src/sherpa_ai/tools.py | 26 +++++- src/tests/conftest.py | 2 +- .../test_citation_validation.py | 2 +- .../unit_tests/tools/test_search_tool.py | 26 +++++- 6 files changed, 63 insertions(+), 86 deletions(-) diff --git a/src/poetry.lock b/src/poetry.lock index e9ddd0860..7de214329 100644 --- a/src/poetry.lock +++ b/src/poetry.lock @@ -6,7 +6,7 @@ version = "2.7.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, @@ -18,7 +18,7 @@ version = "3.14.2" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiohttp-3.14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ceb77c159b2b4c1a179b96a26af36bcaa68eb79c393ec4f569386a69d013cbe9"}, {file = "aiohttp-3.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3f3381f81bc1c6cbe160b2a3708d39d05014329118e6b648b95edc841eeeebd4"}, @@ -161,7 +161,7 @@ version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -265,7 +265,7 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" -groups = ["main", "optional"] +groups = ["optional"] markers = "python_version == \"3.10\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, @@ -1320,7 +1320,7 @@ version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, @@ -2226,75 +2226,6 @@ numpy = [ {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, ] -[[package]] -name = "langchain-classic" -version = "1.0.8" -description = "Building applications with LLMs through composability" -optional = false -python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] -files = [ - {file = "langchain_classic-1.0.8-py3-none-any.whl", hash = "sha256:1a11ea7fbe630c4f2af2f3873d27718ceac9488cf32d0821030be7cf039a6213"}, - {file = "langchain_classic-1.0.8.tar.gz", hash = "sha256:ada0cc341a8a5b80fb24d73bdfaaeb849056ee2d8a41cc468355163fd3667484"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} -langchain-core = ">=1.4.4,<2.0.0" -langchain-text-splitters = ">=1.1.2,<2.0.0" -langsmith = ">=0.1.17,<1.0.0" -pydantic = ">=2.7.4,<3.0.0" -pyyaml = ">=5.3.0,<7.0.0" -requests = ">=2.0.0,<3.0.0" -sqlalchemy = ">=1.4.0,<3.0.0" - -[package.extras] -anthropic = ["langchain-anthropic"] -aws = ["langchain-aws"] -azure-ai = ["langchain-azure-ai"] -cohere = ["langchain-cohere"] -community = ["langchain-community"] -deepseek = ["langchain-deepseek"] -fireworks = ["langchain-fireworks"] -google-genai = ["langchain-google-genai"] -google-vertexai = ["langchain-google-vertexai"] -groq = ["langchain-groq"] -huggingface = ["langchain-huggingface"] -mistralai = ["langchain-mistralai"] -ollama = ["langchain-ollama"] -openai = ["langchain-openai"] -perplexity = ["langchain-perplexity"] -together = ["langchain-together"] -xai = ["langchain-xai"] - -[[package]] -name = "langchain-community" -version = "0.4.2" -description = "Community contributed LangChain integrations." -optional = false -python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] -files = [ - {file = "langchain_community-0.4.2-py3-none-any.whl", hash = "sha256:84dd8c5122532394d5b6849a5fc9995ef28e4f77227daeb09f24b3d942e9e466"}, - {file = "langchain_community-0.4.2.tar.gz", hash = "sha256:a99308160d53d7e9b5965ee665e5173709914338210089fd5788ad724432c21e"}, -] - -[package.dependencies] -aiohttp = ">=3.8.3,<4.0.0" -httpx-sse = ">=0.4.0,<1.0.0" -langchain-classic = ">=1.0.7,<2.0.0" -langchain-core = ">=1.4.0,<2.0.0" -langsmith = ">=0.1.125,<1.0.0" -numpy = [ - {version = ">=1.26.2", markers = "python_version < \"3.13\""}, - {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, -] -pydantic-settings = ">=2.10.1,<3.0.0" -pyyaml = ">=5.3.0,<7.0.0" -requests = ">=2.32.5,<3.0.0" -sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" - [[package]] name = "langchain-core" version = "1.5.2" @@ -2859,7 +2790,7 @@ version = "6.7.1" description = "multidict implementation" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, @@ -3733,7 +3664,7 @@ version = "0.5.2" description = "Accelerated property cache" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, @@ -6961,7 +6892,7 @@ version = "1.24.5" description = "Yet another URL library" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, @@ -7210,4 +7141,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "<3.14,>=3.10" -content-hash = "79a448728a85c44641ad9b79bfee561a6e8c744ef2a3ce41078613f33a69508b" +content-hash = "d68d58760b3388c028307609e58ce3b4034f1150c109fb9e5ec0d28269402d9d" diff --git a/src/pyproject.toml b/src/pyproject.toml index 3191ece00..bba1b7a2c 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -22,7 +22,6 @@ optional = true [tool.poetry.dependencies] python = "<3.14,>=3.10" langchain-core = ">=1.5.0,<2.0" -langchain-community = ">=0.4.2,<0.5" langchain-text-splitters = ">=1.1.2,<2.0" python-dotenv = "^1.0.0" langchain-openai = ">=1.3.5,<2.0" @@ -53,6 +52,11 @@ nltk = "^3.9.1" # optional even though nothing in sherpa_ai/ imports it directly. It was # previously pulled in transitively by `unstructured`. transformers = "^5.14.1" +# database/user_usage_tracker.py imports sqlalchemy directly for the usage- +# tracking DB. It was previously only a transitive dependency of +# langchain-community (via langchain-classic); dropping that package +# exposed this as a missing direct dependency. +sqlalchemy = "^2.0.51" [tool.poetry.group.test.dependencies] pytest = "^9.0.3" diff --git a/src/sherpa_ai/tools.py b/src/sherpa_ai/tools.py index 51a2a5e37..cadf3df4c 100644 --- a/src/sherpa_ai/tools.py +++ b/src/sherpa_ai/tools.py @@ -3,7 +3,6 @@ from typing import Any, List, Tuple, Union import requests -from langchain_community.utilities import GoogleSerperAPIWrapper from langchain_core.tools import BaseTool from langchain_core.vectorstores import VectorStoreRetriever from loguru import logger @@ -19,6 +18,28 @@ HTTP_GET_TIMEOUT = 20.0 +def _google_serper_search(query: str) -> dict: + """Query the Serper.dev Google Search API directly. + + Replaces langchain_community's GoogleSerperAPIWrapper, which was the + package's only remaining use of langchain-community (a project that + now prints its own "being sunset, no longer actively maintained" + deprecation warning on import). + """ + headers = { + "X-API-KEY": cfg.SERPER_API_KEY or "", + "Content-Type": "application/json", + } + response = requests.post( + "https://google.serper.dev/search", + headers=headers, + params={"q": query}, + timeout=HTTP_GET_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + def get_tools(memory, config): """Factory function to create and configure a set of tools for the agent. @@ -328,8 +349,7 @@ def _run_single_query( Link: https://example.com/python """ logger.debug(f"Search query: {query}") - google_serper = GoogleSerperAPIWrapper() - search_results = google_serper._google_serper_api_results(query) + search_results = _google_serper_search(query) logger.debug(f"Google Search Result: {search_results}") # case 1: answerBox in the result dictionary diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 91470649a..b0292c719 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -46,7 +46,7 @@ def mock_env(external_api): os.environ["OPENAI_API_KEY"] = "dummy" with mock.patch( - "langchain_community.utilities.GoogleSerperAPIWrapper._google_serper_api_results" + "sherpa_ai.tools._google_serper_search" ) as mock_search, mock.patch("sherpa_ai.utils.scrape_with_url") as mock_scrape: mock_search.return_value = GOOGLE_SEARCH_MOCK # mock_socket.side_effect = guard diff --git a/src/tests/unit_tests/output_parsers/test_citation_validation.py b/src/tests/unit_tests/output_parsers/test_citation_validation.py index 733cd5f21..01b05c02b 100644 --- a/src/tests/unit_tests/output_parsers/test_citation_validation.py +++ b/src/tests/unit_tests/output_parsers/test_citation_validation.py @@ -77,7 +77,7 @@ def test_task_agent_succeeds(get_llm, external_api): # noqa: F811 } with mock.patch( - "langchain_community.utilities.GoogleSerperAPIWrapper._google_serper_api_results" + "sherpa_ai.tools._google_serper_search" ) as mock_search: mock_search.return_value = GOOGLE_SEARCH_MOCK result = task_agent.run() diff --git a/src/tests/unit_tests/tools/test_search_tool.py b/src/tests/unit_tests/tools/test_search_tool.py index 194d4c1f7..6b1a12a32 100644 --- a/src/tests/unit_tests/tools/test_search_tool.py +++ b/src/tests/unit_tests/tools/test_search_tool.py @@ -1,11 +1,12 @@ import re -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from loguru import logger +import sherpa_ai.config as cfg from sherpa_ai.config import AgentConfig -from sherpa_ai.tools import SearchTool +from sherpa_ai.tools import SearchTool, _google_serper_search def _extract_links(search_result: str) -> list: @@ -16,6 +17,27 @@ def _extract_links(search_result: str) -> list: return re.findall(r"Link:(\S+)", search_result) +def test_google_serper_search_calls_serper_api_directly(): + """Regression test for the langchain-community removal: this hits + Serper's API directly (no GoogleSerperAPIWrapper in between), so pin + down the request shape it relies on.""" + mock_response = MagicMock() + mock_response.json.return_value = {"organic": []} + + with patch.object(cfg, "SERPER_API_KEY", "test-key"), \ + patch("sherpa_ai.tools.requests.post", return_value=mock_response) as mock_post: + result = _google_serper_search("what is the weather today?") + + mock_post.assert_called_once_with( + "https://google.serper.dev/search", + headers={"X-API-KEY": "test-key", "Content-Type": "application/json"}, + params={"q": "what is the weather today?"}, + timeout=pytest.approx(20.0), + ) + mock_response.raise_for_status.assert_called_once() + assert result == {"organic": []} + + def test_formulate_search_query(): config = AgentConfig(verbose=True) search_tool = SearchTool(config=config) From 32866c2e668a7414f62866cd8bdbe4b0ba51b00b Mon Sep 17 00:00:00 2001 From: Amir Feizpour Date: Fri, 7 Aug 2026 23:50:59 -0400 Subject: [PATCH 2/2] Address review: wrap Serper search in GoogleSerperAPIWrapper class Per review feedback on #543, replace the standalone _google_serper_search function with a GoogleSerperAPIWrapper(BaseModel) class exposing a search() method, matching the requested design and mirroring the shape of the langchain wrapper it replaces. Co-Authored-By: Claude Sonnet 5 --- src/sherpa_ai/tools.py | 37 +++++++++++-------- src/tests/conftest.py | 2 +- .../test_citation_validation.py | 2 +- .../unit_tests/tools/test_search_tool.py | 8 +++- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/sherpa_ai/tools.py b/src/sherpa_ai/tools.py index cadf3df4c..dcc60b73d 100644 --- a/src/sherpa_ai/tools.py +++ b/src/sherpa_ai/tools.py @@ -6,6 +6,7 @@ from langchain_core.tools import BaseTool from langchain_core.vectorstores import VectorStoreRetriever from loguru import logger +from pydantic import BaseModel, Field from typing_extensions import Literal import sherpa_ai.config as cfg @@ -18,26 +19,32 @@ HTTP_GET_TIMEOUT = 20.0 -def _google_serper_search(query: str) -> dict: - """Query the Serper.dev Google Search API directly. +class GoogleSerperAPIWrapper(BaseModel): + """Wrapper around the Serper.dev Google Search API. Replaces langchain_community's GoogleSerperAPIWrapper, which was the package's only remaining use of langchain-community (a project that now prints its own "being sunset, no longer actively maintained" deprecation warning on import). """ - headers = { - "X-API-KEY": cfg.SERPER_API_KEY or "", - "Content-Type": "application/json", - } - response = requests.post( - "https://google.serper.dev/search", - headers=headers, - params={"q": query}, - timeout=HTTP_GET_TIMEOUT, - ) - response.raise_for_status() - return response.json() + + url: str = "https://google.serper.dev/search" + api_key: str = Field(default_factory=lambda: cfg.SERPER_API_KEY or "") + + def search(self, query: str) -> dict: + """Query the Serper.dev Google Search API directly.""" + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + response = requests.post( + self.url, + headers=headers, + params={"q": query}, + timeout=HTTP_GET_TIMEOUT, + ) + response.raise_for_status() + return response.json() def get_tools(memory, config): @@ -349,7 +356,7 @@ def _run_single_query( Link: https://example.com/python """ logger.debug(f"Search query: {query}") - search_results = _google_serper_search(query) + search_results = GoogleSerperAPIWrapper().search(query) logger.debug(f"Google Search Result: {search_results}") # case 1: answerBox in the result dictionary diff --git a/src/tests/conftest.py b/src/tests/conftest.py index b0292c719..a04a8ed6d 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -46,7 +46,7 @@ def mock_env(external_api): os.environ["OPENAI_API_KEY"] = "dummy" with mock.patch( - "sherpa_ai.tools._google_serper_search" + "sherpa_ai.tools.GoogleSerperAPIWrapper.search" ) as mock_search, mock.patch("sherpa_ai.utils.scrape_with_url") as mock_scrape: mock_search.return_value = GOOGLE_SEARCH_MOCK # mock_socket.side_effect = guard diff --git a/src/tests/unit_tests/output_parsers/test_citation_validation.py b/src/tests/unit_tests/output_parsers/test_citation_validation.py index 01b05c02b..23e4b5077 100644 --- a/src/tests/unit_tests/output_parsers/test_citation_validation.py +++ b/src/tests/unit_tests/output_parsers/test_citation_validation.py @@ -77,7 +77,7 @@ def test_task_agent_succeeds(get_llm, external_api): # noqa: F811 } with mock.patch( - "sherpa_ai.tools._google_serper_search" + "sherpa_ai.tools.GoogleSerperAPIWrapper.search" ) as mock_search: mock_search.return_value = GOOGLE_SEARCH_MOCK result = task_agent.run() diff --git a/src/tests/unit_tests/tools/test_search_tool.py b/src/tests/unit_tests/tools/test_search_tool.py index 6b1a12a32..facaacb64 100644 --- a/src/tests/unit_tests/tools/test_search_tool.py +++ b/src/tests/unit_tests/tools/test_search_tool.py @@ -6,7 +6,11 @@ import sherpa_ai.config as cfg from sherpa_ai.config import AgentConfig -from sherpa_ai.tools import SearchTool, _google_serper_search +from sherpa_ai.tools import GoogleSerperAPIWrapper, SearchTool + +# Captured at collection time, before the autouse `mock_env` fixture (conftest.py) +# patches GoogleSerperAPIWrapper.search for every test. +_real_search = GoogleSerperAPIWrapper.search def _extract_links(search_result: str) -> list: @@ -26,7 +30,7 @@ def test_google_serper_search_calls_serper_api_directly(): with patch.object(cfg, "SERPER_API_KEY", "test-key"), \ patch("sherpa_ai.tools.requests.post", return_value=mock_response) as mock_post: - result = _google_serper_search("what is the weather today?") + result = _real_search(GoogleSerperAPIWrapper(), "what is the weather today?") mock_post.assert_called_once_with( "https://google.serper.dev/search",