Dropbox Toolkit (#332)
Basic tools to list items in a folder, search for files / folders, and download a file content.
This commit is contained in:
parent
13a01254df
commit
c1e8fc795a
19 changed files with 1465 additions and 0 deletions
18
toolkits/dropbox/.pre-commit-config.yaml
Normal file
18
toolkits/dropbox/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
files: ^./
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: "v4.4.0"
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
46
toolkits/dropbox/.ruff.toml
Normal file
46
toolkits/dropbox/.ruff.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
target-version = "py39"
|
||||
line-length = 100
|
||||
fix = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-builtins
|
||||
"A",
|
||||
# flake8-comprehensions
|
||||
"C4",
|
||||
# flake8-debugger
|
||||
"T10",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# isort
|
||||
"I",
|
||||
# mccabe
|
||||
"C90",
|
||||
# pycodestyle
|
||||
"E", "W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pygrep-hooks
|
||||
"PGH",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# ruff
|
||||
"RUF",
|
||||
# tryceratops
|
||||
"TRY",
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"*" = ["TRY003", "B904"]
|
||||
"**/tests/*" = ["S101", "E501"]
|
||||
"**/evals/*" = ["S101", "E501"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
skip-magic-trailing-comma = false
|
||||
21
toolkits/dropbox/LICENSE
Normal file
21
toolkits/dropbox/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Arcade
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
toolkits/dropbox/Makefile
Normal file
53
toolkits/dropbox/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ dropbox Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the poetry environment and install the pre-commit hooks
|
||||
@echo "📦 Checking if Poetry is installed"
|
||||
@if ! command -v poetry &> /dev/null; then \
|
||||
echo "📦 Installing Poetry with pip"; \
|
||||
pip install poetry==1.8.5; \
|
||||
else \
|
||||
echo "📦 Poetry is already installed"; \
|
||||
fi
|
||||
@echo "🚀 Installing package in development mode with all extras"
|
||||
poetry install --all-extras
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
poetry build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
poetry version patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
|
||||
@poetry check
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@poetry run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@poetry run mypy --config-file=pyproject.toml
|
||||
0
toolkits/dropbox/arcade_dropbox/__init__.py
Normal file
0
toolkits/dropbox/arcade_dropbox/__init__.py
Normal file
34
toolkits/dropbox/arcade_dropbox/constants.py
Normal file
34
toolkits/dropbox/arcade_dropbox/constants.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class EndpointType(Enum):
|
||||
API = "api"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class Endpoint(Enum):
|
||||
LIST_FOLDER = "/files/list_folder"
|
||||
SEARCH_FILES = "/files/search"
|
||||
DOWNLOAD_FILE = "/files/download"
|
||||
|
||||
|
||||
class ItemCategory(Enum):
|
||||
IMAGE = "image"
|
||||
DOCUMENT = "document"
|
||||
PDF = "pdf"
|
||||
SPREADSHEET = "spreadsheet"
|
||||
PRESENTATION = "presentation"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
FOLDER = "folder"
|
||||
PAPER = "paper"
|
||||
|
||||
|
||||
API_BASE_URL = "https://{endpoint_type}.dropboxapi.com"
|
||||
API_VERSION = "2"
|
||||
ENDPOINT_URL_MAP = {
|
||||
Endpoint.LIST_FOLDER: (EndpointType.API, "files/list_folder"),
|
||||
Endpoint.SEARCH_FILES: (EndpointType.API, "files/search_v2"),
|
||||
Endpoint.DOWNLOAD_FILE: (EndpointType.CONTENT, "files/download"),
|
||||
}
|
||||
MAX_RESPONSE_BODY_SIZE = 10 * 1024 * 1024 # 10 MiB
|
||||
34
toolkits/dropbox/arcade_dropbox/critics.py
Normal file
34
toolkits/dropbox/arcade_dropbox/critics.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from typing import Any
|
||||
|
||||
from arcade.sdk.eval import BinaryCritic
|
||||
|
||||
|
||||
class DropboxPathCritic(BinaryCritic):
|
||||
def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
|
||||
"""
|
||||
Ignores leading slash in the actual value when comparing to the expected value.
|
||||
|
||||
Note: sometimes the LLM won't start the path with a slash, so this critic ignores it when
|
||||
comparing. Dropbox tools will add the slash, when needed, so no worries about API errors.
|
||||
|
||||
Args:
|
||||
expected: The expected value.
|
||||
actual: The actual value to compare, cast to the type of expected.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the match status and score.
|
||||
"""
|
||||
try:
|
||||
actual_casted = self.cast_actual(expected, actual)
|
||||
# TODO log or something better here
|
||||
except TypeError:
|
||||
actual_casted = actual
|
||||
|
||||
if isinstance(expected, str):
|
||||
expected = expected.lstrip("/")
|
||||
|
||||
if isinstance(actual_casted, str):
|
||||
actual_casted = actual_casted.lstrip("/")
|
||||
|
||||
match = expected == actual_casted
|
||||
return {"match": match, "score": self.weight if match else 0.0}
|
||||
18
toolkits/dropbox/arcade_dropbox/exceptions.py
Normal file
18
toolkits/dropbox/arcade_dropbox/exceptions.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from typing import Optional
|
||||
|
||||
|
||||
class DropboxApiError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
error_summary: str,
|
||||
user_message: Optional[str],
|
||||
):
|
||||
if "path/not_found" in error_summary:
|
||||
self.message = "The specified path was not found by Dropbox"
|
||||
elif "unsupported_file" in error_summary:
|
||||
self.message = "The specified file is not supported for the requested operation"
|
||||
else:
|
||||
self.message = user_message or error_summary
|
||||
|
||||
self.status_code = status_code
|
||||
138
toolkits/dropbox/arcade_dropbox/tools/browse.py
Normal file
138
toolkits/dropbox/arcade_dropbox/tools/browse.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from typing import Annotated, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Dropbox
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_dropbox.constants import Endpoint, ItemCategory
|
||||
from arcade_dropbox.exceptions import DropboxApiError
|
||||
from arcade_dropbox.utils import (
|
||||
build_dropbox_json,
|
||||
clean_dropbox_entries,
|
||||
parse_dropbox_path,
|
||||
send_dropbox_request,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
requires_auth=Dropbox(
|
||||
scopes=["files.metadata.read"],
|
||||
)
|
||||
)
|
||||
async def list_items_in_folder(
|
||||
context: ToolContext,
|
||||
folder_path: Annotated[
|
||||
str,
|
||||
"The path to the folder to list the contents of. E.g. '/AcmeInc/Reports'. "
|
||||
"Defaults to an empty string (list items in the Dropbox root folder).",
|
||||
] = "",
|
||||
limit: Annotated[
|
||||
int,
|
||||
"The maximum number of items to return. Defaults to 100. Maximum allowed is 2000.",
|
||||
] = 100,
|
||||
cursor: Annotated[
|
||||
Optional[str],
|
||||
"The cursor token for the next page of results. "
|
||||
"Defaults to None (returns the first page of results).",
|
||||
] = None,
|
||||
) -> Annotated[
|
||||
dict, "Dictionary containing the list of files and folders in the specified folder path"
|
||||
]:
|
||||
"""Provides a dictionary containing the list of items in the specified folder path.
|
||||
|
||||
Note 1: when paginating, it is not necessary to provide any other argument besides the cursor.
|
||||
Note 2: when paginating, any given item (file or folder) may be returned in multiple pages.
|
||||
"""
|
||||
limit = min(limit, 2000)
|
||||
|
||||
try:
|
||||
result = await send_dropbox_request(
|
||||
context.get_auth_token_or_empty(),
|
||||
endpoint=Endpoint.LIST_FOLDER,
|
||||
path=parse_dropbox_path(folder_path),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
except DropboxApiError as api_error:
|
||||
return {"error": api_error.message}
|
||||
|
||||
return {
|
||||
"items": clean_dropbox_entries(result["entries"]),
|
||||
"cursor": result.get("cursor"),
|
||||
"has_more": result.get("has_more", False),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
requires_auth=Dropbox(
|
||||
scopes=["files.metadata.read"],
|
||||
)
|
||||
)
|
||||
async def search_files_and_folders(
|
||||
context: ToolContext,
|
||||
keywords: Annotated[
|
||||
str,
|
||||
"The keywords to search for. E.g. 'quarterly report'. "
|
||||
"Maximum length allowed by the Dropbox API is 1000 characters. ",
|
||||
],
|
||||
search_in_folder_path: Annotated[
|
||||
Optional[str],
|
||||
"Restricts the search to the specified folder path. E.g. '/AcmeInc/Reports'. "
|
||||
"Defaults to None (search in the entire Dropbox).",
|
||||
] = None,
|
||||
filter_by_category: Annotated[
|
||||
Optional[list[ItemCategory]],
|
||||
"Restricts the search to the specified category(ies) of items. "
|
||||
"Provide None, one or multiple, if needed. Defaults to None (returns all categories).",
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
"The maximum number of items to return. Defaults to 100. Maximum allowed is 1000.",
|
||||
] = 100,
|
||||
cursor: Annotated[
|
||||
Optional[str],
|
||||
"The cursor token for the next page of results. Defaults to None (first page of results).",
|
||||
] = None,
|
||||
) -> Annotated[dict, "List of items in the specified folder path matching the search criteria"]:
|
||||
"""Returns a list of items in the specified folder path matching the search criteria.
|
||||
|
||||
Note 1: the Dropbox API will return up to 10,000 (ten thousand) items cumulatively across
|
||||
multiple pagination requests using the cursor token.
|
||||
Note 2: when paginating, it is not necessary to provide any other argument besides the cursor.
|
||||
Note 3: when paginating, any given item (file or folder) may be returned in multiple pages.
|
||||
"""
|
||||
if len(keywords) > 1000:
|
||||
raise ToolExecutionError(
|
||||
"The keywords argument must be a string with up to 1000 characters."
|
||||
)
|
||||
|
||||
limit = min(limit, 1000)
|
||||
|
||||
filter_by_category = filter_by_category or []
|
||||
|
||||
options = build_dropbox_json(
|
||||
file_status="active",
|
||||
filename_only=False,
|
||||
path=parse_dropbox_path(search_in_folder_path),
|
||||
max_results=limit,
|
||||
file_categories=[category.value for category in filter_by_category],
|
||||
)
|
||||
|
||||
try:
|
||||
result = await send_dropbox_request(
|
||||
context.get_auth_token_or_empty(),
|
||||
endpoint=Endpoint.SEARCH_FILES,
|
||||
query=keywords,
|
||||
options=options,
|
||||
cursor=cursor,
|
||||
)
|
||||
except DropboxApiError as api_error:
|
||||
return {"error": api_error.message}
|
||||
|
||||
return {
|
||||
"items": clean_dropbox_entries([
|
||||
match["metadata"]["metadata"] for match in result["matches"]
|
||||
]),
|
||||
"cursor": result.get("cursor"),
|
||||
"has_more": result.get("has_more", False),
|
||||
}
|
||||
47
toolkits/dropbox/arcade_dropbox/tools/files.py
Normal file
47
toolkits/dropbox/arcade_dropbox/tools/files.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from typing import Annotated, Optional
|
||||
|
||||
from arcade.sdk import ToolContext, tool
|
||||
from arcade.sdk.auth import Dropbox
|
||||
from arcade.sdk.errors import ToolExecutionError
|
||||
|
||||
from arcade_dropbox.constants import Endpoint
|
||||
from arcade_dropbox.exceptions import DropboxApiError
|
||||
from arcade_dropbox.utils import parse_dropbox_path, send_dropbox_request
|
||||
|
||||
|
||||
@tool(
|
||||
requires_auth=Dropbox(
|
||||
scopes=["files.content.read"],
|
||||
)
|
||||
)
|
||||
async def download_file(
|
||||
context: ToolContext,
|
||||
file_path: Annotated[
|
||||
Optional[str],
|
||||
"The path of the file to download. E.g. '/AcmeInc/Reports/Q1_2025.txt'. Defaults to None.",
|
||||
] = None,
|
||||
file_id: Annotated[
|
||||
Optional[str],
|
||||
"The ID of the file to download. E.g. 'id:a4ayc_80_OEAAAAAAAAAYa'. Defaults to None.",
|
||||
] = None,
|
||||
) -> Annotated[dict, "Contents of the specified file"]:
|
||||
"""Downloads the specified file.
|
||||
|
||||
Note: either one of `file_path` or `file_id` must be provided.
|
||||
"""
|
||||
if not file_path and not file_id:
|
||||
raise ToolExecutionError("Either `file_path` or `file_id` must be provided.")
|
||||
|
||||
if file_path and file_id:
|
||||
raise ToolExecutionError("Only one of `file_path` or `file_id` can be provided.")
|
||||
|
||||
try:
|
||||
result = await send_dropbox_request(
|
||||
context.get_auth_token_or_empty(),
|
||||
endpoint=Endpoint.DOWNLOAD_FILE,
|
||||
path=parse_dropbox_path(file_path) or file_id,
|
||||
)
|
||||
except DropboxApiError as api_error:
|
||||
return {"error": api_error.message}
|
||||
|
||||
return {"file": result}
|
||||
106
toolkits/dropbox/arcade_dropbox/utils.py
Normal file
106
toolkits/dropbox/arcade_dropbox/utils.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from arcade_dropbox.constants import (
|
||||
API_BASE_URL,
|
||||
API_VERSION,
|
||||
ENDPOINT_URL_MAP,
|
||||
Endpoint,
|
||||
EndpointType,
|
||||
)
|
||||
from arcade_dropbox.exceptions import DropboxApiError
|
||||
|
||||
|
||||
def build_dropbox_url(endpoint_type: EndpointType, endpoint_path: str) -> str:
|
||||
base_url = API_BASE_URL.format(endpoint_type=endpoint_type.value)
|
||||
return f"{base_url}/{API_VERSION}/{endpoint_path.strip('/')}"
|
||||
|
||||
|
||||
def build_dropbox_headers(token: Optional[str]) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
|
||||
def build_dropbox_json(**kwargs: Any) -> dict:
|
||||
return {key: value for key, value in kwargs.items() if value is not None}
|
||||
|
||||
|
||||
async def send_dropbox_request(
|
||||
authorization_token: Optional[str],
|
||||
endpoint: Endpoint,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
endpoint_type, endpoint_path = ENDPOINT_URL_MAP[endpoint]
|
||||
url = build_dropbox_url(endpoint_type, endpoint_path)
|
||||
headers = build_dropbox_headers(authorization_token)
|
||||
json_data = build_dropbox_json(**kwargs)
|
||||
|
||||
if json_data.get("cursor"):
|
||||
url += "/continue"
|
||||
# If cursor is provided, every other argument must be ignored to avoid API error
|
||||
json_data = {"cursor": json_data["cursor"]}
|
||||
|
||||
if endpoint_type == EndpointType.CONTENT:
|
||||
headers["Dropbox-API-Arg"] = json.dumps(json_data)
|
||||
json_data = {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
request_args: dict[str, Any] = {"url": url, "headers": headers}
|
||||
|
||||
if json_data:
|
||||
request_args["json"] = json_data
|
||||
|
||||
response = await client.post(**request_args)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
if response.status_code != 200:
|
||||
raise DropboxApiError(
|
||||
status_code=response.status_code,
|
||||
error_summary=data.get("error_summary", response.text),
|
||||
user_message=data.get("user_message"),
|
||||
)
|
||||
|
||||
if endpoint_type == EndpointType.CONTENT:
|
||||
data = json.loads(response.headers["Dropbox-API-Result"])
|
||||
data = clean_dropbox_entry(data, default_type="file")
|
||||
data["content"] = response.text
|
||||
return data
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def clean_dropbox_entry(entry: dict, default_type: Optional[str] = None) -> dict:
|
||||
return {
|
||||
"type": entry.get(".tag", default_type),
|
||||
"id": entry.get("id"),
|
||||
"name": entry.get("name"),
|
||||
"path": entry.get("path_display"),
|
||||
"size_in_bytes": entry.get("size"),
|
||||
"modified_datetime": entry.get("server_modified"),
|
||||
}
|
||||
|
||||
|
||||
def clean_dropbox_entries(entries: list[dict]) -> list[dict]:
|
||||
return [clean_dropbox_entry(entry) for entry in entries]
|
||||
|
||||
|
||||
def parse_dropbox_path(path: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(path, str):
|
||||
return ""
|
||||
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if path in ["/", "\\"]:
|
||||
return ""
|
||||
|
||||
# Normalize windows-style paths to unix-style paths
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
# Dropbox expects the path to always start with a slash
|
||||
return "/" + path.strip("/")
|
||||
45
toolkits/dropbox/conftest.py
Normal file
45
toolkits/dropbox/conftest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from arcade.sdk import ToolAuthorizationContext, ToolContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106
|
||||
return ToolContext(authorization=mock_auth)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client(mocker):
|
||||
with patch("arcade_dropbox.utils.httpx") as mock_httpx:
|
||||
yield mock_httpx.AsyncClient().__aenter__.return_value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_folder_entry():
|
||||
return {
|
||||
".tag": "folder",
|
||||
"name": "test.txt",
|
||||
"path_display": "/TestFolder",
|
||||
"path_lower": "/testfolder",
|
||||
"id": "1234567890",
|
||||
"client_modified": "2025-01-01T00:00:00Z",
|
||||
"server_modified": "2025-01-01T00:00:00Z",
|
||||
"rev": "1234567890",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_file_entry():
|
||||
return {
|
||||
".tag": "file",
|
||||
"name": "test.txt",
|
||||
"path_display": "/TestFile.txt",
|
||||
"path_lower": "/testfile.txt",
|
||||
"id": "1234567890",
|
||||
"client_modified": "2025-01-01T00:00:00Z",
|
||||
"server_modified": "2025-01-01T00:00:00Z",
|
||||
"rev": "1234567890",
|
||||
"size": 1024,
|
||||
}
|
||||
88
toolkits/dropbox/evals/eval_download_file.py
Normal file
88
toolkits/dropbox/evals/eval_download_file.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_dropbox
|
||||
from arcade_dropbox.critics import DropboxPathCritic
|
||||
from arcade_dropbox.tools.files import download_file
|
||||
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.8,
|
||||
warn_threshold=0.9,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_dropbox)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def download_file_eval_suite() -> EvalSuite:
|
||||
"""Create an evaluation suite for the download_file tool."""
|
||||
suite = EvalSuite(
|
||||
name="download_file",
|
||||
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Download file in the root folder by file path",
|
||||
user_message="Download the file test.txt from Dropbox",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=download_file,
|
||||
args={
|
||||
"file_path": "test.txt",
|
||||
"file_id": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
DropboxPathCritic(critic_field="file_path", weight=0.5),
|
||||
BinaryCritic(critic_field="file_id", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Download file with a sub-folder structure",
|
||||
user_message="Download the file Q1report.ppt in the folder AcmeInc/Reports from Dropbox",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=download_file,
|
||||
args={
|
||||
"file_path": "/AcmeInc/Reports/Q1report.ppt",
|
||||
"file_id": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
DropboxPathCritic(critic_field="file_path", weight=0.5),
|
||||
BinaryCritic(critic_field="file_id", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Download file by ID",
|
||||
user_message="Download the file id:a4ayc_80_OEAAAAAAAAAYa from Dropbox",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=download_file,
|
||||
args={
|
||||
"file_path": None,
|
||||
"file_id": "id:a4ayc_80_OEAAAAAAAAAYa",
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="file_path", weight=0.5),
|
||||
BinaryCritic(critic_field="file_id", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
94
toolkits/dropbox/evals/eval_list_items.py
Normal file
94
toolkits/dropbox/evals/eval_list_items.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_dropbox
|
||||
from arcade_dropbox.critics import DropboxPathCritic
|
||||
from arcade_dropbox.tools.browse import list_items_in_folder
|
||||
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.8,
|
||||
warn_threshold=0.9,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_dropbox)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def list_items_in_folder_eval_suite() -> EvalSuite:
|
||||
"""Create an evaluation suite for the list_items_in_folder tool."""
|
||||
suite = EvalSuite(
|
||||
name="list_items_in_folder",
|
||||
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="List items in the Dropbox root folder",
|
||||
user_message="List the items in the Dropbox root folder",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_items_in_folder,
|
||||
args={
|
||||
"folder_path": "",
|
||||
"limit": 100,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
DropboxPathCritic(critic_field="folder_path", weight=0.6),
|
||||
BinaryCritic(critic_field="limit", weight=0.2),
|
||||
BinaryCritic(critic_field="cursor", weight=0.2),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="List items in a sub-folder",
|
||||
user_message="List the items in the folder AcmeInc/Reports",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_items_in_folder,
|
||||
args={
|
||||
"folder_path": "/AcmeInc/Reports",
|
||||
"limit": 100,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
DropboxPathCritic(critic_field="folder_path", weight=0.6),
|
||||
BinaryCritic(critic_field="limit", weight=0.2),
|
||||
BinaryCritic(critic_field="cursor", weight=0.2),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="List items in a sub-folder with custom limit",
|
||||
user_message="List the first 50 items in the folder AcmeInc/Reports",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=list_items_in_folder,
|
||||
args={
|
||||
"folder_path": "/AcmeInc/Reports",
|
||||
"limit": 50,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
DropboxPathCritic(critic_field="folder_path", weight=0.4),
|
||||
BinaryCritic(critic_field="limit", weight=0.4),
|
||||
BinaryCritic(critic_field="cursor", weight=0.2),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
131
toolkits/dropbox/evals/eval_search.py
Normal file
131
toolkits/dropbox/evals/eval_search.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from arcade.sdk import ToolCatalog
|
||||
from arcade.sdk.eval import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
tool_eval,
|
||||
)
|
||||
|
||||
import arcade_dropbox
|
||||
from arcade_dropbox.constants import ItemCategory
|
||||
from arcade_dropbox.critics import DropboxPathCritic
|
||||
from arcade_dropbox.tools.browse import search_files_and_folders
|
||||
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.8,
|
||||
warn_threshold=0.9,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_dropbox)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def search_files_and_folders_eval_suite() -> EvalSuite:
|
||||
"""Create an evaluation suite for the search_files_and_folders tool."""
|
||||
suite = EvalSuite(
|
||||
name="list_items_in_folder",
|
||||
system_message="You are an AI assistant that can interact with files and folders in Dropbox using the provided tools.",
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Search for files about 'quarterly report' in my Dropbox",
|
||||
user_message="Search for files about 'quarterly report' in my Dropbox",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=search_files_and_folders,
|
||||
args={
|
||||
"keywords": "quarterly report",
|
||||
"search_in_folder_path": None,
|
||||
"filter_by_category": None,
|
||||
"limit": 100,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="keywords", weight=0.6),
|
||||
BinaryCritic(critic_field="search_in_folder_path", weight=0.1),
|
||||
BinaryCritic(critic_field="filter_by_category", weight=0.1),
|
||||
BinaryCritic(critic_field="limit", weight=0.1),
|
||||
BinaryCritic(critic_field="cursor", weight=0.1),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Search for files about 'quarterly report' in a sub-folder",
|
||||
user_message="Search for files about 'quarterly report' in the folder AcmeInc/Reports",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=search_files_and_folders,
|
||||
args={
|
||||
"keywords": "quarterly report",
|
||||
"search_in_folder_path": "/AcmeInc/Reports",
|
||||
"filter_by_category": None,
|
||||
"limit": 100,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="keywords", weight=0.35),
|
||||
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.35),
|
||||
BinaryCritic(critic_field="filter_by_category", weight=0.1),
|
||||
BinaryCritic(critic_field="limit", weight=0.1),
|
||||
BinaryCritic(critic_field="cursor", weight=0.1),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Search for PDF files about 'quarterly report' in a sub-folder",
|
||||
user_message="Search for PDF files about 'quarterly report' in the folder AcmeInc/Reports",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=search_files_and_folders,
|
||||
args={
|
||||
"keywords": "quarterly report",
|
||||
"search_in_folder_path": "/AcmeInc/Reports",
|
||||
"filter_by_category": [ItemCategory.PDF.value],
|
||||
"limit": 100,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="keywords", weight=0.25),
|
||||
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.25),
|
||||
BinaryCritic(critic_field="filter_by_category", weight=0.25),
|
||||
BinaryCritic(critic_field="limit", weight=0.125),
|
||||
BinaryCritic(critic_field="cursor", weight=0.125),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Search for PDF files about 'quarterly report' in a sub-folder",
|
||||
user_message="Return the first 10 PDF files about 'quarterly report' in the folder AcmeInc/Reports",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=search_files_and_folders,
|
||||
args={
|
||||
"keywords": "quarterly report",
|
||||
"search_in_folder_path": "/AcmeInc/Reports",
|
||||
"filter_by_category": [ItemCategory.PDF.value],
|
||||
"limit": 10,
|
||||
"cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
critics=[
|
||||
BinaryCritic(critic_field="keywords", weight=0.2),
|
||||
DropboxPathCritic(critic_field="search_in_folder_path", weight=0.2),
|
||||
BinaryCritic(critic_field="filter_by_category", weight=0.2),
|
||||
BinaryCritic(critic_field="limit", weight=0.2),
|
||||
BinaryCritic(critic_field="cursor", weight=0.2),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
42
toolkits/dropbox/pyproject.toml
Normal file
42
toolkits/dropbox/pyproject.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
[tool.poetry]
|
||||
name = "arcade_dropbox"
|
||||
version = "0.1.0"
|
||||
description = "Arcade tools designed for LLMs to interact with Dropbox"
|
||||
authors = ["Arcade <dev@arcade.dev>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
arcade-ai = ">=1.0.5,<2.0"
|
||||
dropbox = ">=12.0.0,<13.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^8.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
pytest-mock = "^3.11.1"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.4.0"
|
||||
tox = "^4.11.1"
|
||||
ruff = "^0.7.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
files = ["arcade_dropbox/**/*.py"]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
119
toolkits/dropbox/tests/test_download_file.py
Normal file
119
toolkits/dropbox/tests/test_download_file.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from arcade_dropbox.tools.files import download_file
|
||||
from arcade_dropbox.utils import clean_dropbox_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_content_response_header():
|
||||
return json.dumps({
|
||||
"id": "123",
|
||||
"name": "test.txt",
|
||||
"path_display": "/test.txt",
|
||||
"size": 1024,
|
||||
"server_modified": "2021-01-01T00:00:00Z",
|
||||
"content_hash": "1234567890",
|
||||
"is_downloadable": True,
|
||||
"rev": "a1c10ce0dd78",
|
||||
"sharing_info": {
|
||||
"modified_by": "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc",
|
||||
"parent_shared_folder_id": "84528192421",
|
||||
"read_only": True,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_success(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
file_content_response_header,
|
||||
):
|
||||
file_content = "test file content"
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.side_effect = ValueError("not json")
|
||||
mock_httpx_response.headers = {"Dropbox-API-Result": file_content_response_header}
|
||||
mock_httpx_response.text = file_content
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await download_file(
|
||||
context=mock_context,
|
||||
file_path="test.txt",
|
||||
)
|
||||
|
||||
expected_response = clean_dropbox_entry(json.loads(file_content_response_header))
|
||||
expected_response["content"] = file_content
|
||||
expected_response["type"] = "file"
|
||||
|
||||
assert tool_response == {"file": expected_response}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_path_not_found(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 409
|
||||
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await download_file(
|
||||
context=mock_context,
|
||||
file_path="test.txt",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"error": "The specified path was not found by Dropbox",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_unsupported_file(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 409
|
||||
mock_httpx_response.json.return_value = {"error_summary": "unsupported_file/not_supported"}
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await download_file(
|
||||
context=mock_context,
|
||||
file_path="test.txt",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"error": "The specified file is not supported for the requested operation",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_server_error(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 500
|
||||
mock_httpx_response.text = "500 Internal server error"
|
||||
mock_httpx_response.json.side_effect = ValueError("not json")
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await download_file(
|
||||
context=mock_context,
|
||||
file_path="test.txt",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"error": "500 Internal server error",
|
||||
}
|
||||
145
toolkits/dropbox/tests/test_list_items.py
Normal file
145
toolkits/dropbox/tests/test_list_items.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from arcade_dropbox.tools.browse import list_items_in_folder
|
||||
from arcade_dropbox.utils import clean_dropbox_entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_success_empty_folder(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"entries": [], "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await list_items_in_folder(
|
||||
context=mock_context,
|
||||
folder_path="/path/to/folder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_success_with_folder_entries(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_folder_entry,
|
||||
sample_file_entry,
|
||||
):
|
||||
entries = [sample_folder_entry, sample_file_entry]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"entries": entries, "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await list_items_in_folder(
|
||||
context=mock_context,
|
||||
folder_path="/path/to/folder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": clean_dropbox_entries(entries),
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_success_with_more_items_to_paginate(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_folder_entry,
|
||||
sample_file_entry,
|
||||
):
|
||||
entries = [sample_folder_entry, sample_file_entry]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {
|
||||
"entries": entries,
|
||||
"cursor": "cursor",
|
||||
"has_more": True,
|
||||
}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await list_items_in_folder(
|
||||
context=mock_context,
|
||||
folder_path="/path/to/folder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": clean_dropbox_entries(entries),
|
||||
"cursor": "cursor",
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_success_providing_cursor(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_folder_entry,
|
||||
sample_file_entry,
|
||||
):
|
||||
entries = [sample_folder_entry, sample_file_entry]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {
|
||||
"entries": entries,
|
||||
"cursor": "cursor2",
|
||||
"has_more": True,
|
||||
}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await list_items_in_folder(
|
||||
context=mock_context,
|
||||
folder_path="/path/to/folder",
|
||||
cursor="cursor1",
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": clean_dropbox_entries(entries),
|
||||
"cursor": "cursor2",
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
# Check that the request was made with the cursor and not the other arguments
|
||||
mock_httpx_client.post.assert_called_with(
|
||||
url="https://api.dropboxapi.com/2/files/list_folder/continue",
|
||||
headers={"Authorization": "Bearer fake-token"},
|
||||
json={"cursor": "cursor1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_items_path_not_found(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 409
|
||||
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await list_items_in_folder(
|
||||
context=mock_context,
|
||||
folder_path="/not/exist/folder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"error": "The specified path was not found by Dropbox",
|
||||
}
|
||||
286
toolkits/dropbox/tests/test_search_files.py
Normal file
286
toolkits/dropbox/tests/test_search_files.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from arcade_dropbox.constants import ItemCategory
|
||||
from arcade_dropbox.tools.browse import search_files_and_folders
|
||||
from arcade_dropbox.utils import clean_dropbox_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_folder_match(sample_folder_entry):
|
||||
return {
|
||||
"metadata": {
|
||||
".tag": "metadata",
|
||||
"metadata": sample_folder_entry,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_file_match(sample_file_entry):
|
||||
return {
|
||||
"metadata": {
|
||||
".tag": "metadata",
|
||||
"metadata": sample_file_entry,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_empty_results(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"matches": [], "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="do not match anything",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_with_matches(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
sample_file_entry,
|
||||
sample_folder_entry,
|
||||
):
|
||||
matches = [
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [
|
||||
clean_dropbox_entry(sample_file_entry),
|
||||
clean_dropbox_entry(sample_folder_entry),
|
||||
],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_with_path_missing_leading_slash(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
sample_file_entry,
|
||||
sample_folder_entry,
|
||||
):
|
||||
matches = [
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
search_in_folder_path="TestFolder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [
|
||||
clean_dropbox_entry(sample_file_entry),
|
||||
clean_dropbox_entry(sample_folder_entry),
|
||||
],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
mock_httpx_client.post.assert_called_once_with(
|
||||
url="https://api.dropboxapi.com/2/files/search_v2",
|
||||
headers={"Authorization": "Bearer fake-token"},
|
||||
json={
|
||||
"query": "test",
|
||||
"options": {
|
||||
"file_categories": [],
|
||||
"path": "/TestFolder",
|
||||
"file_status": "active",
|
||||
"filename_only": False,
|
||||
"max_results": 100,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_with_more_results_to_paginate(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
sample_file_entry,
|
||||
sample_folder_entry,
|
||||
):
|
||||
matches = [
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {
|
||||
"matches": matches,
|
||||
"cursor": "cursor",
|
||||
"has_more": True,
|
||||
}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [
|
||||
clean_dropbox_entry(sample_file_entry),
|
||||
clean_dropbox_entry(sample_folder_entry),
|
||||
],
|
||||
"cursor": "cursor",
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_providing_pagination_cursor(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
sample_file_entry,
|
||||
sample_folder_entry,
|
||||
):
|
||||
matches = [
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
cursor="cursor",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [
|
||||
clean_dropbox_entry(sample_file_entry),
|
||||
clean_dropbox_entry(sample_folder_entry),
|
||||
],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
# Assert that the request was made with the correct cursor and not other arguments
|
||||
mock_httpx_client.post.assert_called_once_with(
|
||||
url="https://api.dropboxapi.com/2/files/search_v2/continue",
|
||||
headers={"Authorization": "Bearer fake-token"},
|
||||
json={"cursor": "cursor"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_path_not_found(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
):
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 409
|
||||
mock_httpx_response.json.return_value = {"error_summary": "path/not_found"}
|
||||
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
search_in_folder_path="/not/exist/folder",
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"error": "The specified path was not found by Dropbox",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_success_filtering_by_category(
|
||||
mock_context,
|
||||
mock_httpx_client,
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
sample_file_entry,
|
||||
sample_folder_entry,
|
||||
):
|
||||
matches = [
|
||||
sample_file_match,
|
||||
sample_folder_match,
|
||||
]
|
||||
|
||||
mock_httpx_response = MagicMock(spec=httpx.Response)
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response.json.return_value = {"matches": matches, "cursor": None, "has_more": False}
|
||||
mock_httpx_client.post.return_value = mock_httpx_response
|
||||
|
||||
tool_response = await search_files_and_folders(
|
||||
context=mock_context,
|
||||
keywords="test",
|
||||
filter_by_category=[ItemCategory.PDF],
|
||||
)
|
||||
|
||||
assert tool_response == {
|
||||
"items": [
|
||||
clean_dropbox_entry(sample_file_entry),
|
||||
clean_dropbox_entry(sample_folder_entry),
|
||||
],
|
||||
"cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
mock_httpx_client.post.assert_called_once_with(
|
||||
url="https://api.dropboxapi.com/2/files/search_v2",
|
||||
headers={"Authorization": "Bearer fake-token"},
|
||||
json={
|
||||
"query": "test",
|
||||
"options": {
|
||||
"path": "",
|
||||
"file_status": "active",
|
||||
"filename_only": False,
|
||||
"max_results": 100,
|
||||
"file_categories": [ItemCategory.PDF.value],
|
||||
},
|
||||
},
|
||||
)
|
||||
Loading…
Reference in a new issue