Mateo/lchain (#549)

The latest change to langchain-arcade was blocked becuase it still
relied on poetry, I moved that to uv
This commit is contained in:
Mateo Torres 2025-09-08 13:55:06 -03:00 committed by GitHub
parent c481f1d677
commit 639f726b30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 202 additions and 142 deletions

View file

@ -13,6 +13,7 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
steps:
- name: Checkout code
@ -20,15 +21,11 @@ jobs:
with:
fetch-depth: 0
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.8.5
- uses: actions/setup-python@v5
- name: Set up the environment
uses: ./.github/actions/setup-uv-env
with:
python-version: "3.11"
cache: "pip"
working-directory: contrib/langchain
- name: Test LangChain Arcade
working-directory: contrib/langchain
@ -41,33 +38,38 @@ jobs:
if: inputs.version != ''
working-directory: contrib/langchain
run: |
poetry version ${{ inputs.version }}
uv version ${{ inputs.version }}
- name: Publish to PyPI
- name: Build release distributions
working-directory: contrib/langchain
run: |
poetry build
# Extract version from pyproject.toml using poetry and save it
VERSION=$(poetry version -s)
VERSION=$(uv version --short)
echo "VERSION=$VERSION" >> $GITHUB_ENV
poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
# Attempt to publish the toolkit to PyPI. Skip if the version already exists
if poetry publish --skip-existing 2>&1 | grep -q "File exists. Skipping"; then
echo "Version already exists on PyPI. Skipping publish."
echo "skip_publish=true" >> $GITHUB_OUTPUT
else
echo "skip_publish=false" >> $GITHUB_OUTPUT
fi
uv build --out-dir dist | tee build.log
# Verify build artifacts
ls -la dist/
echo "Built artifacts for langchain_arcade v${{ env.VERSION }}"
- name: Upload release distributions
uses: actions/upload-artifact@v4
with:
name: release-dists-langchain_arcade-${{ env.VERSION }}
path: dist/
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Send status to Slack
if: steps.Publish_LangChain.outputs.skip_publish != 'true'
if: always()
uses: slackapi/slack-github-action@v2.0.0
with:
webhook: ${{ secrets.PACKAGE_RELEASE_SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
{
"status": "${{ job.status }}",
"status": "${{ job.status == 'failure' || job.status == 'cancelled' && 'Failed' || 'Success' }}",
"package": "langchain_arcade",
"version": "${{ env.VERSION }}",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

View file

@ -18,15 +18,17 @@ jobs:
- name: Check out
uses: actions/checkout@v4
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Set up the environment
uses: ./.github/actions/setup-uv-env
with:
version: 1.8.5
python-version: "3.11"
working-directory: contrib/langchain
- name: Install
run: cd contrib/langchain && make install && make check
tox:
test:
runs-on: ubuntu-latest
strategy:
matrix:
@ -41,21 +43,18 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Set up the environment
uses: ./.github/actions/setup-uv-env
with:
version: 1.8.5
python-version: ${{ matrix.python-version }}
working-directory: contrib/langchain
- name: Install dependencies
run: cd contrib/langchain && make install
- name: Install tox
- name: Test & Coverage
run: |
python -m pip install --upgrade pip
python -m pip install tox tox-gh-actions
- name: Test with tox
run: cd contrib/langchain && tox
cd contrib/langchain && make test && make coverage
- name: Upload coverage reports to Codecov with GitHub Action on Python 3.11
uses: codecov/codecov-action@v4.0.1

View file

@ -1,66 +1,47 @@
VERSION ?= "0.1.0"
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the poetry environment and install the pre-commit hooks
@echo "📦 Checking if Poetry is installed"
@if ! command -v poetry >/dev/null 2>&1; then \
echo "📦 Installing Poetry with pip"; \
pip install poetry==1.8.5; \
else \
echo "📦 Poetry is already installed"; \
fi
@echo "🚀 Installing package in development mode with all extras"
poetry install --all-extras
@echo "🚀 Installing pre-commit hooks"
poetry run pre-commit install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@uv run pre-commit install
@echo "✅ All packages and dependencies installed via uv"
.PHONY: check
check: ## Run code quality tools.
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock"
@poetry check --lock
@echo "🚀 Linting code: Running pre-commit"
@poetry run pre-commit run -a
@echo "🚀 Static type checking: Running mypy"
@poetry run mypy $(git ls-files '*.py')
.PHONY: build
build: clean-build ## Build wheel file using uv
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
@uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: set-version
set-version: ## Set the version in the pyproject.toml file
@echo "🚀 Setting version in pyproject.toml"
@poetry version $(VERSION)
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
coverage report
@echo "Generating coverage report"
coverage html
.PHONY: unset-version
unset-version: ## Set the version in the pyproject.toml file
@echo "🚀 Setting version in pyproject.toml"
@poetry version 0.1.0
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --bump patch
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
@poetry build
.PHONY: clean-build
clean-build: ## clean build artifacts
@rm -rf dist
.PHONY: publish
publish: ## publish a release to pypi.
@echo "🚀 Publishing: Dry run."
@poetry config pypi-token.pypi $(PYPI_TOKEN)
@poetry publish --dry-run
@echo "🚀 Publishing."
@poetry publish
.PHONY: build-and-publish
build-and-publish: build publish ## Build and publish.
.PHONY: help
help:
@echo "🛠️ Arcade Dev Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help
.PHONY: check
check: ## Run code quality tools.
@echo "🚀 Linting code: Running pre-commit"
@uv run pre-commit run -a
@echo "🚀 Static type checking: Running mypy"
@uv run mypy --config-file=pyproject.toml

View file

@ -53,7 +53,9 @@ def tool_definition_to_pydantic_model(tool_def: ToolDefinition) -> type[BaseMode
for param in tool_def.input.parameters or []:
param_type = get_python_type(param.value_schema.val_type)
if param_type == list and param.value_schema.inner_val_type: # noqa: E721
inner_type: type[Any] = get_python_type(param.value_schema.inner_val_type)
inner_type: type[Any] = get_python_type(
param.value_schema.inner_val_type
)
param_type = list[inner_type] # type: ignore[valid-type]
param_description = param.description or "No description provided."
default = ... if param.required else None
@ -90,9 +92,14 @@ def process_tool_execution_response(
"tool": tool_name,
}
if execute_response.output is not None and execute_response.output.error is not None:
if (
execute_response.output is not None
and execute_response.output.error is not None
):
error = execute_response.output.error
error_message = str(error.message) if hasattr(error, "message") else "Unknown error"
error_message = (
str(error.message) if hasattr(error, "message") else "Unknown error"
)
error_details["error"] = error_message
# Add all non-None optional error fields to the details
@ -133,10 +140,13 @@ def create_tool_function(
A callable function that executes the tool.
"""
if langgraph and not LANGGRAPH_ENABLED:
raise ImportError("LangGraph is not installed. Please install it to use this feature.")
raise ImportError(
"LangGraph is not installed. Please install it to use this feature."
)
requires_authorization = (
tool_def.requirements is not None and tool_def.requirements.authorization is not None
tool_def.requirements is not None
and tool_def.requirements.authorization is not None
)
def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
@ -161,7 +171,9 @@ def create_tool_function(
# Authorize the user for the tool
auth_response = client.tools.authorize(tool_name=tool_name, user_id=user_id)
if auth_response.status != "completed":
auth_message = f"Please use the following link to authorize: {auth_response.url}"
auth_message = (
f"Please use the following link to authorize: {auth_response.url}"
)
if langgraph:
raise NodeInterrupt(auth_message)
return {"error": auth_message}
@ -249,10 +261,13 @@ def create_async_tool_function(
An async callable function that executes the tool.
"""
if langgraph and not LANGGRAPH_ENABLED:
raise ImportError("LangGraph is not installed. Please install it to use this feature.")
raise ImportError(
"LangGraph is not installed. Please install it to use this feature."
)
requires_authorization = (
tool_def.requirements is not None and tool_def.requirements.authorization is not None
tool_def.requirements is not None
and tool_def.requirements.authorization is not None
)
async def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
@ -275,9 +290,13 @@ def create_async_tool_function(
return {"error": error_message}
# Authorize the user for the tool
auth_response = await client.tools.authorize(tool_name=tool_name, user_id=user_id)
auth_response = await client.tools.authorize(
tool_name=tool_name, user_id=user_id
)
if auth_response.status != "completed":
auth_message = f"Please use the following link to authorize: {auth_response.url}"
auth_message = (
f"Please use the following link to authorize: {auth_response.url}"
)
if langgraph:
raise NodeInterrupt(auth_message)
return {"error": auth_message}

View file

@ -188,7 +188,9 @@ class ToolManager(LangChainToolManager):
"""
tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
return [
wrap_arcade_tool(self._client, tool_name, definition, langgraph=use_interrupts)
wrap_arcade_tool(
self._client, tool_name, definition, langgraph=use_interrupts
)
for tool_name, definition in tool_map.items()
]
@ -228,7 +230,9 @@ class ToolManager(LangChainToolManager):
Raises:
ValueError: If no tools or toolkits are provided and raise_on_empty is True.
"""
tools_list = self._retrieve_tool_definitions(tools, toolkits, raise_on_empty, limit, offset)
tools_list = self._retrieve_tool_definitions(
tools, toolkits, raise_on_empty, limit, offset
)
self._tools = _create_tool_map(tools_list)
return self.to_langchain()
@ -332,7 +336,9 @@ class ToolManager(LangChainToolManager):
# If no specific tools or toolkits are requested, raise an error.
if not tools and not toolkits:
if raise_on_empty:
raise ValueError("No tools or toolkits provided to retrieve tool definitions.")
raise ValueError(
"No tools or toolkits provided to retrieve tool definitions."
)
return []
# Retrieve individual tools if specified
@ -378,7 +384,10 @@ class ToolManager(LangChainToolManager):
self._tools.update(_create_tool_map([tool]))
def add_toolkit(
self, toolkit_name: str, limit: Optional[int] = None, offset: Optional[int] = None
self,
toolkit_name: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> None:
"""
Add all tools from a specific toolkit to the manager.
@ -584,7 +593,9 @@ class AsyncToolManager(LangChainToolManager):
"""
tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
return [
wrap_arcade_tool(self._client, tool_name, definition, langgraph=use_interrupts)
wrap_arcade_tool(
self._client, tool_name, definition, langgraph=use_interrupts
)
for tool_name, definition in tool_map.items()
]
@ -686,7 +697,9 @@ class AsyncToolManager(LangChainToolManager):
# If no specific tools or toolkits are requested, raise an error.
if not tools and not toolkits:
if raise_on_empty:
raise ValueError("No tools or toolkits provided to retrieve tool definitions.")
raise ValueError(
"No tools or toolkits provided to retrieve tool definitions."
)
return []
# First, gather single tools if the user specifically requested them.
@ -734,7 +747,10 @@ class AsyncToolManager(LangChainToolManager):
self._tools.update(_create_tool_map([tool]))
async def add_toolkit(
self, toolkit_name: str, limit: Optional[int] = None, offset: Optional[int] = None
self,
toolkit_name: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> None:
"""
Add all tools from a specific toolkit to the manager.

View file

@ -1,26 +1,32 @@
[tool.poetry]
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "langchain-arcade"
version = "1.4.0"
description = "An integration package connecting Arcade and Langchain/LangGraph"
authors = ["Arcade <dev@arcade.dev>"]
readme = "README.md"
repository = "https://github.com/arcadeai/arcade-ai/tree/main/contrib/langchain"
license = "MIT"
[tool.poetry.dependencies]
python = ">=3.10,<4"
arcadepy = "1.7.*"
langchain-core = ">=0.3.49,<0.4"
requires-python = ">=3.10"
dependencies = [
"arcadepy>=1.7.0",
"langchain-core>=0.3.49,<0.4",
]
[tool.poetry.group.dev.dependencies]
pytest = "^8.1.2"
pytest-cov = "^4.0.0"
mypy = "^1.5.1"
pre-commit = "^3.4.0"
tox = "^4.11.1"
pytest-asyncio = "^0.23.7"
langgraph = ">=0.3.23,<0.4"
[project.optional-dependencies]
dev = [
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"ruff>=0.7.4,<0.8.0",
"langgraph>=0.3.23,<0.4"
]
[tool.mypy]
@ -45,3 +51,9 @@ source = ["langchain_arcade"]
[tool.coverage.report]
skip_empty = true
[tool.ruff.lint]
ignore = ["C901"]
[tool.hatch.build.targets.wheel]
packages = [ "langchain_arcade",]

View file

@ -165,7 +165,9 @@ async def test_init_tools_parameterized(
client.tools.list.return_value = page_cls(items=[mock_tool])
# Act
result = await maybe_await(manager.init_tools(tools=["GoogleSearch_Search"]), is_async)
result = await maybe_await(
manager.init_tools(tools=["GoogleSearch_Search"]), is_async
)
# Assert
assert "GoogleSearch_Search" in manager.tools
@ -220,7 +222,9 @@ async def test_deprecated_get_tools_parameterized(
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning):
result = await maybe_await(manager.get_tools(tools=["GoogleSearch_Search"]), is_async)
result = await maybe_await(
manager.get_tools(tools=["GoogleSearch_Search"]), is_async
)
# Assert - Method should still work
assert len(result) == 1
@ -280,7 +284,9 @@ async def test_add_toolkit_parameterized(
# Mock the response for toolkit listing
page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
client.tools.list.return_value = page_cls(items=[mock_tool_list_emails, mock_tool_trash_email])
client.tools.list.return_value = page_cls(
items=[mock_tool_list_emails, mock_tool_trash_email]
)
# Act
await maybe_await(manager.add_toolkit("Search"), is_async)
@ -290,7 +296,9 @@ async def test_add_toolkit_parameterized(
assert "Gmail_SendEmail" in manager.tools
assert "Gmail_ListEmails" in manager.tools
assert "Gmail_TrashEmail" in manager.tools
client.tools.list.assert_called_once_with(toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN)
client.tools.list.assert_called_once_with(
toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN
)
@pytest.mark.asyncio
@ -339,7 +347,9 @@ async def test_wait_for_auth_with_response_object_parameterized(
client = async_mock_arcade_client if is_async else mock_arcade_client
completed_response = AuthorizationResponse(
id="auth_abc", status="completed", tool_fully_qualified_name="GoogleSearch_Search"
id="auth_abc",
status="completed",
tool_fully_qualified_name="GoogleSearch_Search",
)
client.auth.wait_for_completion.return_value = completed_response
@ -409,7 +419,8 @@ async def test_get_tools_with_explicit_parameterized(
# Act - Check for deprecation warning
with pytest.warns(DeprecationWarning):
retrieved_tools = await maybe_await(
manager.get_tools(tools=["GoogleSearch_Search", "BingSearch_Search"]), is_async
manager.get_tools(tools=["GoogleSearch_Search", "BingSearch_Search"]),
is_async,
)
# Assert
@ -427,7 +438,9 @@ def test_arcade_tool_manager_deprecation_warning():
with pytest.warns(DeprecationWarning) as warnings_record:
ArcadeToolManager(client=MagicMock())
# Assert
assert any("ArcadeToolManager is deprecated" in str(w.message) for w in warnings_record)
assert any(
"ArcadeToolManager is deprecated" in str(w.message) for w in warnings_record
)
@pytest.mark.asyncio
@ -468,7 +481,9 @@ def test_requires_auth_true(manager, make_tool):
# Arrange
tool_name = "GoogleSearch_Search"
# Pass a MagicMock with 'authorization' to ensure it gets converted
mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization="some_required_auth"))
mock_tool_def = make_tool(
tool_name, requirements=MagicMock(authorization="some_required_auth")
)
manager._tools[tool_name] = mock_tool_def
# Act
@ -533,7 +548,9 @@ def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_
mock_arcade_client.tools.get.return_value = mock_tool
# Act
results = manager._retrieve_tool_definitions(tools=["GoogleSearch_Search"], toolkits=None)
results = manager._retrieve_tool_definitions(
tools=["GoogleSearch_Search"], toolkits=None
)
# Assert
assert len(results) == 1
@ -541,7 +558,9 @@ def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_
mock_arcade_client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
def test_retrieve_tool_definitions_toolkits_only(manager, mock_arcade_client, make_tool):
def test_retrieve_tool_definitions_toolkits_only(
manager, mock_arcade_client, make_tool
):
"""
Test the internal _retrieve_tool_definitions method by specifying toolkits.
"""
@ -567,7 +586,9 @@ def test_retrieve_tool_definitions_raise_on_empty(manager):
"""
# Act & Assert
with pytest.raises(ValueError) as excinfo:
manager._retrieve_tool_definitions(tools=None, toolkits=None, raise_on_empty=True)
manager._retrieve_tool_definitions(
tools=None, toolkits=None, raise_on_empty=True
)
assert "No tools or toolkits provided" in str(excinfo.value)
@ -578,7 +599,9 @@ def test_retrieve_tool_definitions_empty_no_raise(manager):
are provided and raise_on_empty is False.
"""
# Act
results = manager._retrieve_tool_definitions(tools=None, toolkits=None, raise_on_empty=False)
results = manager._retrieve_tool_definitions(
tools=None, toolkits=None, raise_on_empty=False
)
# Assert
assert results == []
@ -601,9 +624,13 @@ async def test_retrieve_tool_definitions_with_limit_offset_parameterized(
# Act
if is_async:
results = await manager._retrieve_tool_definitions(toolkits=["Search"], limit=10, offset=5)
results = await manager._retrieve_tool_definitions(
toolkits=["Search"], limit=10, offset=5
)
else:
results = manager._retrieve_tool_definitions(toolkits=["Search"], limit=10, offset=5)
results = manager._retrieve_tool_definitions(
toolkits=["Search"], limit=10, offset=5
)
# Assert
assert len(results) > 0
@ -618,7 +645,9 @@ def test_get_client_config_with_kwargs():
manager = ToolManager(client=MagicMock()) # Client won't be used here
# Act
with patch.dict("os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}):
with patch.dict(
"os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
):
result = manager._get_client_config(api_key="kwarg_key", base_url="kwarg_url")
# Assert
@ -634,7 +663,9 @@ def test_get_client_config_with_env_vars():
manager = ToolManager(client=MagicMock()) # Client won't be used here
# Act
with patch.dict("os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}):
with patch.dict(
"os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
):
result = manager._get_client_config()
# Assert

View file

@ -10,7 +10,7 @@ python =
[testenv]
passenv = PYTHON_VERSION
allowlist_externals = poetry
allowlist_externals = uv
commands =
poetry install -v --all-extras
pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml
uv sync --active --all-extras
uv pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml