langchain-arcade 1.3.0__tar.gz → 1.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ .DS_Store
2
+ credentials.yaml
3
+ docker/credentials.yaml
4
+
5
+ *.lock
6
+
7
+ # example data
8
+ examples/data
9
+ scratch
10
+
11
+
12
+ docs/source
13
+
14
+ # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
15
+
16
+ # Byte-compiled / optimized / DLL files
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+
21
+ # C extensions
22
+ *.so
23
+
24
+ # Distribution / packaging
25
+ .Python
26
+ build/
27
+ develop-eggs/
28
+ dist/
29
+ downloads/
30
+ eggs/
31
+ .eggs/
32
+ lib/
33
+ lib64/
34
+ parts/
35
+ sdist/
36
+ var/
37
+ wheels/
38
+ share/python-wheels/
39
+ *.egg-info/
40
+ .installed.cfg
41
+ *.egg
42
+ MANIFEST
43
+
44
+ # PyInstaller
45
+ # Usually these files are written by a python script from a template
46
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
47
+ *.manifest
48
+ *.spec
49
+
50
+ # Installer logs
51
+ pip-log.txt
52
+ pip-delete-this-directory.txt
53
+
54
+ # Unit test / coverage reports
55
+ htmlcov/
56
+ .tox/
57
+ .nox/
58
+ .coverage
59
+ .coverage.*
60
+ .cache
61
+ nosetests.xml
62
+ coverage.xml
63
+ *.cover
64
+ *.py,cover
65
+ .hypothesis/
66
+ .pytest_cache/
67
+ cover/
68
+
69
+ # Translations
70
+ *.mo
71
+ *.pot
72
+
73
+ # Django stuff:
74
+ *.log
75
+ local_settings.py
76
+ db.sqlite3
77
+ db.sqlite3-journal
78
+
79
+ # Flask stuff:
80
+ instance/
81
+ .webassets-cache
82
+
83
+ # Scrapy stuff:
84
+ .scrapy
85
+
86
+ # Sphinx documentation
87
+ docs/_build/
88
+
89
+ # PyBuilder
90
+ .pybuilder/
91
+ target/
92
+
93
+ # Jupyter Notebook
94
+ .ipynb_checkpoints
95
+
96
+ # IPython
97
+ profile_default/
98
+ ipython_config.py
99
+
100
+ # pyenv
101
+ # For a library or package, you might want to ignore these files since the code is
102
+ # intended to run in multiple environments; otherwise, check them in:
103
+ # .python-version
104
+
105
+ # pipenv
106
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
107
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
108
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
109
+ # install all needed dependencies.
110
+ #Pipfile.lock
111
+
112
+ # poetry
113
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
114
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
115
+ # commonly ignored for libraries.
116
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
117
+ poetry.lock
118
+
119
+ # pdm
120
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
121
+ #pdm.lock
122
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
123
+ # in version control.
124
+ # https://pdm.fming.dev/#use-with-ide
125
+ .pdm.toml
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .venv
140
+ env/
141
+ venv/
142
+ ENV/
143
+ env.bak/
144
+ venv.bak/
145
+
146
+ # Spyder project settings
147
+ .spyderproject
148
+ .spyproject
149
+
150
+ # Rope project settings
151
+ .ropeproject
152
+
153
+ # mkdocs documentation
154
+ /site
155
+
156
+ # mypy
157
+ .mypy_cache/
158
+ .dmypy.json
159
+ dmypy.json
160
+
161
+ # Pyre type checker
162
+ .pyre/
163
+
164
+ # pytype static type analyzer
165
+ .pytype/
166
+
167
+ # Cython debug symbols
168
+ cython_debug/
169
+
170
+ # PyCharm
171
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
172
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
173
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
174
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
175
+ #.idea/
@@ -0,0 +1,47 @@
1
+ .PHONY: help
2
+
3
+ help:
4
+ @echo "🛠️ github Commands:\n"
5
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
6
+
7
+
8
+ .PHONY: install
9
+ install: ## Install the uv environment and install all packages with dependencies
10
+ @echo "🚀 Creating virtual environment and installing all packages using uv"
11
+ @uv sync --active --all-extras --no-sources
12
+ @uv run pre-commit install
13
+ @echo "✅ All packages and dependencies installed via uv"
14
+
15
+ .PHONY: build
16
+ build: clean-build ## Build wheel file using uv
17
+ @echo "🚀 Creating wheel file"
18
+ uv build
19
+
20
+ .PHONY: clean-build
21
+ clean-build: ## clean build artifacts
22
+ @echo "🗑️ Cleaning dist directory"
23
+ rm -rf dist
24
+
25
+ .PHONY: test
26
+ test: ## Test the code with pytest
27
+ @echo "🚀 Testing code: Running pytest"
28
+ @uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
29
+
30
+ .PHONY: coverage
31
+ coverage: ## Generate coverage report
32
+ @echo "coverage report"
33
+ coverage report
34
+ @echo "Generating coverage report"
35
+ coverage html
36
+
37
+ .PHONY: bump-version
38
+ bump-version: ## Bump the version in the pyproject.toml file by a patch version
39
+ @echo "🚀 Bumping version in pyproject.toml"
40
+ uv version --bump patch
41
+
42
+ .PHONY: check
43
+ check: ## Run code quality tools.
44
+ @echo "🚀 Linting code: Running pre-commit"
45
+ @uv run pre-commit run -a
46
+ @echo "🚀 Static type checking: Running mypy"
47
+ @uv run mypy --config-file=pyproject.toml
@@ -1,21 +1,21 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: langchain-arcade
3
- Version: 1.3.0
3
+ Version: 1.4.4
4
4
  Summary: An integration package connecting Arcade and Langchain/LangGraph
