langchain-arcade 1.3.1__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.
- langchain_arcade-1.4.4/.gitignore +175 -0
- langchain_arcade-1.4.4/Makefile +47 -0
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/PKG-INFO +16 -17
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/langchain_arcade/_utilities.py +29 -10
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/langchain_arcade/manager.py +23 -7
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/pyproject.toml +30 -18
- langchain_arcade-1.4.4/tests/conftest.py +33 -0
- langchain_arcade-1.4.4/tests/test_manager.py +738 -0
- langchain_arcade-1.4.4/tox.ini +16 -0
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/LICENSE +0 -0
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/README.md +0 -0
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/langchain_arcade/__init__.py +0 -0
- {langchain_arcade-1.3.1 → langchain_arcade-1.4.4}/langchain_arcade/py.typed +0 -0
|
@@ -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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: langchain-arcade
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: An integration package connecting Arcade and Langchain/LangGraph
|
|
5
|
-
|
|
6
|
-
License:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Requires-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Requires-Dist:
|
|
17
|
-
Requires-Dist:
|
|
18
|
-
|
|
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">
|
|
@@ -193,4 +193,3 @@ For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade
|
|
|
193
193
|
## More Examples
|
|
194
194
|
|
|
195
195
|
For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/langchain).
|
|
196
|
-
|
|
@@ -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(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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,26 +1,32 @@
|
|
|
1
|
-
[
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [ "hatchling",]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
2
6
|
name = "langchain-arcade"
|
|
3
|
-
version = "1.
|
|
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
|
-
[
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[
|
|
17
|
-
|
|
18
|
-
pytest
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
]
|
|
24
30
|
|
|
25
31
|
|
|
26
32
|
[tool.mypy]
|
|
@@ -45,3 +51,9 @@ source = ["langchain_arcade"]
|
|
|
45
51
|
|
|
46
52
|
[tool.coverage.report]
|
|
47
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|