diff --git a/.github/workflows/publish-langchain.yml b/.github/workflows/publish-langchain.yml index 2a87cb35..b356056a 100644 --- a/.github/workflows/publish-langchain.yml +++ b/.github/workflows/publish-langchain.yml @@ -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 }}" diff --git a/.github/workflows/test-langchain.yml b/.github/workflows/test-langchain.yml index b28a96b3..71499980 100644 --- a/.github/workflows/test-langchain.yml +++ b/.github/workflows/test-langchain.yml @@ -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 diff --git a/contrib/langchain/Makefile b/contrib/langchain/Makefile index fb7a4773..761be0eb 100644 --- a/contrib/langchain/Makefile +++ b/contrib/langchain/Makefile @@ -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 diff --git a/contrib/langchain/langchain_arcade/_utilities.py b/contrib/langchain/langchain_arcade/_utilities.py index 9bff4f2c..1176cb3c 100644 --- a/contrib/langchain/langchain_arcade/_utilities.py +++ b/contrib/langchain/langchain_arcade/_utilities.py @@ -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} diff --git a/contrib/langchain/langchain_arcade/manager.py b/contrib/langchain/langchain_arcade/manager.py index a76c3197..03a6997e 100644 --- a/contrib/langchain/langchain_arcade/manager.py +++ b/contrib/langchain/langchain_arcade/manager.py @@ -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. diff --git a/contrib/langchain/pyproject.toml b/contrib/langchain/pyproject.toml index e22d61eb..48bc58d4 100644 --- a/contrib/langchain/pyproject.toml +++ b/contrib/langchain/pyproject.toml @@ -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 "] 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",] diff --git a/contrib/langchain/tests/test_manager.py b/contrib/langchain/tests/test_manager.py index 16ad8a5d..2aa19879 100644 --- a/contrib/langchain/tests/test_manager.py +++ b/contrib/langchain/tests/test_manager.py @@ -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 diff --git a/contrib/langchain/tox.ini b/contrib/langchain/tox.ini index fcb62a70..dd7ffaaa 100644 --- a/contrib/langchain/tox.ini +++ b/contrib/langchain/tox.ini @@ -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