5
- Home-page: https://github.com/arcadeai/arcade-ai/tree/main/contrib/langchain
6
- License: MIT
7
- Author: Arcade
8
- Author-email: dev@arcade.dev
9
- Requires-Python: >=3.10,<4
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: arcadepy (==1.3.*)
17
- Requires-Dist: langgraph (>=0.2.67,<0.3.0)
18
- Project-URL: Repository, https://github.com/arcadeai/arcade-ai/tree/main/contrib/langchain
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: arcadepy>=1.7.0
9
+ Requires-Dist: langchain-core<0.4,>=0.3.49
10
+ Provides-Extra: dev
11
+ Requires-Dist: langgraph<0.4,>=0.3.23; extra == 'dev'
12
+ Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
13
+ Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
15
+ Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
16
+ Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
17
+ Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
18
+ Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
19
19
  Description-Content-Type: text/markdown
20
20
 
21
21
  <h3 align="center">
@@ -80,6 +80,10 @@ langchain_tools = manager.to_langchain()
80
80
 
81
81
  ### 2. Use with LangGraph
82
82
 
83
+ ```bash
84
+ pip install langgraph
85
+ ```
86
+
83
87
  Here's a simple example of using Arcade tools with LangGraph:
84
88
 
85
89
  ```python
@@ -189,4 +193,3 @@ For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade
189
193
  ## More Examples
190
194
 
191
195
  For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/langchain).
192
-
@@ -60,6 +60,10 @@ langchain_tools = manager.to_langchain()
60
60
 
61
61
  ### 2. Use with LangGraph
62
62
 
63
+ ```bash
64
+ pip install langgraph
65
+ ```
66
+
63
67
  Here's a simple example of using Arcade tools with LangGraph:
64
68
 
65
69
  ```python
@@ -53,7 +53,9 @@ def tool_definition_to_pydantic_model(tool_def: ToolDefinition) -> type[BaseMode
53
53
  for param in tool_def.input.parameters or []:
54
54
  param_type = get_python_type(param.value_schema.val_type)
55
55
  if param_type == list and param.value_schema.inner_val_type: # noqa: E721
56
- inner_type: type[Any] = get_python_type(param.value_schema.inner_val_type)
56
+ inner_type: type[Any] = get_python_type(
57
+ param.value_schema.inner_val_type
58
+ )
57
59
  param_type = list[inner_type] # type: ignore[valid-type]
58
60
  param_description = param.description or "No description provided."
59
61
  default = ... if param.required else None
@@ -90,9 +92,14 @@ def process_tool_execution_response(
90
92
  "tool": tool_name,
91
93
  }
92
94
 
93
- if execute_response.output is not None and execute_response.output.error is not None:
95
+ if (
96
+ execute_response.output is not None
97
+ and execute_response.output.error is not None
98
+ ):
94
99
  error = execute_response.output.error
95
- error_message = str(error.message) if hasattr(error, "message") else "Unknown error"
100
+ error_message = (
101
+ str(error.message) if hasattr(error, "message") else "Unknown error"
102
+ )
96
103
  error_details["error"] = error_message
97
104
 
98
105
  # Add all non-None optional error fields to the details
@@ -133,10 +140,13 @@ def create_tool_function(
133
140
  A callable function that executes the tool.
134
141
  """
135
142
  if langgraph and not LANGGRAPH_ENABLED:
136
- raise ImportError("LangGraph is not installed. Please install it to use this feature.")
143
+ raise ImportError(
144
+ "LangGraph is not installed. Please install it to use this feature."
145
+ )
137
146
 
138
147
  requires_authorization = (
139
- tool_def.requirements is not None and tool_def.requirements.authorization is not None
148
+ tool_def.requirements is not None
149
+ and tool_def.requirements.authorization is not None
140
150
  )
141
151
 
142
152
  def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
@@ -161,7 +171,9 @@ def create_tool_function(
161
171
  # Authorize the user for the tool
162
172
  auth_response = client.tools.authorize(tool_name=tool_name, user_id=user_id)
163
173
  if auth_response.status != "completed":
164
- auth_message = f"Please use the following link to authorize: {auth_response.url}"
174
+ auth_message = (
175
+ f"Please use the following link to authorize: {auth_response.url}"
176
+ )
165
177
  if langgraph:
166
178
  raise NodeInterrupt(auth_message)
167
179
  return {"error": auth_message}
@@ -249,10 +261,13 @@ def create_async_tool_function(
249
261
  An async callable function that executes the tool.
250
262
  """
251
263
  if langgraph and not LANGGRAPH_ENABLED:
252
- raise ImportError("LangGraph is not installed. Please install it to use this feature.")
264
+ raise ImportError(
265
+ "LangGraph is not installed. Please install it to use this feature."
266
+ )
253
267
 
254
268
  requires_authorization = (
255
- tool_def.requirements is not None and tool_def.requirements.authorization is not None
269
+ tool_def.requirements is not None
270
+ and tool_def.requirements.authorization is not None
256
271
  )
257
272
 
258
273
  async def tool_function(config: RunnableConfig, **kwargs: Any) -> Any:
@@ -275,9 +290,13 @@ def create_async_tool_function(
275
290
  return {"error": error_message}
276
291
 
277
292
  # Authorize the user for the tool
278
- auth_response = await client.tools.authorize(tool_name=tool_name, user_id=user_id)
293
+ auth_response = await client.tools.authorize(
294
+ tool_name=tool_name, user_id=user_id
295
+ )
279
296
  if auth_response.status != "completed":
280
- auth_message = f"Please use the following link to authorize: {auth_response.url}"
297
+ auth_message = (
298
+ f"Please use the following link to authorize: {auth_response.url}"
299
+ )
281
300
  if langgraph:
282
301
  raise NodeInterrupt(auth_message)
283
302
  return {"error": auth_message}
@@ -188,7 +188,9 @@ class ToolManager(LangChainToolManager):
188
188
  """
189
189
  tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
190
190
  return [
191
- wrap_arcade_tool(self._client, tool_name, definition, langgraph=use_interrupts)
191
+ wrap_arcade_tool(
192
+ self._client, tool_name, definition, langgraph=use_interrupts
193
+ )
192
194
  for tool_name, definition in tool_map.items()
193
195
  ]
194
196
 
@@ -228,7 +230,9 @@ class ToolManager(LangChainToolManager):
228
230
  Raises:
229
231
  ValueError: If no tools or toolkits are provided and raise_on_empty is True.
230
232
  """
231
- tools_list = self._retrieve_tool_definitions(tools, toolkits, raise_on_empty, limit, offset)
233
+ tools_list = self._retrieve_tool_definitions(
234
+ tools, toolkits, raise_on_empty, limit, offset
235
+ )
232
236
  self._tools = _create_tool_map(tools_list)
233
237
  return self.to_langchain()
234
238
 
@@ -332,7 +336,9 @@ class ToolManager(LangChainToolManager):
332
336
  # If no specific tools or toolkits are requested, raise an error.
333
337
  if not tools and not toolkits:
334
338
  if raise_on_empty:
335
- raise ValueError("No tools or toolkits provided to retrieve tool definitions.")
339
+ raise ValueError(
340
+ "No tools or toolkits provided to retrieve tool definitions."
341
+ )
336
342
  return []
337
343
 
338
344
  # Retrieve individual tools if specified
@@ -378,7 +384,10 @@ class ToolManager(LangChainToolManager):
378
384
  self._tools.update(_create_tool_map([tool]))
379
385
 
380
386
  def add_toolkit(
381
- self, toolkit_name: str, limit: Optional[int] = None, offset: Optional[int] = None
387
+ self,
388
+ toolkit_name: str,
389
+ limit: Optional[int] = None,
390
+ offset: Optional[int] = None,
382
391
  ) -> None:
383
392
  """
384
393
  Add all tools from a specific toolkit to the manager.
@@ -584,7 +593,9 @@ class AsyncToolManager(LangChainToolManager):
584
593
  """
585
594
  tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores)
586
595
  return [
587
- wrap_arcade_tool(self._client, tool_name, definition, langgraph=use_interrupts)
596
+ wrap_arcade_tool(
597
+ self._client, tool_name, definition, langgraph=use_interrupts
598
+ )
588
599
  for tool_name, definition in tool_map.items()
589
600
  ]
590
601
 
@@ -686,7 +697,9 @@ class AsyncToolManager(LangChainToolManager):
686
697
  # If no specific tools or toolkits are requested, raise an error.
687
698
  if not tools and not toolkits:
688
699
  if raise_on_empty:
689
- raise ValueError("No tools or toolkits provided to retrieve tool definitions.")
700
+ raise ValueError(
701
+ "No tools or toolkits provided to retrieve tool definitions."
702
+ )
690
703
  return []
691
704
 
692
705
  # First, gather single tools if the user specifically requested them.
@@ -734,7 +747,10 @@ class AsyncToolManager(LangChainToolManager):
734
747
  self._tools.update(_create_tool_map([tool]))
735
748
 
736
749
  async def add_toolkit(
737
- self, toolkit_name: str, limit: Optional[int] = None, offset: Optional[int] = None
750
+ self,
751
+ toolkit_name: str,
752
+ limit: Optional[int] = None,
753
+ offset: Optional[int] = None,
738
754
  ) -> None:
739
755
  """
740
756
  Add all tools from a specific toolkit to the manager.
@@ -1,25 +1,32 @@
1
- [tool.poetry]
1
+ [build-system]
2
+ requires = [ "hatchling",]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
2
6
  name = "langchain-arcade"
3
- version = "1.3.0"
7
+ version = "1.4.4"
4
8
  description = "An integration package connecting Arcade and Langchain/LangGraph"
5
- authors = ["Arcade <dev@arcade.dev>"]
6
9
  readme = "README.md"
7
10
  repository = "https://github.com/arcadeai/arcade-ai/tree/main/contrib/langchain"
8
11
  license = "MIT"
9
-
10
- [tool.poetry.dependencies]
11
- python = ">=3.10,<4"
12
- arcadepy = "1.3.*"
13
- langgraph = ">=0.2.67,<0.3.0"
14
-
15
-
16
- [tool.poetry.group.dev.dependencies]
17
- pytest = "^8.1.2"
18
- pytest-cov = "^4.0.0"
19
- mypy = "^1.5.1"
20
- pre-commit = "^3.4.0"
21
- tox = "^4.11.1"
22
- pytest-asyncio = "^0.23.7"
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "arcadepy>=1.7.0",
15
+ "langchain-core>=0.3.49,<0.4",
16
+ ]
17
+
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.3.0,<8.4.0",
22
+ "pytest-cov>=4.0.0,<4.1.0",
23
+ "pytest-mock>=3.11.1,<3.12.0",
24
+ "pytest-asyncio>=0.24.0,<0.25.0",
25
+ "mypy>=1.5.1,<1.6.0",
26
+ "pre-commit>=3.4.0,<3.5.0",
27
+ "ruff>=0.7.4,<0.8.0",
28
+ "langgraph>=0.3.23,<0.4"
29
+ ]
23
30
 
24
31
 
25
32
  [tool.mypy]
@@ -44,3 +51,9 @@ source = ["langchain_arcade"]
44
51
 
45
52
  [tool.coverage.report]
46
53
  skip_empty = true
54
+
55
+ [tool.ruff.lint]
56
+ ignore = ["C901"]
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = [ "langchain_arcade",]
@@ -0,0 +1,33 @@
1
+ import os
2
+
3
+ import pytest
4
+ from arcadepy import Arcade
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def arcade_base_url():
9
+ """
10
+ Retrieve the ARCADE_BASE_URL from the environment, falling back to a default
11
+ if not found.
12
+ """
13
+ return os.getenv("ARCADE_BASE_URL", "http://localhost:9099")
14
+
15
+
16
+ @pytest.fixture(scope="session")
17
+ def arcade_api_key():
18
+ """
19
+ Retrieve the ARCADE_API_KEY from the environment, falling back to a default
20
+ if not found.
21
+ """
22
+ return os.getenv("ARCADE_API_KEY", "test_api_key")
23
+
24
+
25
+ @pytest.fixture(scope="session")
26
+ def arcade_client(arcade_base_url, arcade_api_key):
27
+ """
28
+ Creates a single Arcade client instance for use in all tests.
29
+ Any method calls on this client can be patched/mocked within the tests.
30
+ """
31
+ client = Arcade(api_key=arcade_api_key, base_url=arcade_base_url)
32
+ yield client
33
+ # Teardown logic would go here if necessary
@@ -0,0 +1,738 @@
1
+ from unittest.mock import AsyncMock, MagicMock, patch
2
+
3
+ import pytest
4
+ from arcadepy import NOT_GIVEN
5
+ from arcadepy.pagination import AsyncOffsetPage, SyncOffsetPage
6
+ from arcadepy.types import ToolDefinition
7
+ from arcadepy.types.shared import AuthorizationResponse
8
+ from langchain_arcade.manager import ArcadeToolManager, AsyncToolManager, ToolManager
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_arcade_client():
13
+ """
14
+ A fixture to mock the Arcade client object for testing the ToolManager.
15
+
16
+ This mocks all relevant methods used by the manager, including:
17
+ - tools.get
18
+ - tools.list
19
+ - tools.authorize
20
+ - auth.status
21
+ - auth.wait_for_completion
22
+ """
23
+ mock_client = MagicMock()
24
+ # Mock the "tools" sub-client
25
+ mock_client.tools.get = MagicMock()
26
+ mock_client.tools.list = MagicMock()
27
+ mock_client.tools.authorize = MagicMock()
28
+ # Mock the "auth" sub-client
29
+ mock_client.auth.status = MagicMock()
30
+ mock_client.auth.wait_for_completion = MagicMock()
31
+
32
+ return mock_client
33
+
34
+
35
+ @pytest.fixture
36
+ def async_mock_arcade_client():
37
+ """
38
+ A fixture to mock the Arcade client object for testing the AsyncToolManager.
39
+ """
40
+ mock_client = AsyncMock()
41
+ mock_client.tools.get = AsyncMock()
42
+ mock_client.tools.list = AsyncMock()
43
+ mock_client.tools.authorize = AsyncMock()
44
+ mock_client.auth.status = AsyncMock()
45
+ mock_client.auth.wait_for_completion = AsyncMock()
46
+ return mock_client
47
+
48
+
49
+ @pytest.fixture
50
+ def manager(mock_arcade_client):
51
+ """
52
+ A fixture that creates a ToolManager with the mocked Arcade client.
53
+ """
54
+ return ToolManager(client=mock_arcade_client)
55
+
56
+
57
+ @pytest.fixture
58
+ def async_manager(async_mock_arcade_client):
59
+ """
60
+ A fixture that creates an AsyncToolManager with the mocked Arcade client.
61
+ """
62
+ return AsyncToolManager(client=async_mock_arcade_client)
63
+
64
+
65
+ @pytest.fixture(params=[("sync", False), ("async", True)])
66
+ def manager_fixture(request, manager, async_manager):
67
+ """
68
+ A parameterized fixture that returns a tuple with:
69
+ - The appropriate manager (sync or async)
70
+ - A boolean indicating if it's async
71
+ - The appropriate mock client
72
+ """
73
+ param_name, is_async = request.param
74
+ if is_async:
75
+ return async_manager, True
76
+ else:
77
+ return manager, False
78
+
79
+
80
+ @pytest.fixture
81
+ def make_tool():
82
+ """
83
+ A factory fixture for creating a valid ToolDefinition with a given
84
+ fully qualified name. Because the underlying ToolDefinition model
85
+ expects "toolkit" to be a dictionary with at least one field (for example "slug"),
86
+ and "requirements.authorization" to be a valid dictionary if present, we set them up
87
+ accordingly.
88
+ """
89
+
90
+ def _make_tool(fully_qualified_name="GoogleSearch_Search", **kwargs):
91
+ # Split on the first dot to derive a 'toolkit' slug and a tool 'name'
92
+ if "." in fully_qualified_name:
93
+ raw_toolkit, raw_tool_name = fully_qualified_name.split(".", 1)
94
+ elif "_" in fully_qualified_name:
95
+ # Convert from "_" to "." to match the expected format of tool name when
96
+ # using Langchain models for LLM inference.
97
+ raw_toolkit, raw_tool_name = fully_qualified_name.split("_", 1)
98
+
99
+ else:
100
+ raw_toolkit, raw_tool_name = fully_qualified_name, fully_qualified_name
101
+
102
+ # Provide a default toolkit dict unless one already exists in kwargs
103
+ toolkit = kwargs.pop("toolkit", {"name": raw_toolkit})
104
+
105
+ # Provide a default input
106
+ # arcadepy.types.ToolDefinition expects "input" to be a valid structure (dict).
107
+ tool_input = kwargs.pop("input", {"parameters": []})
108
+
109
+ # Convert MagicMock-based requirements (with authorization) to an appropriate dict,
110
+ # or use what's passed. If none is passed, default to None.
111
+ requirements = kwargs.pop("requirements", None)
112
+ if requirements is not None and not isinstance(requirements, dict):
113
+ # If it's e.g. a MagicMock(authorization="xyz"), convert it to a dict
114
+ req_auth = getattr(requirements, "authorization", None)
115
+ # If the test expects an authorization presence, represent it as a dict
116
+ # that Pydantic can parse
117
+ if req_auth is not None:
118
+ requirements = {"authorization": {"type": req_auth}}
119
+ else:
120
+ requirements = {"authorization": None}
121
+
122
+ # Provide a default description if none is supplied
123
+ description = kwargs.pop("description", "Mock tool for testing")
124
+
125
+ # Build the pydantic fields
126
+ data = {
127
+ "fully_qualified_name": fully_qualified_name,
128
+ "qualified_name": fully_qualified_name,
129
+ "name": raw_tool_name,
130
+ "toolkit": toolkit,
131
+ "input": tool_input,
132
+ "description": description,
133
+ "requirements": requirements,
134
+ }
135
+ data.update(kwargs) # merge any extras
136
+
137
+ return ToolDefinition(**data)
138
+
139
+ return _make_tool
140
+
141
+
142
+ async def maybe_await(obj, is_async):
143
+ """Helper to handle both sync and async return values"""
144
+ if is_async:
145
+ return await obj
146
+ return obj
147
+
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_init_tools_parameterized(
151
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
152
+ ):
153
+ """
154
+ Test that init_tools clears any existing tools and retrieves new ones
155
+ from either an explicit list of tools or an entire toolkit.
156
+ """
157
+ # Arrange
158
+ manager, is_async = manager_fixture
159
+ client = async_mock_arcade_client if is_async else mock_arcade_client
160
+
161
+ mock_tool = make_tool("GoogleSearch_Search")
162
+ client.tools.get.return_value = mock_tool
163
+
164
+ page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
165
+ client.tools.list.return_value = page_cls(items=[mock_tool])
166
+
167
+ # Act
168
+ result = await maybe_await(
169
+ manager.init_tools(tools=["GoogleSearch_Search"]), is_async
170
+ )
171
+
172
+ # Assert
173
+ assert "GoogleSearch_Search" in manager.tools
174
+ assert manager._tools["GoogleSearch_Search"] == mock_tool
175
+ client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
176
+ # Verify the result is a list of StructuredTool objects
177
+ assert len(result) == 1
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_to_langchain_parameterized(
182
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
183
+ ):
184
+ """
185
+ Test that to_langchain returns the tools as StructuredTool objects.
186
+ """
187
+ # Arrange
188
+ manager, is_async = manager_fixture
189
+
190
+ mock_tool = make_tool("GoogleSearch_Search")
191
+ manager._tools = {"GoogleSearch_Search": mock_tool}
192
+
193
+ # Act - with default parameters
194
+ result = await maybe_await(manager.to_langchain(), is_async)
195
+
196
+ # Assert
197
+ assert len(result) == 1
198
+ assert result[0].name == "GoogleSearch_Search"
199
+
200
+ # Act - with underscores=False
201
+ result = await maybe_await(manager.to_langchain(use_underscores=False), is_async)
202
+
203
+ # Assert
204
+ assert len(result) == 1
205
+ assert result[0].name == "GoogleSearch.Search"
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_deprecated_get_tools_parameterized(
210
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
211
+ ):
212
+ """
213
+ Test that the deprecated get_tools method still works but issues a warning.
214
+ """
215
+ # Arrange
216
+ manager, is_async = manager_fixture
217
+ client = async_mock_arcade_client if is_async else mock_arcade_client
218
+
219
+ mock_tool = make_tool("GoogleSearch_Search")
220
+ client.tools.get.return_value = mock_tool
221
+ manager._tools = {} # Ensure no tools are already loaded
222
+
223
+ # Act - Check for deprecation warning
224
+ with pytest.warns(DeprecationWarning):
225
+ result = await maybe_await(
226
+ manager.get_tools(tools=["GoogleSearch_Search"]), is_async
227
+ )
228
+
229
+ # Assert - Method should still work
230
+ assert len(result) == 1
231
+ assert "GoogleSearch_Search" in manager.tools
232
+ client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
233
+
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_add_tool_parameterized(
237
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
238
+ ):
239
+ """
240
+ Test that add_tool adds a single tool to the manager without clearing existing tools.
241
+ """
242
+ # Arrange
243
+ manager, is_async = manager_fixture
244
+ client = async_mock_arcade_client if is_async else mock_arcade_client
245
+
246
+ # Set up two different mock tools
247
+ mock_tool_google = make_tool("GoogleSearch_Search")
248
+ mock_tool_bing = make_tool("BingSearch_Search")
249
+
250
+ # First tool already exists in manager
251
+ manager._tools = {"GoogleSearch_Search": mock_tool_google}
252
+
253
+ # Second tool will be added
254
+ client.tools.get.return_value = mock_tool_bing
255
+
256
+ # Act
257
+ await maybe_await(manager.add_tool("BingSearch_Search"), is_async)
258
+
259
+ # Assert - Both tools should now be in the manager
260
+ assert "GoogleSearch_Search" in manager.tools
261
+ assert "BingSearch_Search" in manager.tools
262
+ assert len(manager.tools) == 2
263
+ client.tools.get.assert_called_once_with(name="BingSearch_Search")
264
+
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_add_toolkit_parameterized(
268
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
269
+ ):
270
+ """
271
+ Test that add_toolkit adds all tools from a toolkit without clearing existing tools.
272
+ """
273
+ # Arrange
274
+ manager, is_async = manager_fixture
275
+ client = async_mock_arcade_client if is_async else mock_arcade_client
276
+
277
+ # Create a tool that's already in the manager
278
+ mock_tool_send_email = make_tool("Gmail_SendEmail")
279
+ manager._tools = {"Gmail_SendEmail": mock_tool_send_email}
280
+
281
+ # Create tools to be added from the toolkit
282
+ mock_tool_list_emails = make_tool("Gmail_ListEmails")
283
+ mock_tool_trash_email = make_tool("Gmail_TrashEmail")
284
+
285
+ # Mock the response for toolkit listing
286
+ page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
287
+ client.tools.list.return_value = page_cls(
288
+ items=[mock_tool_list_emails, mock_tool_trash_email]
289
+ )
290
+
291
+ # Act
292
+ await maybe_await(manager.add_toolkit("Search"), is_async)
293
+
294
+ # Assert - All tools should now be in the manager
295
+ assert len(manager.tools) == 3
296
+ assert "Gmail_SendEmail" in manager.tools
297
+ assert "Gmail_ListEmails" in manager.tools
298
+ assert "Gmail_TrashEmail" in manager.tools
299
+ client.tools.list.assert_called_once_with(
300
+ toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN
301
+ )
302
+
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_is_authorized_with_response_object_parameterized(
306
+ manager_fixture, mock_arcade_client, async_mock_arcade_client
307
+ ):
308
+ """
309
+ Test the is_authorized method accepting both authorization ID string and AuthorizationResponse.
310
+ """
311
+ # Arrange
312
+ manager, is_async = manager_fixture
313
+ client = async_mock_arcade_client if is_async else mock_arcade_client
314
+
315
+ mock_type = AsyncMock if is_async else MagicMock
316
+ client.auth.status.return_value = mock_type(status="completed")
317
+
318
+ # Create an auth response object
319
+ auth_response = AuthorizationResponse(
320
+ id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
321
+ )
322
+
323
+ # Act - Test with string ID
324
+ status_result1 = await maybe_await(manager.is_authorized("auth_abc"), is_async)
325
+
326
+ # Act - Test with response object
327
+ status_result2 = await maybe_await(manager.is_authorized(auth_response), is_async)
328
+
329
+ # Assert
330
+ assert status_result1 is True
331
+ assert status_result2 is True
332
+ client.auth.status.assert_any_call(id="auth_abc")
333
+ client.auth.status.assert_any_call(
334
+ id="auth_abc"
335
+ ) # Should be called with the same ID both times
336
+
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_wait_for_auth_with_response_object_parameterized(
340
+ manager_fixture, mock_arcade_client, async_mock_arcade_client
341
+ ):
342
+ """
343
+ Test the wait_for_auth method accepting both authorization ID string and AuthorizationResponse.
344
+ """
345
+ # Arrange
346
+ manager, is_async = manager_fixture
347
+ client = async_mock_arcade_client if is_async else mock_arcade_client
348
+
349
+ completed_response = AuthorizationResponse(
350
+ id="auth_abc",
351
+ status="completed",
352
+ tool_fully_qualified_name="GoogleSearch_Search",
353
+ )
354
+ client.auth.wait_for_completion.return_value = completed_response
355
+
356
+ # Create an auth response object
357
+ auth_response = AuthorizationResponse(
358
+ id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
359
+ )
360
+
361
+ # Act - Test with string ID
362
+ result1 = await maybe_await(manager.wait_for_auth("auth_abc"), is_async)
363
+
364
+ # Act - Test with response object
365
+ result2 = await maybe_await(manager.wait_for_auth(auth_response), is_async)
366
+
367
+ # Assert
368
+ assert result1 == completed_response
369
+ assert result2 == completed_response
370
+ client.auth.wait_for_completion.assert_any_call("auth_abc")
371
+ client.auth.wait_for_completion.assert_any_call(
372
+ "auth_abc"
373
+ ) # Should be called with the same ID both times
374
+
375
+
376
+ @pytest.mark.asyncio
377
+ async def test_get_tools_no_init_parameterized(
378
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
379
+ ):
380
+ """
381
+ Test that the deprecated get_tools method without previous initialization
382
+ issues a warning and fetches tools.
383
+ """
384
+ # Arrange
385
+ manager, is_async = manager_fixture
386
+ client = async_mock_arcade_client if is_async else mock_arcade_client
387
+
388
+ mock_tool = make_tool("GoogleSearch_Search")
389
+ page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
390
+ client.tools.list.return_value = page_cls(items=[mock_tool])
391
+
392
+ # Act - Check for deprecation warning
393
+ with pytest.warns(DeprecationWarning):
394
+ tools = await maybe_await(
395
+ manager.get_tools(), is_async
396
+ ) # No param means manager calls list
397
+
398
+ # Assert
399
+ assert len(tools) == 0
400
+ assert "GoogleSearch_Search" not in manager.tools
401
+
402
+
403
+ @pytest.mark.asyncio
404
+ async def test_get_tools_with_explicit_parameterized(
405
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
406
+ ):
407
+ """
408
+ Test that the deprecated get_tools method with explicitly specified tools
409
+ issues a warning and fetches the requested tools.
410
+ """
411
+ # Arrange
412
+ manager, is_async = manager_fixture
413
+ client = async_mock_arcade_client if is_async else mock_arcade_client
414
+
415
+ mock_tool_google = make_tool("GoogleSearch_Search")
416
+ mock_tool_bing = make_tool("BingSearch_Search")
417
+ client.tools.get.side_effect = [mock_tool_google, mock_tool_bing]
418
+
419
+ # Act - Check for deprecation warning
420
+ with pytest.warns(DeprecationWarning):
421
+ retrieved_tools = await maybe_await(
422
+ manager.get_tools(tools=["GoogleSearch_Search", "BingSearch_Search"]),
423
+ is_async,
424
+ )
425
+
426
+ # Assert
427
+ assert len(retrieved_tools) == 2
428
+ assert set(manager.tools) == {"GoogleSearch_Search", "BingSearch_Search"}
429
+ client.tools.get.assert_any_call(name="GoogleSearch_Search")
430
+ client.tools.get.assert_any_call(name="BingSearch_Search")
431
+
432
+
433
+ def test_arcade_tool_manager_deprecation_warning():
434
+ """
435
+ Test that the ArcadeToolManager class issues a deprecation warning.
436
+ """
437
+ # Act - Check for deprecation warning
438
+ with pytest.warns(DeprecationWarning) as warnings_record:
439
+ ArcadeToolManager(client=MagicMock())
440
+ # Assert
441
+ assert any(
442
+ "ArcadeToolManager is deprecated" in str(w.message) for w in warnings_record
443
+ )
444
+
445
+
446
+ @pytest.mark.asyncio
447
+ async def test_authorize_parameterized(
448
+ manager_fixture, mock_arcade_client, async_mock_arcade_client
449
+ ):
450
+ """
451
+ Test the authorize method to ensure it calls the Arcade client's
452
+ tools.authorize method correctly.
453
+ """
454
+ # Arrange
455
+ manager, is_async = manager_fixture
456
+ client = async_mock_arcade_client if is_async else mock_arcade_client
457
+
458
+ auth_response = AuthorizationResponse(
459
+ id="auth_123", status="pending", tool_fully_qualified_name="GoogleSearch_Search"
460
+ )
461
+ client.tools.authorize.return_value = auth_response
462
+
463
+ # Act
464
+ response = await maybe_await(
465
+ manager.authorize(tool_name="GoogleSearch_Search", user_id="user_123"), is_async
466
+ )
467
+
468
+ # Assert
469
+ assert response.id == "auth_123"
470
+ assert response.status == "pending"
471
+ client.tools.authorize.assert_called_once_with(
472
+ tool_name="GoogleSearch_Search", user_id="user_123"
473
+ )
474
+
475
+
476
+ def test_requires_auth_true(manager, make_tool):
477
+ """
478
+ Test the requires_auth method returning True if
479
+ the stored tool definition's requirements contain an authorization entry.
480
+ """
481
+ # Arrange
482
+ tool_name = "GoogleSearch_Search"
483
+ # Pass a MagicMock with 'authorization' to ensure it gets converted
484
+ mock_tool_def = make_tool(
485
+ tool_name, requirements=MagicMock(authorization="some_required_auth")
486
+ )
487
+ manager._tools[tool_name] = mock_tool_def
488
+
489
+ # Act
490
+ result = manager.requires_auth(tool_name)
491
+
492
+ # Assert
493
+ assert result is True
494
+
495
+
496
+ def test_requires_auth_false(manager, make_tool):
497
+ """
498
+ Test the requires_auth method returning False if authorization
499
+ is not required in the tool definition.
500
+ """
501
+ # Arrange
502
+ tool_name = "GoogleSearch_Search"
503
+ mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization=None))
504
+ manager._tools[tool_name] = mock_tool_def
505
+
506
+ # Act
507
+ result = manager.requires_auth(tool_name)
508
+
509
+ # Assert
510
+ assert result is False
511
+
512
+
513
+ def test_get_tool_definition_existing(manager, make_tool):
514
+ """
515
+ Test the internal _get_tool_definition method retrieving
516
+ an existing tool definition by name.
517
+ """
518
+ # Arrange
519
+ tool_name = "GoogleSearch_Search"
520
+ mock_tool_def = make_tool(tool_name)
521
+ manager._tools[tool_name] = mock_tool_def
522
+
523
+ # Act
524
+ definition = manager._get_tool_definition(tool_name)
525
+
526
+ # Assert
527
+ assert definition == mock_tool_def
528
+
529
+
530
+ def test_get_tool_definition_missing(manager):
531
+ """
532
+ Test the internal _get_tool_definition method raising a ValueError
533
+ if the tool is not in the manager.
534
+ """
535
+ # Act & Assert
536
+ with pytest.raises(ValueError) as excinfo:
537
+ manager._get_tool_definition("Nonexistent.Tool")
538
+
539
+ assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value)
540
+
541
+
542
+ def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_tool):
543
+ """
544
+ Test the internal _retrieve_tool_definitions method by specifying tools only.
545
+ """
546
+ # Arrange
547
+ mock_tool = make_tool("GoogleSearch_Search")
548
+ mock_arcade_client.tools.get.return_value = mock_tool
549
+
550
+ # Act
551
+ results = manager._retrieve_tool_definitions(
552
+ tools=["GoogleSearch_Search"], toolkits=None
553
+ )
554
+
555
+ # Assert
556
+ assert len(results) == 1
557
+ assert results[0].fully_qualified_name == "GoogleSearch_Search"
558
+ mock_arcade_client.tools.get.assert_called_once_with(name="GoogleSearch_Search")
559
+
560
+
561
+ def test_retrieve_tool_definitions_toolkits_only(
562
+ manager, mock_arcade_client, make_tool
563
+ ):
564
+ """
565
+ Test the internal _retrieve_tool_definitions method by specifying toolkits.
566
+ """
567
+ # Arrange
568
+ mock_tool = make_tool("Search_SearchBing")
569
+ mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool])
570
+
571
+ # Act
572
+ results = manager._retrieve_tool_definitions(tools=None, toolkits=["Search"])
573
+
574
+ # Assert
575
+ assert len(results) == 1
576
+ assert results[0].fully_qualified_name == "Search_SearchBing"
577
+ mock_arcade_client.tools.list.assert_called_once_with(
578
+ toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN
579
+ )
580
+
581
+
582
+ def test_retrieve_tool_definitions_raise_on_empty(manager):
583
+ """
584
+ Test that _retrieve_tool_definitions raises ValueError when no tools or toolkits
585
+ are provided and raise_on_empty is True.
586
+ """
587
+ # Act & Assert
588
+ with pytest.raises(ValueError) as excinfo:
589
+ manager._retrieve_tool_definitions(
590
+ tools=None, toolkits=None, raise_on_empty=True
591
+ )
592
+
593
+ assert "No tools or toolkits provided" in str(excinfo.value)
594
+
595
+
596
+ def test_retrieve_tool_definitions_empty_no_raise(manager):
597
+ """
598
+ Test that _retrieve_tool_definitions returns empty list when no tools or toolkits
599
+ are provided and raise_on_empty is False.
600
+ """
601
+ # Act
602
+ results = manager._retrieve_tool_definitions(
603
+ tools=None, toolkits=None, raise_on_empty=False
604
+ )
605
+
606
+ # Assert
607
+ assert results == []
608
+
609
+
610
+ @pytest.mark.asyncio
611
+ async def test_retrieve_tool_definitions_with_limit_offset_parameterized(
612
+ manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool
613
+ ):
614
+ """
615
+ Test that _retrieve_tool_definitions respects limit and offset parameters.
616
+ """
617
+ # Arrange
618
+ manager, is_async = manager_fixture
619
+ client = async_mock_arcade_client if is_async else mock_arcade_client
620
+
621
+ mock_tool = make_tool("Search_SearchGoogle")
622
+ page_cls = AsyncOffsetPage if is_async else SyncOffsetPage
623
+ client.tools.list.return_value = page_cls(items=[mock_tool])
624
+
625
+ # Act
626
+ if is_async:
627
+ results = await manager._retrieve_tool_definitions(
628
+ toolkits=["Search"], limit=10, offset=5
629
+ )
630
+ else:
631
+ results = manager._retrieve_tool_definitions(
632
+ toolkits=["Search"], limit=10, offset=5
633
+ )
634
+
635
+ # Assert
636
+ assert len(results) > 0
637
+ client.tools.list.assert_called_once_with(toolkit="Search", limit=10, offset=5)
638
+
639
+
640
+ def test_get_client_config_with_kwargs():
641
+ """
642
+ Test that _get_client_config prioritizes kwargs over environment variables.
643
+ """
644
+ # Arrange
645
+ manager = ToolManager(client=MagicMock()) # Client won't be used here
646
+
647
+ # Act
648
+ with patch.dict(
649
+ "os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
650
+ ):
651
+ result = manager._get_client_config(api_key="kwarg_key", base_url="kwarg_url")
652
+
653
+ # Assert
654
+ assert result["api_key"] == "kwarg_key"
655
+ assert result["base_url"] == "kwarg_url"
656
+
657
+
658
+ def test_get_client_config_with_env_vars():
659
+ """
660
+ Test that _get_client_config falls back to environment variables when kwargs not provided.
661
+ """
662
+ # Arrange
663
+ manager = ToolManager(client=MagicMock()) # Client won't be used here
664
+
665
+ # Act
666
+ with patch.dict(
667
+ "os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"}
668
+ ):
669
+ result = manager._get_client_config()
670
+
671
+ # Assert
672
+ assert result["api_key"] == "env_key"
673
+ assert result["base_url"] == "env_url"
674
+
675
+
676
+ def test_getitem_access(manager, make_tool):
677
+ """
678
+ Test that __getitem__ allows dictionary-style access to tools.
679
+ """
680
+ # Arrange
681
+ tool_name = "Search_SearchGoogle"
682
+ mock_tool_def = make_tool(tool_name)
683
+ manager._tools[tool_name] = mock_tool_def
684
+
685
+ # Act
686
+ definition = manager[tool_name]
687
+
688
+ # Assert
689
+ assert definition == mock_tool_def
690
+
691
+
692
+ def test_getitem_missing(manager):
693
+ """
694
+ Test that __getitem__ raises ValueError for missing tools.
695
+ """
696
+ # Act & Assert
697
+ with pytest.raises(ValueError) as excinfo:
698
+ _ = manager["Nonexistent.Tool"]
699
+
700
+ assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value)
701
+
702
+
703
+ def test_create_tool_map_with_underscores(make_tool):
704
+ """
705
+ Test the _create_tool_map function with use_underscores=True.
706
+ """
707
+ # Arrange
708
+ from langchain_arcade.manager import _create_tool_map
709
+
710
+ tool1 = make_tool("GoogleSearch.Search")
711
+ tool2 = make_tool("Gmail.SendEmail")
712
+
713
+ # Act
714
+ result = _create_tool_map([tool1, tool2], use_underscores=True)
715
+
716
+ # Assert
717
+ assert "GoogleSearch_Search" in result
718
+ assert "Gmail_SendEmail" in result
719
+ assert len(result) == 2
720
+
721
+
722
+ def test_create_tool_map_with_dots(make_tool):
723
+ """
724
+ Test the _create_tool_map function with use_underscores=False.
725
+ """
726
+ # Arrange
727
+ from langchain_arcade.manager import _create_tool_map
728
+
729
+ tool1 = make_tool("GoogleSearch.Search")
730
+ tool2 = make_tool("Gmail.SendEmail")
731
+
732
+ # Act
733
+ result = _create_tool_map([tool1, tool2], use_underscores=False)
734
+
735
+ # Assert
736
+ assert "GoogleSearch.Search" in result
737
+ assert "Gmail.SendEmail" in result
738
+ assert len(result) == 2
@@ -0,0 +1,16 @@
1
+ [tox]
2
+ skipsdist = true
3
+ envlist = py310, py311, py312
4
+
5
+ [gh-actions]
6
+ python =
7
+ 3.10: py310
8
+ 3.11: py311
9
+ 3.12: py312
10
+
11
+ [testenv]
12
+ passenv = PYTHON_VERSION
13
+ allowlist_externals = uv
14
+ commands =
15
+ uv sync --active --all-extras
16
+ uv pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml