From 78504532f49b5dbb1218285cb0c61f2b474a9624 Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Sun, 27 Jul 2025 19:08:56 -0400 Subject: [PATCH 1/6] Adding Todoist toolkit --- toolkits/todoist/.pre-commit-config.yaml | 18 + toolkits/todoist/.ruff.toml | 44 ++ toolkits/todoist/Makefile | 55 ++ toolkits/todoist/README.md | 26 + toolkits/todoist/arcade_todoist/__init__.py | 0 .../todoist/arcade_todoist/tools/__init__.py | 0 .../todoist/arcade_todoist/tools/projects.py | 35 ++ .../todoist/arcade_todoist/tools/tasks.py | 360 ++++++++++++ toolkits/todoist/arcade_todoist/utils.py | 83 +++ toolkits/todoist/evals/eval_todoist.py | 169 ++++++ toolkits/todoist/pyproject.toml | 58 ++ toolkits/todoist/tests/__init__.py | 0 toolkits/todoist/tests/conftest.py | 14 + toolkits/todoist/tests/test_projects.py | 38 ++ toolkits/todoist/tests/test_tasks.py | 539 ++++++++++++++++++ 15 files changed, 1439 insertions(+) create mode 100644 toolkits/todoist/.pre-commit-config.yaml create mode 100644 toolkits/todoist/.ruff.toml create mode 100644 toolkits/todoist/Makefile create mode 100644 toolkits/todoist/README.md create mode 100644 toolkits/todoist/arcade_todoist/__init__.py create mode 100644 toolkits/todoist/arcade_todoist/tools/__init__.py create mode 100644 toolkits/todoist/arcade_todoist/tools/projects.py create mode 100644 toolkits/todoist/arcade_todoist/tools/tasks.py create mode 100644 toolkits/todoist/arcade_todoist/utils.py create mode 100644 toolkits/todoist/evals/eval_todoist.py create mode 100644 toolkits/todoist/pyproject.toml create mode 100644 toolkits/todoist/tests/__init__.py create mode 100644 toolkits/todoist/tests/conftest.py create mode 100644 toolkits/todoist/tests/test_projects.py create mode 100644 toolkits/todoist/tests/test_tasks.py diff --git a/toolkits/todoist/.pre-commit-config.yaml b/toolkits/todoist/.pre-commit-config.yaml new file mode 100644 index 000000000..1aa88316d --- /dev/null +++ b/toolkits/todoist/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^.*/todoist/.* +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 diff --git a/toolkits/todoist/.ruff.toml b/toolkits/todoist/.ruff.toml new file mode 100644 index 000000000..9519fe6c3 --- /dev/null +++ b/toolkits/todoist/.ruff.toml @@ -0,0 +1,44 @@ +target-version = "py310" +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] +"**/tests/*" = ["S101"] + +[format] +preview = true +skip-magic-trailing-comma = false diff --git a/toolkits/todoist/Makefile b/toolkits/todoist/Makefile new file mode 100644 index 000000000..fe0ad4bd5 --- /dev/null +++ b/toolkits/todoist/Makefile @@ -0,0 +1,55 @@ +.PHONY: help + +help: + @echo "🛠️ github Commands:\n" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: install +install: ## Install the uv environment and install all packages with dependencies + @echo "🚀 Creating virtual environment and installing all packages using uv" + @uv sync --active --all-extras --no-sources + @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi + @echo "✅ All packages and dependencies installed via uv" + +.PHONY: install-local +install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources + @echo "🚀 Creating virtual environment and installing all packages using uv" + @uv sync --active --all-extras + @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi + @echo "✅ All packages and dependencies installed via uv" + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "🚀 Creating wheel file" + uv build + +.PHONY: clean-build +clean-build: ## clean build artifacts + @echo "🗑️ Cleaning dist directory" + rm -rf dist + +.PHONY: test +test: ## Test the code with pytest + @echo "🚀 Testing code: Running pytest" + @uv run --no-sources pytest -W ignore -vv --cov --cov-config=pyproject.toml --cov-report=xml + +.PHONY: coverage +coverage: ## Generate coverage report + @echo "coverage report" + @uv run --no-sources coverage report + @echo "Generating coverage report" + @uv run --no-sources coverage html + +.PHONY: bump-version +bump-version: ## Bump the version in the pyproject.toml file by a patch version + @echo "🚀 Bumping version in pyproject.toml" + uv version --no-sources --bump patch + +.PHONY: check +check: ## Run code quality tools. + @if [ -f .pre-commit-config.yaml ]; then\ + echo "🚀 Linting code: Running pre-commit";\ + uv run --no-sources pre-commit run -a;\ + fi + @echo "🚀 Static type checking: Running mypy" + @uv run --no-sources mypy --config-file=pyproject.toml diff --git a/toolkits/todoist/README.md b/toolkits/todoist/README.md new file mode 100644 index 000000000..8b3761746 --- /dev/null +++ b/toolkits/todoist/README.md @@ -0,0 +1,26 @@ +
+ +
+ +
+ Python version + License + PyPI version +
+ + +
+
+ +# Arcade todoist Toolkit +Allow agent to connect and interact with Todoist +## Features + +- The todoist toolkit does not have any features yet. + +## Development + +Read the docs on how to create a toolkit [here](https://docs.arcade.dev/home/build-tools/create-a-toolkit) diff --git a/toolkits/todoist/arcade_todoist/__init__.py b/toolkits/todoist/arcade_todoist/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/todoist/arcade_todoist/tools/__init__.py b/toolkits/todoist/arcade_todoist/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/todoist/arcade_todoist/tools/projects.py b/toolkits/todoist/arcade_todoist/tools/projects.py new file mode 100644 index 000000000..2c023dade --- /dev/null +++ b/toolkits/todoist/arcade_todoist/tools/projects.py @@ -0,0 +1,35 @@ +from typing import Annotated + +import httpx +from arcade_tdk import ToolContext, tool +from arcade_tdk.auth import OAuth2 + +from arcade_todoist.utils import get_headers, get_url, parse_projects + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read"], + ), +) +async def get_projects( + context: ToolContext, +) -> Annotated[dict, "The projects object returned by the Todoist API."]: + """ + Get all projects from the Todoist API. Use this when the user wants to see, list, or browse + their projects. Do NOT use this for creating tasks - use create_task instead even if a + project name is mentioned. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="projects") + headers = get_headers(context=context) + + response = await client.get(url, headers=headers) + + response.raise_for_status() + + projects = parse_projects(response.json()["results"]) + + return {"projects": projects} diff --git a/toolkits/todoist/arcade_todoist/tools/tasks.py b/toolkits/todoist/arcade_todoist/tools/tasks.py new file mode 100644 index 000000000..3bb0228a6 --- /dev/null +++ b/toolkits/todoist/arcade_todoist/tools/tasks.py @@ -0,0 +1,360 @@ +from typing import Annotated + +import httpx +from arcade_tdk import ToolContext, tool +from arcade_tdk.auth import OAuth2 +from arcade_tdk.errors import ToolExecutionError + +from arcade_todoist.tools.projects import get_projects +from arcade_todoist.utils import get_headers, get_url, parse_task, parse_tasks + + +class ProjectNotFoundError(ToolExecutionError): + """Raised when a project is not found.""" + + def __init__(self, project_name: str, partial_matches: list[str] | None = None): + if partial_matches: + matches_str = "', '".join(partial_matches) + super().__init__( + "Project not found", + developer_message=( + f"Project '{project_name}' not found, but found partial matches: " + f"{matches_str}. " + f"Please specify the exact project name." + ), + ) + else: + super().__init__( + "Project not found", + developer_message=( + f"Project '{project_name}' not found. " + f"Ask the user to create the project first." + ), + ) + + +class TaskNotFoundError(ToolExecutionError): + """Raised when a task is not found.""" + + def __init__(self, task_description: str, partial_matches: list[str] | None = None): + if partial_matches: + matches_str = "', '".join(partial_matches) + super().__init__( + "Task not found", + developer_message=( + f"Task '{task_description}' not found, but found partial matches: " + f"{matches_str}. " + f"Please specify the exact task description." + ), + ) + else: + super().__init__( + "Task not found", developer_message=f"Task '{task_description}' not found." + ) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read"], + ), +) +async def get_all_tasks( + context: ToolContext, +) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + """ + Get all tasks from the Todoist API. Use this when the user wants to see, list, view, or + browse all their existing tasks. For getting tasks from a specific project, use + get_tasks_by_project_name or get_tasks_by_project_id instead. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + response = await client.get(url, headers=headers) + + response.raise_for_status() + + tasks = parse_tasks(response.json()["results"]) + + return {"tasks": tasks} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read"], + ), +) +async def get_tasks_by_project_id( + context: ToolContext, + project_id: Annotated[str, "The ID of the project to get tasks from."], +) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + """ + Get tasks from a specific project by project ID. Use this when you already have the project ID. + For getting tasks by project name, use get_tasks_by_project_name instead. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + params = {"project_id": project_id} + + response = await client.get(url, headers=headers, params=params) + + response.raise_for_status() + + tasks = parse_tasks(response.json()["results"]) + + return {"tasks": tasks} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read"], + ), +) +async def get_tasks_by_project_name( + context: ToolContext, + project_name: Annotated[str, "The name of the project to get tasks from."], +) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + """ + Get tasks from a specific project by project name. Use this when the user wants to see + tasks from a specific project. + """ + + projects = await get_projects(context=context) + + project_id = None + + for project in projects["projects"]: + if project["name"].lower() == project_name.lower(): + project_id = project["id"] + break + + if project_id is None: + partial_matches = [] + for project in projects["projects"]: + if project_name.lower() in project["name"].lower(): + partial_matches.append(project["name"]) + + if partial_matches: + raise ProjectNotFoundError(project_name, partial_matches) + else: + raise ProjectNotFoundError(project_name) + + return await get_tasks_by_project_id(context=context, project_id=project_id) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def create_task_in_project( + context: ToolContext, + description: Annotated[str, "The title of the task to be created."], + project_id: Annotated[ + str | None, "The ID of the project to add the task to. Use None to add to inbox." + ], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Create a new task in a specific project by project ID. Use this when you already have the + project ID. For creating tasks by project name, use create_task instead. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + response = await client.post( + url, + headers=headers, + json={ + "content": description, + "project_id": project_id, + }, + ) + + response.raise_for_status() + + task = parse_task(response.json()) + + return task + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def create_task( + context: ToolContext, + description: Annotated[str, "The title of the task to be created."], + project_name: Annotated[ + str | None, + "The name of the project to add the task to. Use the project name if user mentions a " + "specific project.", + ], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Create a new task for the user. Use this whenever the user wants to create, add, or make a task. + If the user mentions a specific project, pass the project name as project_name. + If no project is mentioned, leave project_name as None to add to inbox. + """ + + project_id = None + if project_name is not None: + projects = await get_projects(context=context) + + for project in projects["projects"]: + if project["name"].lower() == project_name.lower(): + project_id = project["id"] + break + + if project_id is None: + partial_matches = [] + for project in projects["projects"]: + if project_name.lower() in project["name"].lower(): + partial_matches.append(project["name"]) + + if partial_matches: + raise ProjectNotFoundError(project_name, partial_matches) + else: + raise ProjectNotFoundError(project_name) + + return await create_task_in_project( + context=context, description=description, project_id=project_id + ) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def close_task_by_task_id( + context: ToolContext, + task_id: Annotated[str, "The id of the task to be closed."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Close a task by its ID. Use this when you already have the task ID. + For closing tasks by description/content, use close_task instead. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint=f"tasks/{task_id}/close") + headers = get_headers(context=context) + + response = await client.post(url, headers=headers) + + response.raise_for_status() + + return {"message": "Task closed successfully"} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def close_task( + context: ToolContext, + task_description: Annotated[str, "The description/content of the task to be closed."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Close a task by its description/content. Use this whenever the user wants to mark a task as + completed, done, or closed. + """ + + tasks = await get_all_tasks(context=context) + + task_id = None + + for task in tasks["tasks"]: + if task["content"].lower() == task_description.lower(): + task_id = task["id"] + break + + if task_id is None: + partial_matches = [] + for task in tasks["tasks"]: + if task_description.lower() in task["content"].lower(): + partial_matches.append(task["content"]) + + if partial_matches: + raise TaskNotFoundError(task_description, partial_matches) + else: + raise TaskNotFoundError(task_description) + + return await close_task_by_task_id(context=context, task_id=task_id) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def delete_task_by_task_id( + context: ToolContext, + task_id: Annotated[str, "The id of the task to be deleted."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Delete a task by its ID. Use this when you already have the task ID. + For deleting tasks by description/content, use delete_task instead. + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint=f"tasks/{task_id}") + headers = get_headers(context=context) + + response = await client.delete(url, headers=headers) + + response.raise_for_status() + + return {"message": "Task deleted successfully"} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def delete_task( + context: ToolContext, + task_description: Annotated[str, "The description/content of the task to be deleted."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Delete a task by its description/content. Use this whenever the user wants to delete a task. + """ + + tasks = await get_all_tasks(context=context) + + task_id = None + + for task in tasks["tasks"]: + if task["content"].lower() == task_description.lower(): + task_id = task["id"] + break + + if task_id is None: + partial_matches = [] + for task in tasks["tasks"]: + if task_description.lower() in task["content"].lower(): + partial_matches.append(task["content"]) + + if partial_matches: + raise TaskNotFoundError(task_description, partial_matches) + else: + raise TaskNotFoundError(task_description) + + return await delete_task_by_task_id(context=context, task_id=task_id) diff --git a/toolkits/todoist/arcade_todoist/utils.py b/toolkits/todoist/arcade_todoist/utils.py new file mode 100644 index 000000000..90bce6f0d --- /dev/null +++ b/toolkits/todoist/arcade_todoist/utils.py @@ -0,0 +1,83 @@ +from typing import Any + +from arcade_tdk import ToolContext +from arcade_tdk.errors import ToolExecutionError + + +class TodoistAuthError(ToolExecutionError): + """Raised when Todoist authentication token is missing.""" + + def __init__(self): + super().__init__("No token found") + + +def get_headers(context: ToolContext) -> dict[str, str]: + """ + Build headers for the Todoist API requests. + """ + + token = context.get_auth_token_or_empty() + + if not token: + raise TodoistAuthError() + + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def get_url( + context: ToolContext, + endpoint: str, + api_version: str = "v1", +) -> str: + """ + Build the URL for the Todoist API request. + """ + + base_url = "https://api.todoist.com" + + return f"{base_url}/api/{api_version}/{endpoint}" + + +def parse_project(project: dict[str, Any]) -> dict[str, Any]: + """ + Parse the project object returned by the Todoist API. + """ + + return { + "id": project["id"], + "name": project["name"], + "created_at": project["created_at"], + } + + +def parse_projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Parse the projects object returned by the Todoist API. + """ + + return [parse_project(project) for project in projects] + + +def parse_task(task: dict[str, Any]) -> dict[str, Any]: + """ + Parse the task object returned by the Todoist API. + """ + + return { + "id": task["id"], + "content": task["content"], + "added_at": task["added_at"], + "checked": task["checked"], + "project_id": task["project_id"], + } + + +def parse_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Parse the tasks object returned by the Todoist API. + """ + + return [parse_task(task) for task in tasks] diff --git a/toolkits/todoist/evals/eval_todoist.py b/toolkits/todoist/evals/eval_todoist.py new file mode 100644 index 000000000..630b4ca77 --- /dev/null +++ b/toolkits/todoist/evals/eval_todoist.py @@ -0,0 +1,169 @@ +from arcade_evals import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade_evals.critic import SimilarityCritic +from arcade_tdk import ToolCatalog + +import arcade_todoist +from arcade_todoist.tools.projects import get_projects +from arcade_todoist.tools.tasks import ( + close_task, + close_task_by_task_id, + create_task, + delete_task, + delete_task_by_task_id, + get_all_tasks, + get_tasks_by_project_id, + get_tasks_by_project_name, +) + +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + +catalog = ToolCatalog() +catalog.add_module(arcade_todoist) + + +@tool_eval() +def todoist_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="todoist Tools Evaluation", + system_message=( + "You are an AI assistant with access to todoist tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Getting the projects", + user_message="Get all my projects", + expected_tool_calls=[ExpectedToolCall(func=get_projects, args={})], + rubric=rubric, + critics=[], + additional_messages=[], + ) + + suite.add_case( + name="Getting all the tasks", + user_message="Get all my tasks from across the board", + expected_tool_calls=[ExpectedToolCall(func=get_all_tasks, args={})], + rubric=rubric, + critics=[], + additional_messages=[], + ) + + suite.add_case( + name="Getting tasks from a specific project with project id", + user_message="What are my tasks in the project with id '12345'?", + expected_tool_calls=[ + ExpectedToolCall(func=get_tasks_by_project_id, args={"project_id": "12345"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="project_id", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Getting tasks from a specific project with project name", + user_message="What do I have left to do in the 'Personal' project?", + expected_tool_calls=[ + ExpectedToolCall(func=get_tasks_by_project_name, args={"project_name": "Personal"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="project_name", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Create a task for the inbox", + user_message="Hey! create a task to 'Buy groceries'", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, args={"description": "Buy groceries", "project_id": None} + ) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="description", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Create a task for the a specific project", + user_message="Hey! create a task to 'Check the email' in the 'Personal' project", + expected_tool_calls=[ + ExpectedToolCall( + func=create_task, + args={"description": "Check the email", "project_name": "Personal"}, + ) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="description", weight=0.5), + SimilarityCritic(critic_field="project_name", weight=0.5), + ], + additional_messages=[], + ) + + suite.add_case( + name="Close a task by ID", + user_message="Mark task with ID '12345' as completed", + expected_tool_calls=[ + ExpectedToolCall(func=close_task_by_task_id, args={"task_id": "12345"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="task_id", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Close a task by Name", + user_message="I'm done with the task 'Buy groceries'", + expected_tool_calls=[ + ExpectedToolCall(func=close_task, args={"task_description": "Buy groceries"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="task_description", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Complete a task by id", + user_message="Please close task with id abc123, I finished it", + expected_tool_calls=[ + ExpectedToolCall(func=close_task_by_task_id, args={"task_id": "abc123"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="task_id", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Delete a task by ID", + user_message="Delete task with ID 'task_456'", + expected_tool_calls=[ + ExpectedToolCall(func=delete_task_by_task_id, args={"task_id": "task_456"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="task_id", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Remove a task by name", + user_message="I want to remove task Wash car completely", + expected_tool_calls=[ + ExpectedToolCall(func=delete_task, args={"task_description": "Wash car"}) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="task_description", weight=1)], + additional_messages=[], + ) + + return suite diff --git a/toolkits/todoist/pyproject.toml b/toolkits/todoist/pyproject.toml new file mode 100644 index 000000000..0aefa1559 --- /dev/null +++ b/toolkits/todoist/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "arcade_todoist" +version = "0.1.0" +description = "Allow agent to connect and interact with Todoist" +requires-python = ">=3.10" +dependencies = [ + "arcade-tdk>=2.0.0,<3.0.0", +] + + +[project.optional-dependencies] +dev = [ + "arcade-ai[evals]>=2.1.1,<3.0.0", + "arcade-serve>=2.0.0,<3.0.0", + "pytest>=8.3.0,<8.4.0", + "pytest-cov>=4.0.0,<4.1.0", + "pytest-mock>=3.11.1,<3.12.0", + "pytest-asyncio>=0.24.0,<0.25.0", + "mypy>=1.5.1,<1.6.0", + "pre-commit>=3.4.0,<3.5.0", + "tox>=4.11.1,<4.12.0", + "ruff>=0.7.4,<0.8.0", +] + +# Tell Arcade.dev that this package is a toolkit +[project.entry-points.arcade_toolkits] +toolkit_name = "arcade_todoist" + +# Use local path sources for arcade libs when working locally +[tool.uv.sources] +arcade-ai = { path = "../../", editable = true } +arcade-serve = { path = "../../libs/arcade-serve/", editable = true } +arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } + +[tool.mypy] +files = [ "arcade_todoist/**/*.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 + +[tool.hatch.build.targets.wheel] +packages = [ "arcade_todoist",] diff --git a/toolkits/todoist/tests/__init__.py b/toolkits/todoist/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/todoist/tests/conftest.py b/toolkits/todoist/tests/conftest.py new file mode 100644 index 000000000..812ae77ad --- /dev/null +++ b/toolkits/todoist/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from arcade_tdk import ToolContext + + +@pytest.fixture +def tool_context() -> ToolContext: + return ToolContext(authorization={"token": "test_token"}) + + +@pytest.fixture +def httpx_mock(mocker): + mock_client = mocker.patch("httpx.AsyncClient", autospec=True) + async_mock_client = mock_client.return_value.__aenter__.return_value + return async_mock_client diff --git a/toolkits/todoist/tests/test_projects.py b/toolkits/todoist/tests/test_projects.py new file mode 100644 index 000000000..c263c8670 --- /dev/null +++ b/toolkits/todoist/tests/test_projects.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock + +import pytest + +from arcade_todoist.tools.projects import get_projects + +fake_projects_response = { + "results": [ + { + "id": "1", + "name": "Project 1", + "created_at": "2021-01-01", + "can_assign_tasks": True, + "child_order": 0, + "color": "string", + "creator_uid": "string", + "is_archived": True, + "is_deleted": True, + "is_favorite": True, + } + ] +} + +faked_parsed_projects = {"projects": [{"id": "1", "name": "Project 1", "created_at": "2021-01-01"}]} + + +@pytest.mark.asyncio +async def test_get_projects_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_projects_response + httpx_mock.get.return_value = mock_response + + result = await get_projects(context=tool_context) + + assert result == faked_parsed_projects + + httpx_mock.get.assert_called_once() diff --git a/toolkits/todoist/tests/test_tasks.py b/toolkits/todoist/tests/test_tasks.py new file mode 100644 index 000000000..24cb7814e --- /dev/null +++ b/toolkits/todoist/tests/test_tasks.py @@ -0,0 +1,539 @@ +from unittest.mock import MagicMock + +import httpx +import pytest +from arcade_tdk.errors import ToolExecutionError + +from arcade_todoist.tools.tasks import ( + ProjectNotFoundError, + TaskNotFoundError, + close_task, + close_task_by_task_id, + create_task, + create_task_in_project, + delete_task, + delete_task_by_task_id, + get_all_tasks, + get_tasks_by_project_id, + get_tasks_by_project_name, +) + +fake_tasks_response = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project id", + "checked": True, + "description": "Description of the task", + } + ] +} + +faked_parsed_tasks = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project id", + "checked": True, + } + ] +} + +fake_create_task_response = { + "id": "2", + "content": "New Task", + "added_at": "2024-01-01", + "project_id": "project_123", + "checked": False, + "priority": 1, + "description": "A new task description", +} + +faked_parsed_create_task = { + "id": "2", + "content": "New Task", + "added_at": "2024-01-01", + "project_id": "project_123", + "checked": False, +} + +expected_close_task_response = {"message": "Task closed successfully"} + +expected_delete_task_response = {"message": "Task deleted successfully"} + + +@pytest.mark.asyncio +async def test_get_all_tasks_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context) + + assert result == faked_parsed_tasks + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_all_tasks_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response + ) + httpx_mock.get.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await get_all_tasks(context=tool_context) + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_task_in_project_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_create_task_response + httpx_mock.post.return_value = mock_response + + result = await create_task_in_project( + context=tool_context, description="New Task", project_id="project_123" + ) + + assert result == faked_parsed_create_task + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_task_in_project_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Bad Request", + request=httpx.Request("POST", "http://test.com"), + response=mock_response, + ) + httpx_mock.post.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await create_task_in_project( + context=tool_context, description="New Task", project_id="project_123" + ) + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_close_task_by_task_id_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + httpx_mock.post.return_value = mock_response + + result = await close_task_by_task_id(context=tool_context, task_id="task_123") + + assert result == expected_close_task_response + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_close_task_by_task_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", + request=httpx.Request("POST", "http://test.com"), + response=mock_response, + ) + httpx_mock.post.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await close_task_by_task_id(context=tool_context, task_id="task_123") + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_task_by_task_id_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + httpx_mock.delete.return_value = mock_response + + result = await delete_task_by_task_id(context=tool_context, task_id="task_123") + + assert result == expected_delete_task_response + + httpx_mock.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_task_by_task_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", + request=httpx.Request("DELETE", "http://test.com"), + response=mock_response, + ) + httpx_mock.delete.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await delete_task_by_task_id(context=tool_context, task_id="task_123") + + httpx_mock.delete.assert_called_once() + + +# Additional test data for project-based and description-based tests +fake_projects_response = { + "projects": [ + {"id": "project_123", "name": "Work Project", "created_at": "2021-01-01"}, + {"id": "project_456", "name": "Personal Tasks", "created_at": "2021-01-01"}, + ] +} + +fake_multiple_tasks_response = { + "tasks": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "2", + "content": "Grocery shopping", + "added_at": "2021-01-01", + "project_id": "project_456", + "checked": False, + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ] +} + + +@pytest.mark.asyncio +async def test_create_task_success_exact_project_match(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + # Mock create_task_in_project + mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks.create_task_in_project") + mock_create_task_in_project.return_value = faked_parsed_create_task + + result = await create_task( + context=tool_context, description="New Task", project_name="Work Project" + ) + + assert result == faked_parsed_create_task + mock_get_projects.assert_called_once_with(context=tool_context) + mock_create_task_in_project.assert_called_once_with( + context=tool_context, description="New Task", project_id="project_123" + ) + + +@pytest.mark.asyncio +async def test_create_task_success_no_project(tool_context, mocker) -> None: + # Mock create_task_in_project + mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks.create_task_in_project") + mock_create_task_in_project.return_value = faked_parsed_create_task + + result = await create_task(context=tool_context, description="New Task", project_name=None) + + assert result == faked_parsed_create_task + mock_create_task_in_project.assert_called_once_with( + context=tool_context, description="New Task", project_id=None + ) + + +@pytest.mark.asyncio +async def test_create_task_project_not_found(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + with pytest.raises(ProjectNotFoundError) as exc_info: + await create_task( + context=tool_context, description="New Task", project_name="Nonexistent Project" + ) + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_create_task_project_partial_match(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + with pytest.raises(ProjectNotFoundError) as exc_info: + await create_task(context=tool_context, description="New Task", project_name="Work") + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_close_task_success_exact_match(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + # Mock close_task_by_task_id + mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks.close_task_by_task_id") + mock_close_task_by_task_id.return_value = expected_close_task_response + + result = await close_task(context=tool_context, task_description="Buy groceries") + + assert result == expected_close_task_response + mock_get_all_tasks.assert_called_once_with(context=tool_context) + mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") + + +@pytest.mark.asyncio +async def test_close_task_not_found(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + with pytest.raises(TaskNotFoundError) as exc_info: + await close_task(context=tool_context, task_description="Nonexistent task") + + assert "Task not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_close_task_partial_match(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + with pytest.raises(TaskNotFoundError) as exc_info: + await close_task(context=tool_context, task_description="grocery") + + error_message = str(exc_info.value) + assert "Task not found" in error_message + + +@pytest.mark.asyncio +async def test_delete_task_success_exact_match(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + # Mock delete_task_by_task_id + mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks.delete_task_by_task_id") + mock_delete_task_by_task_id.return_value = expected_delete_task_response + + result = await delete_task(context=tool_context, task_description="Meeting notes") + + assert result == expected_delete_task_response + mock_get_all_tasks.assert_called_once_with(context=tool_context) + mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") + + +@pytest.mark.asyncio +async def test_delete_task_not_found(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + with pytest.raises(TaskNotFoundError) as exc_info: + await delete_task(context=tool_context, task_description="Nonexistent task") + + assert "Task not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_delete_task_partial_match(tool_context, mocker) -> None: + # Mock get_all_tasks + mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") + mock_get_all_tasks.return_value = fake_multiple_tasks_response + + with pytest.raises(TaskNotFoundError) as exc_info: + await delete_task(context=tool_context, task_description="notes") + + assert "Task not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None: + # Mock API response for specific project + project_tasks_response = { + "results": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description of the task", + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description of the task", + }, + ] + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = project_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_project_id(context=tool_context, project_id="project_123") + + # Should only return tasks from project_123 + expected_filtered_tasks = { + "tasks": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ] + } + + assert result == expected_filtered_tasks + httpx_mock.get.assert_called_once() + + # Verify the API was called with the correct query parameter + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"project_id": "project_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_empty_result(tool_context, httpx_mock) -> None: + # Mock API response with no tasks for the project + empty_tasks_response = {"results": []} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = empty_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_project_id(context=tool_context, project_id="empty_project") + + # Should return empty tasks list + expected_empty_result = {"tasks": []} + + assert result == expected_empty_result + httpx_mock.get.assert_called_once() + + # Verify the API was called with the correct query parameter + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"project_id": "empty_project"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response + ) + httpx_mock.get.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await get_tasks_by_project_id(context=tool_context, project_id="project_123") + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_success(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + # Mock get_tasks_by_project_id + expected_filtered_tasks = { + "tasks": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ] + } + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks.get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = expected_filtered_tasks + + result = await get_tasks_by_project_name(context=tool_context, project_name="Work Project") + + assert result == expected_filtered_tasks + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, project_id="project_123" + ) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_not_found(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + with pytest.raises(ProjectNotFoundError) as exc_info: + await get_tasks_by_project_name(context=tool_context, project_name="Nonexistent Project") + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_partial_match(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + with pytest.raises(ProjectNotFoundError) as exc_info: + await get_tasks_by_project_name(context=tool_context, project_name="Work") + + assert "Project not found" in str(exc_info.value) From 56e6e2c8e5eb40716a0181f84c1ea768465112fe Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Tue, 29 Jul 2025 13:27:08 -0400 Subject: [PATCH 2/6] Added pagination support to tasks tools --- .../todoist/arcade_todoist/tools/tasks.py | 136 +++++++--- toolkits/todoist/evals/eval_todoist.py | 27 ++ toolkits/todoist/tests/test_tasks.py | 233 +++++++++++++++++- 3 files changed, 352 insertions(+), 44 deletions(-) diff --git a/toolkits/todoist/arcade_todoist/tools/tasks.py b/toolkits/todoist/arcade_todoist/tools/tasks.py index 3bb0228a6..2ef84b1b4 100644 --- a/toolkits/todoist/arcade_todoist/tools/tasks.py +++ b/toolkits/todoist/arcade_todoist/tools/tasks.py @@ -9,6 +9,47 @@ from arcade_todoist.utils import get_headers, get_url, parse_task, parse_tasks +async def _get_tasks_with_pagination( + context: ToolContext, + limit: int = 50, + next_page_token: str | None = None, + project_id: str | None = None, +) -> dict: + """ + Utility function to get tasks with pagination support. + + Args: + context: ToolContext for API access + limit: Number of tasks to return (min: 1, default: 50, max: 200) + next_page_token: Token for pagination, use None for first page + project_id: Optional project ID to filter tasks by project + + Returns: + Dict containing tasks and next_page_token for pagination + """ + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + params = {"limit": limit} + if next_page_token: + params["cursor"] = next_page_token + if project_id: + params["project_id"] = project_id + + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + tasks = parse_tasks(data["results"]) + next_cursor = data.get("next_cursor") + + return { + "tasks": tasks, + "next_page_token": next_cursor + } + + class ProjectNotFoundError(ToolExecutionError): """Raised when a project is not found.""" @@ -61,24 +102,30 @@ def __init__(self, task_description: str, partial_matches: list[str] | None = No ) async def get_all_tasks( context: ToolContext, -) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases." + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results." + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: """ - Get all tasks from the Todoist API. Use this when the user wants to see, list, view, or + Get all tasks from the Todoist API with pagination support. Use this when the user wants to see, list, view, or browse all their existing tasks. For getting tasks from a specific project, use get_tasks_by_project_name or get_tasks_by_project_id instead. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. """ - - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint="tasks") - headers = get_headers(context=context) - - response = await client.get(url, headers=headers) - - response.raise_for_status() - - tasks = parse_tasks(response.json()["results"]) - - return {"tasks": tasks} + return await _get_tasks_with_pagination( + context=context, + limit=limit, + next_page_token=next_page_token + ) @tool( @@ -90,25 +137,30 @@ async def get_all_tasks( async def get_tasks_by_project_id( context: ToolContext, project_id: Annotated[str, "The ID of the project to get tasks from."], -) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases." + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results." + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: """ - Get tasks from a specific project by project ID. Use this when you already have the project ID. + Get tasks from a specific project by project ID with pagination support. Use this when you already have the project ID. For getting tasks by project name, use get_tasks_by_project_name instead. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. """ - - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint="tasks") - headers = get_headers(context=context) - - params = {"project_id": project_id} - - response = await client.get(url, headers=headers, params=params) - - response.raise_for_status() - - tasks = parse_tasks(response.json()["results"]) - - return {"tasks": tasks} + return await _get_tasks_with_pagination( + context=context, + limit=limit, + next_page_token=next_page_token, + project_id=project_id + ) @tool( @@ -120,10 +172,23 @@ async def get_tasks_by_project_id( async def get_tasks_by_project_name( context: ToolContext, project_name: Annotated[str, "The name of the project to get tasks from."], -) -> Annotated[dict, "The tasks object returned by the Todoist API."]: + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases." + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results." + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: """ - Get tasks from a specific project by project name. Use this when the user wants to see + Get tasks from a specific project by project name with pagination support. Use this when the user wants to see tasks from a specific project. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. """ projects = await get_projects(context=context) @@ -146,7 +211,12 @@ async def get_tasks_by_project_name( else: raise ProjectNotFoundError(project_name) - return await get_tasks_by_project_id(context=context, project_id=project_id) + return await get_tasks_by_project_id( + context=context, + project_id=project_id, + limit=limit, + next_page_token=next_page_token + ) @tool( diff --git a/toolkits/todoist/evals/eval_todoist.py b/toolkits/todoist/evals/eval_todoist.py index 630b4ca77..ee9885ef2 100644 --- a/toolkits/todoist/evals/eval_todoist.py +++ b/toolkits/todoist/evals/eval_todoist.py @@ -166,4 +166,31 @@ def todoist_eval_suite() -> EvalSuite: additional_messages=[], ) + # Pagination test cases + suite.add_case( + name="Getting limited number of all tasks", + user_message="Get only 10 of my tasks from across the board", + expected_tool_calls=[ExpectedToolCall(func=get_all_tasks, args={"limit": 10})], + rubric=rubric, + critics=[SimilarityCritic(critic_field="limit", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Getting limited tasks from specific project", + user_message="Show me only 5 tasks from the 'Work' project", + expected_tool_calls=[ + ExpectedToolCall( + func=get_tasks_by_project_name, + args={"project_name": "Work", "limit": 5} + ) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="project_name", weight=0.5), + SimilarityCritic(critic_field="limit", weight=0.5) + ], + additional_messages=[], + ) + return suite diff --git a/toolkits/todoist/tests/test_tasks.py b/toolkits/todoist/tests/test_tasks.py index 24cb7814e..f5d723291 100644 --- a/toolkits/todoist/tests/test_tasks.py +++ b/toolkits/todoist/tests/test_tasks.py @@ -29,7 +29,8 @@ "checked": True, "description": "Description of the task", } - ] + ], + "next_cursor": None } faked_parsed_tasks = { @@ -41,7 +42,36 @@ "project_id": "project id", "checked": True, } - ] + ], + "next_page_token": None +} + +fake_paginated_tasks_response = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project id", + "checked": True, + "description": "Description of the task", + } + ], + "next_cursor": "next_page_cursor_123" +} + +faked_parsed_paginated_tasks = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project id", + "checked": True, + } + ], + "next_page_token": "next_page_cursor_123" } fake_create_task_response = { @@ -228,7 +258,8 @@ async def test_delete_task_by_task_id_failure(tool_context, httpx_mock) -> None: "project_id": "project_123", "checked": False, }, - ] + ], + "next_page_token": None } @@ -399,7 +430,8 @@ async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None "checked": False, "description": "Description of the task", }, - ] + ], + "next_cursor": None } mock_response = MagicMock() @@ -426,7 +458,8 @@ async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None "project_id": "project_123", "checked": False, }, - ] + ], + "next_page_token": None } assert result == expected_filtered_tasks @@ -434,13 +467,13 @@ async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None # Verify the API was called with the correct query parameter call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"project_id": "project_123"} + assert call_args[1]["params"] == {"limit": 50, "project_id": "project_123"} @pytest.mark.asyncio async def test_get_tasks_by_project_id_empty_result(tool_context, httpx_mock) -> None: # Mock API response with no tasks for the project - empty_tasks_response = {"results": []} + empty_tasks_response = {"results": [], "next_cursor": None} mock_response = MagicMock() mock_response.status_code = 200 @@ -450,14 +483,14 @@ async def test_get_tasks_by_project_id_empty_result(tool_context, httpx_mock) -> result = await get_tasks_by_project_id(context=tool_context, project_id="empty_project") # Should return empty tasks list - expected_empty_result = {"tasks": []} + expected_empty_result = {"tasks": [], "next_page_token": None} assert result == expected_empty_result httpx_mock.get.assert_called_once() # Verify the API was called with the correct query parameter call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"project_id": "empty_project"} + assert call_args[1]["params"] == {"limit": 50, "project_id": "empty_project"} @pytest.mark.asyncio @@ -499,7 +532,8 @@ async def test_get_tasks_by_project_name_success(tool_context, mocker) -> None: "project_id": "project_123", "checked": False, }, - ] + ], + "next_page_token": None } mock_get_tasks_by_project_id = mocker.patch( "arcade_todoist.tools.tasks.get_tasks_by_project_id" @@ -511,7 +545,7 @@ async def test_get_tasks_by_project_name_success(tool_context, mocker) -> None: assert result == expected_filtered_tasks mock_get_projects.assert_called_once_with(context=tool_context) mock_get_tasks_by_project_id.assert_called_once_with( - context=tool_context, project_id="project_123" + context=tool_context, project_id="project_123", limit=50, next_page_token=None ) @@ -537,3 +571,180 @@ async def test_get_tasks_by_project_name_partial_match(tool_context, mocker) -> await get_tasks_by_project_name(context=tool_context, project_name="Work") assert "Project not found" in str(exc_info.value) + + +# Pagination-specific tests +@pytest.mark.asyncio +async def test_get_all_tasks_with_custom_limit(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context, limit=25) + + assert result == faked_parsed_tasks + httpx_mock.get.assert_called_once() + + # Verify the API was called with the correct limit parameter + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 25} + + +@pytest.mark.asyncio +async def test_get_all_tasks_with_pagination(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_paginated_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context, next_page_token="page_token_123") + + assert result == faked_parsed_paginated_tasks + httpx_mock.get.assert_called_once() + + # Verify the API was called with the cursor parameter + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 50, "cursor": "page_token_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_with_custom_limit(tool_context, httpx_mock) -> None: + project_tasks_response = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description", + }, + ], + "next_cursor": None + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = project_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_project_id( + context=tool_context, project_id="project_123", limit=100 + ) + + expected_result = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": None + } + + assert result == expected_result + httpx_mock.get.assert_called_once() + + # Verify the API was called with custom limit and project_id + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 100, "project_id": "project_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_with_pagination(tool_context, httpx_mock) -> None: + project_tasks_response = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description", + }, + ], + "next_cursor": "next_page_token_456" + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = project_tasks_response + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_project_id( + context=tool_context, + project_id="project_123", + limit=25, + next_page_token="previous_page_token" + ) + + expected_result = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": "next_page_token_456" + } + + assert result == expected_result + httpx_mock.get.assert_called_once() + + # Verify the API was called with all pagination parameters + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == { + "limit": 25, + "cursor": "previous_page_token", + "project_id": "project_123" + } + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_with_pagination(tool_context, mocker) -> None: + # Mock get_projects + mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") + mock_get_projects.return_value = fake_projects_response + + # Mock get_tasks_by_project_id + expected_result = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": "next_token_789" + } + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks.get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = expected_result + + result = await get_tasks_by_project_name( + context=tool_context, + project_name="Work Project", + limit=10, + next_page_token="some_token" + ) + + assert result == expected_result + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, + project_id="project_123", + limit=10, + next_page_token="some_token" + ) From 83e1969f1f7ace3a77c6bcb120f208cbaae0c148 Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Fri, 1 Aug 2025 12:29:50 -0400 Subject: [PATCH 3/6] Added get tasks by filter and reorganized code --- .../todoist/arcade_todoist/tools/tasks.py | 430 ---------- toolkits/todoist/arcade_todoist/utils.py | 83 -- .../{ => community}/.pre-commit-config.yaml | 0 toolkits/todoist/{ => community}/.ruff.toml | 0 toolkits/todoist/{ => community}/Makefile | 0 toolkits/todoist/{ => community}/README.md | 0 .../arcade_todoist/__init__.py | 0 .../community/arcade_todoist/errors.py | 59 ++ .../arcade_todoist/tools/__init__.py | 0 .../arcade_todoist/tools/projects.py | 0 .../community/arcade_todoist/tools/tasks.py | 332 ++++++++ .../todoist/community/arcade_todoist/utils.py | 265 +++++++ .../{ => community}/evals/eval_todoist.py | 116 ++- .../todoist/{ => community}/pyproject.toml | 2 +- .../todoist/{ => community}/tests/__init__.py | 0 .../todoist/{ => community}/tests/conftest.py | 0 toolkits/todoist/community/tests/fakes.py | 323 ++++++++ .../todoist/community/tests/test_projects.py | 20 + .../todoist/community/tests/test_tasks.py | 634 +++++++++++++++ toolkits/todoist/tests/test_projects.py | 38 - toolkits/todoist/tests/test_tasks.py | 750 ------------------ 21 files changed, 1712 insertions(+), 1340 deletions(-) delete mode 100644 toolkits/todoist/arcade_todoist/tools/tasks.py delete mode 100644 toolkits/todoist/arcade_todoist/utils.py rename toolkits/todoist/{ => community}/.pre-commit-config.yaml (100%) rename toolkits/todoist/{ => community}/.ruff.toml (100%) rename toolkits/todoist/{ => community}/Makefile (100%) rename toolkits/todoist/{ => community}/README.md (100%) rename toolkits/todoist/{ => community}/arcade_todoist/__init__.py (100%) create mode 100644 toolkits/todoist/community/arcade_todoist/errors.py rename toolkits/todoist/{ => community}/arcade_todoist/tools/__init__.py (100%) rename toolkits/todoist/{ => community}/arcade_todoist/tools/projects.py (100%) create mode 100644 toolkits/todoist/community/arcade_todoist/tools/tasks.py create mode 100644 toolkits/todoist/community/arcade_todoist/utils.py rename toolkits/todoist/{ => community}/evals/eval_todoist.py (57%) rename toolkits/todoist/{ => community}/pyproject.toml (98%) rename toolkits/todoist/{ => community}/tests/__init__.py (100%) rename toolkits/todoist/{ => community}/tests/conftest.py (100%) create mode 100644 toolkits/todoist/community/tests/fakes.py create mode 100644 toolkits/todoist/community/tests/test_projects.py create mode 100644 toolkits/todoist/community/tests/test_tasks.py delete mode 100644 toolkits/todoist/tests/test_projects.py delete mode 100644 toolkits/todoist/tests/test_tasks.py diff --git a/toolkits/todoist/arcade_todoist/tools/tasks.py b/toolkits/todoist/arcade_todoist/tools/tasks.py deleted file mode 100644 index 2ef84b1b4..000000000 --- a/toolkits/todoist/arcade_todoist/tools/tasks.py +++ /dev/null @@ -1,430 +0,0 @@ -from typing import Annotated - -import httpx -from arcade_tdk import ToolContext, tool -from arcade_tdk.auth import OAuth2 -from arcade_tdk.errors import ToolExecutionError - -from arcade_todoist.tools.projects import get_projects -from arcade_todoist.utils import get_headers, get_url, parse_task, parse_tasks - - -async def _get_tasks_with_pagination( - context: ToolContext, - limit: int = 50, - next_page_token: str | None = None, - project_id: str | None = None, -) -> dict: - """ - Utility function to get tasks with pagination support. - - Args: - context: ToolContext for API access - limit: Number of tasks to return (min: 1, default: 50, max: 200) - next_page_token: Token for pagination, use None for first page - project_id: Optional project ID to filter tasks by project - - Returns: - Dict containing tasks and next_page_token for pagination - """ - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint="tasks") - headers = get_headers(context=context) - - params = {"limit": limit} - if next_page_token: - params["cursor"] = next_page_token - if project_id: - params["project_id"] = project_id - - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - data = response.json() - tasks = parse_tasks(data["results"]) - next_cursor = data.get("next_cursor") - - return { - "tasks": tasks, - "next_page_token": next_cursor - } - - -class ProjectNotFoundError(ToolExecutionError): - """Raised when a project is not found.""" - - def __init__(self, project_name: str, partial_matches: list[str] | None = None): - if partial_matches: - matches_str = "', '".join(partial_matches) - super().__init__( - "Project not found", - developer_message=( - f"Project '{project_name}' not found, but found partial matches: " - f"{matches_str}. " - f"Please specify the exact project name." - ), - ) - else: - super().__init__( - "Project not found", - developer_message=( - f"Project '{project_name}' not found. " - f"Ask the user to create the project first." - ), - ) - - -class TaskNotFoundError(ToolExecutionError): - """Raised when a task is not found.""" - - def __init__(self, task_description: str, partial_matches: list[str] | None = None): - if partial_matches: - matches_str = "', '".join(partial_matches) - super().__init__( - "Task not found", - developer_message=( - f"Task '{task_description}' not found, but found partial matches: " - f"{matches_str}. " - f"Please specify the exact task description." - ), - ) - else: - super().__init__( - "Task not found", developer_message=f"Task '{task_description}' not found." - ) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read"], - ), -) -async def get_all_tasks( - context: ToolContext, - limit: Annotated[ - int, - "Number of tasks to return (min: 1, default: 50, max: 200). " - "Default is 50 which should be sufficient for most use cases." - ] = 50, - next_page_token: Annotated[ - str | None, - "Token for pagination. Use None for the first page, or the token returned " - "from a previous call to get the next page of results." - ] = None, -) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: - """ - Get all tasks from the Todoist API with pagination support. Use this when the user wants to see, list, view, or - browse all their existing tasks. For getting tasks from a specific project, use - get_tasks_by_project_name or get_tasks_by_project_id instead. - - The response includes both tasks and a next_page_token. If next_page_token is not None, - there are more tasks available and you can call this function again with that token. - """ - return await _get_tasks_with_pagination( - context=context, - limit=limit, - next_page_token=next_page_token - ) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read"], - ), -) -async def get_tasks_by_project_id( - context: ToolContext, - project_id: Annotated[str, "The ID of the project to get tasks from."], - limit: Annotated[ - int, - "Number of tasks to return (min: 1, default: 50, max: 200). " - "Default is 50 which should be sufficient for most use cases." - ] = 50, - next_page_token: Annotated[ - str | None, - "Token for pagination. Use None for the first page, or the token returned " - "from a previous call to get the next page of results." - ] = None, -) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: - """ - Get tasks from a specific project by project ID with pagination support. Use this when you already have the project ID. - For getting tasks by project name, use get_tasks_by_project_name instead. - - The response includes both tasks and a next_page_token. If next_page_token is not None, - there are more tasks available and you can call this function again with that token. - """ - return await _get_tasks_with_pagination( - context=context, - limit=limit, - next_page_token=next_page_token, - project_id=project_id - ) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read"], - ), -) -async def get_tasks_by_project_name( - context: ToolContext, - project_name: Annotated[str, "The name of the project to get tasks from."], - limit: Annotated[ - int, - "Number of tasks to return (min: 1, default: 50, max: 200). " - "Default is 50 which should be sufficient for most use cases." - ] = 50, - next_page_token: Annotated[ - str | None, - "Token for pagination. Use None for the first page, or the token returned " - "from a previous call to get the next page of results." - ] = None, -) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: - """ - Get tasks from a specific project by project name with pagination support. Use this when the user wants to see - tasks from a specific project. - - The response includes both tasks and a next_page_token. If next_page_token is not None, - there are more tasks available and you can call this function again with that token. - """ - - projects = await get_projects(context=context) - - project_id = None - - for project in projects["projects"]: - if project["name"].lower() == project_name.lower(): - project_id = project["id"] - break - - if project_id is None: - partial_matches = [] - for project in projects["projects"]: - if project_name.lower() in project["name"].lower(): - partial_matches.append(project["name"]) - - if partial_matches: - raise ProjectNotFoundError(project_name, partial_matches) - else: - raise ProjectNotFoundError(project_name) - - return await get_tasks_by_project_id( - context=context, - project_id=project_id, - limit=limit, - next_page_token=next_page_token - ) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def create_task_in_project( - context: ToolContext, - description: Annotated[str, "The title of the task to be created."], - project_id: Annotated[ - str | None, "The ID of the project to add the task to. Use None to add to inbox." - ], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Create a new task in a specific project by project ID. Use this when you already have the - project ID. For creating tasks by project name, use create_task instead. - """ - - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint="tasks") - headers = get_headers(context=context) - - response = await client.post( - url, - headers=headers, - json={ - "content": description, - "project_id": project_id, - }, - ) - - response.raise_for_status() - - task = parse_task(response.json()) - - return task - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def create_task( - context: ToolContext, - description: Annotated[str, "The title of the task to be created."], - project_name: Annotated[ - str | None, - "The name of the project to add the task to. Use the project name if user mentions a " - "specific project.", - ], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Create a new task for the user. Use this whenever the user wants to create, add, or make a task. - If the user mentions a specific project, pass the project name as project_name. - If no project is mentioned, leave project_name as None to add to inbox. - """ - - project_id = None - if project_name is not None: - projects = await get_projects(context=context) - - for project in projects["projects"]: - if project["name"].lower() == project_name.lower(): - project_id = project["id"] - break - - if project_id is None: - partial_matches = [] - for project in projects["projects"]: - if project_name.lower() in project["name"].lower(): - partial_matches.append(project["name"]) - - if partial_matches: - raise ProjectNotFoundError(project_name, partial_matches) - else: - raise ProjectNotFoundError(project_name) - - return await create_task_in_project( - context=context, description=description, project_id=project_id - ) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def close_task_by_task_id( - context: ToolContext, - task_id: Annotated[str, "The id of the task to be closed."], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Close a task by its ID. Use this when you already have the task ID. - For closing tasks by description/content, use close_task instead. - """ - - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint=f"tasks/{task_id}/close") - headers = get_headers(context=context) - - response = await client.post(url, headers=headers) - - response.raise_for_status() - - return {"message": "Task closed successfully"} - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def close_task( - context: ToolContext, - task_description: Annotated[str, "The description/content of the task to be closed."], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Close a task by its description/content. Use this whenever the user wants to mark a task as - completed, done, or closed. - """ - - tasks = await get_all_tasks(context=context) - - task_id = None - - for task in tasks["tasks"]: - if task["content"].lower() == task_description.lower(): - task_id = task["id"] - break - - if task_id is None: - partial_matches = [] - for task in tasks["tasks"]: - if task_description.lower() in task["content"].lower(): - partial_matches.append(task["content"]) - - if partial_matches: - raise TaskNotFoundError(task_description, partial_matches) - else: - raise TaskNotFoundError(task_description) - - return await close_task_by_task_id(context=context, task_id=task_id) - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def delete_task_by_task_id( - context: ToolContext, - task_id: Annotated[str, "The id of the task to be deleted."], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Delete a task by its ID. Use this when you already have the task ID. - For deleting tasks by description/content, use delete_task instead. - """ - - async with httpx.AsyncClient() as client: - url = get_url(context=context, endpoint=f"tasks/{task_id}") - headers = get_headers(context=context) - - response = await client.delete(url, headers=headers) - - response.raise_for_status() - - return {"message": "Task deleted successfully"} - - -@tool( - requires_auth=OAuth2( - id="todoist", - scopes=["data:read_write"], - ), -) -async def delete_task( - context: ToolContext, - task_description: Annotated[str, "The description/content of the task to be deleted."], -) -> Annotated[dict, "The task object returned by the Todoist API."]: - """ - Delete a task by its description/content. Use this whenever the user wants to delete a task. - """ - - tasks = await get_all_tasks(context=context) - - task_id = None - - for task in tasks["tasks"]: - if task["content"].lower() == task_description.lower(): - task_id = task["id"] - break - - if task_id is None: - partial_matches = [] - for task in tasks["tasks"]: - if task_description.lower() in task["content"].lower(): - partial_matches.append(task["content"]) - - if partial_matches: - raise TaskNotFoundError(task_description, partial_matches) - else: - raise TaskNotFoundError(task_description) - - return await delete_task_by_task_id(context=context, task_id=task_id) diff --git a/toolkits/todoist/arcade_todoist/utils.py b/toolkits/todoist/arcade_todoist/utils.py deleted file mode 100644 index 90bce6f0d..000000000 --- a/toolkits/todoist/arcade_todoist/utils.py +++ /dev/null @@ -1,83 +0,0 @@ -from typing import Any - -from arcade_tdk import ToolContext -from arcade_tdk.errors import ToolExecutionError - - -class TodoistAuthError(ToolExecutionError): - """Raised when Todoist authentication token is missing.""" - - def __init__(self): - super().__init__("No token found") - - -def get_headers(context: ToolContext) -> dict[str, str]: - """ - Build headers for the Todoist API requests. - """ - - token = context.get_auth_token_or_empty() - - if not token: - raise TodoistAuthError() - - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - -def get_url( - context: ToolContext, - endpoint: str, - api_version: str = "v1", -) -> str: - """ - Build the URL for the Todoist API request. - """ - - base_url = "https://api.todoist.com" - - return f"{base_url}/api/{api_version}/{endpoint}" - - -def parse_project(project: dict[str, Any]) -> dict[str, Any]: - """ - Parse the project object returned by the Todoist API. - """ - - return { - "id": project["id"], - "name": project["name"], - "created_at": project["created_at"], - } - - -def parse_projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Parse the projects object returned by the Todoist API. - """ - - return [parse_project(project) for project in projects] - - -def parse_task(task: dict[str, Any]) -> dict[str, Any]: - """ - Parse the task object returned by the Todoist API. - """ - - return { - "id": task["id"], - "content": task["content"], - "added_at": task["added_at"], - "checked": task["checked"], - "project_id": task["project_id"], - } - - -def parse_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Parse the tasks object returned by the Todoist API. - """ - - return [parse_task(task) for task in tasks] diff --git a/toolkits/todoist/.pre-commit-config.yaml b/toolkits/todoist/community/.pre-commit-config.yaml similarity index 100% rename from toolkits/todoist/.pre-commit-config.yaml rename to toolkits/todoist/community/.pre-commit-config.yaml diff --git a/toolkits/todoist/.ruff.toml b/toolkits/todoist/community/.ruff.toml similarity index 100% rename from toolkits/todoist/.ruff.toml rename to toolkits/todoist/community/.ruff.toml diff --git a/toolkits/todoist/Makefile b/toolkits/todoist/community/Makefile similarity index 100% rename from toolkits/todoist/Makefile rename to toolkits/todoist/community/Makefile diff --git a/toolkits/todoist/README.md b/toolkits/todoist/community/README.md similarity index 100% rename from toolkits/todoist/README.md rename to toolkits/todoist/community/README.md diff --git a/toolkits/todoist/arcade_todoist/__init__.py b/toolkits/todoist/community/arcade_todoist/__init__.py similarity index 100% rename from toolkits/todoist/arcade_todoist/__init__.py rename to toolkits/todoist/community/arcade_todoist/__init__.py diff --git a/toolkits/todoist/community/arcade_todoist/errors.py b/toolkits/todoist/community/arcade_todoist/errors.py new file mode 100644 index 000000000..21265ae84 --- /dev/null +++ b/toolkits/todoist/community/arcade_todoist/errors.py @@ -0,0 +1,59 @@ +from arcade_tdk.errors import ToolExecutionError + + +class ProjectNotFoundError(ToolExecutionError): + """Raised when a project is not found.""" + + def __init__(self, project_name: str, partial_matches: list[str] | None = None): + if partial_matches: + matches_str = "', '".join(partial_matches) + super().__init__( + "Project not found", + developer_message=( + f"Project '{project_name}' not found, but found partial matches: " + f"{matches_str}. " + f"Please specify the exact project name." + ), + ) + else: + super().__init__( + "Project not found", + developer_message=( + f"Project '{project_name}' not found. " + f"Ask the user to create the project first." + ), + ) + + +class TaskNotFoundError(ToolExecutionError): + """Raised when a task is not found.""" + + def __init__(self, task_description: str, partial_matches: list[str] | None = None): + if partial_matches: + matches_str = "', '".join(partial_matches) + super().__init__( + "Task not found", + developer_message=( + f"Task '{task_description}' not found, but found partial matches: " + f"{matches_str}. " + f"Please specify the exact task description." + ), + ) + else: + super().__init__( + "Task not found", developer_message=f"Task '{task_description}' not found." + ) + + +class MultipleTasksFoundError(ToolExecutionError): + """Raised when multiple tasks match the search criteria.""" + + def __init__(self, task_description: str, task_matches: list[dict]): + matches_str = "', '".join([task["content"] for task in task_matches]) + super().__init__( + "Multiple tasks found", + developer_message=( + f"Multiple tasks found for '{task_description}': '{matches_str}'. " + f"Please specify the exact task description to choose one." + ), + ) diff --git a/toolkits/todoist/arcade_todoist/tools/__init__.py b/toolkits/todoist/community/arcade_todoist/tools/__init__.py similarity index 100% rename from toolkits/todoist/arcade_todoist/tools/__init__.py rename to toolkits/todoist/community/arcade_todoist/tools/__init__.py diff --git a/toolkits/todoist/arcade_todoist/tools/projects.py b/toolkits/todoist/community/arcade_todoist/tools/projects.py similarity index 100% rename from toolkits/todoist/arcade_todoist/tools/projects.py rename to toolkits/todoist/community/arcade_todoist/tools/projects.py diff --git a/toolkits/todoist/community/arcade_todoist/tools/tasks.py b/toolkits/todoist/community/arcade_todoist/tools/tasks.py new file mode 100644 index 000000000..bb704dae0 --- /dev/null +++ b/toolkits/todoist/community/arcade_todoist/tools/tasks.py @@ -0,0 +1,332 @@ +from typing import Annotated + +import httpx +from arcade_tdk import ToolContext, tool +from arcade_tdk.auth import OAuth2 + +from arcade_todoist.utils import ( + get_headers, + get_tasks_with_pagination, + get_url, + parse_task, + parse_tasks, + resolve_project_id, + resolve_task_id, +) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def get_all_tasks( + context: ToolContext, + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases.", + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results.", + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: + """ + Get all tasks from the Todoist API with pagination support. Use this when the user wants + to see, list, view, or browse ALL their existing tasks. For getting tasks from a specific + project, use get_tasks_by_project instead. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. + """ + return await get_tasks_with_pagination( + context=context, limit=limit, next_page_token=next_page_token + ) + + +async def _get_tasks_by_project_id( + context: ToolContext, + project_id: Annotated[str, "The ID of the project to get tasks from."], + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases.", + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results.", + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: + """ + Internal utility function to get tasks from a specific project by project ID with + pagination support. + + Args: + context: ToolContext for API access + project_id: The ID of the project to get tasks from + limit: Number of tasks to return (min: 1, default: 50, max: 200) + next_page_token: Token for pagination, use None for first page + + Returns: + Dict containing tasks and next_page_token for pagination + """ + return await get_tasks_with_pagination( + context=context, limit=limit, next_page_token=next_page_token, project_id=project_id + ) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def get_tasks_by_project( + context: ToolContext, + project: Annotated[str, "The ID or name of the project to get tasks from."], + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases.", + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results.", + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: + """ + Get tasks from a specific project by project ID or name with pagination support. + Use this when the user wants to see tasks from a specific project. + + The function will first try to find a project with the given ID, and if that doesn't exist, + it will search for a project with the given name. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. + """ + project_id = await resolve_project_id(context=context, project=project) + + return await _get_tasks_by_project_id( + context=context, project_id=project_id, limit=limit, next_page_token=next_page_token + ) + + +async def _create_task_in_project( + context: ToolContext, + description: Annotated[str, "The title of the task to be created."], + project_id: Annotated[ + str | None, "The ID of the project to add the task to. Use None to add to inbox." + ], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Internal utility function to create a new task in a specific project by project ID. + + Args: + context: ToolContext for API access + description: The title of the task to be created + project_id: The ID of the project to add the task to, use None to add to inbox + + Returns: + Dict containing the created task object + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + response = await client.post( + url, + headers=headers, + json={ + "content": description, + "project_id": project_id, + }, + ) + + response.raise_for_status() + + task = parse_task(response.json()) + + return task + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def create_task( + context: ToolContext, + description: Annotated[str, "The title of the task to be created."], + project: Annotated[ + str | None, + "The ID or name of the project to add the task to. Use the project ID or name if " + "user mentions a specific project. Leave as None to add to inbox.", + ] = None, +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Create a new task for the user. Use this whenever the user wants to create, add, or make a task. + If the user mentions a specific project, pass the project ID or name as project. + If no project is mentioned, leave project as None to add to inbox. + + The function will first try to find a project with the given ID, and if that doesn't exist, + it will search for a project with the given name. + """ + + project_id = None + if project is not None: + project_id = await resolve_project_id(context=context, project=project) + + return await _create_task_in_project( + context=context, description=description, project_id=project_id + ) + + +async def _close_task_by_task_id( + context: ToolContext, + task_id: Annotated[str, "The id of the task to be closed."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Internal utility function to close a task by its ID. + + Args: + context: ToolContext for API access + task_id: The ID of the task to be closed + + Returns: + Dict with success message + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint=f"tasks/{task_id}/close") + headers = get_headers(context=context) + + response = await client.post(url, headers=headers) + + response.raise_for_status() + + return {"message": "Task closed successfully"} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def close_task( + context: ToolContext, + task: Annotated[str, "The ID or description/content of the task to be closed."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Close a task by its ID or description/content. Use this whenever the user wants to + mark a task as completed, done, or closed. + + The function will first try to find a task with the given ID, and if that doesn't exist, + it will search for a task with the given description/content. + """ + + task_id = await resolve_task_id(context=context, task=task) + return await _close_task_by_task_id(context=context, task_id=task_id) + + +async def _delete_task_by_task_id( + context: ToolContext, + task_id: Annotated[str, "The id of the task to be deleted."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Internal utility function to delete a task by its ID. + + Args: + context: ToolContext for API access + task_id: The ID of the task to be deleted + + Returns: + Dict with success message + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint=f"tasks/{task_id}") + headers = get_headers(context=context) + + response = await client.delete(url, headers=headers) + + response.raise_for_status() + + return {"message": "Task deleted successfully"} + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def delete_task( + context: ToolContext, + task: Annotated[str, "The ID or description/content of the task to be deleted."], +) -> Annotated[dict, "The task object returned by the Todoist API."]: + """ + Delete a task by its ID or description/content. Use this whenever the user wants to + delete a task. + + The function will first try to find a task with the given ID, and if that doesn't exist, + it will search for a task with the given description/content. + """ + + task_id = await resolve_task_id(context=context, task=task) + return await _delete_task_by_task_id(context=context, task_id=task_id) + + +@tool( + requires_auth=OAuth2( + id="todoist", + scopes=["data:read_write"], + ), +) +async def get_tasks_by_filter( + context: ToolContext, + filter_query: Annotated[ + str, + "The filter query to search tasks.", + ], + limit: Annotated[ + int, + "Number of tasks to return (min: 1, default: 50, max: 200). " + "Default is 50 which should be sufficient for most use cases.", + ] = 50, + next_page_token: Annotated[ + str | None, + "Token for pagination. Use None for the first page, or the token returned " + "from a previous call to get the next page of results.", + ] = None, +) -> Annotated[dict, "The tasks object with pagination info returned by the Todoist API."]: + """ + Get tasks by filter query with pagination support. + Use this when the user wants to search for specific tasks. + + The response includes both tasks and a next_page_token. If next_page_token is not None, + there are more tasks available and you can call this function again with that token. + """ + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks/filter") + headers = get_headers(context=context) + + params = {"query": filter_query, "limit": limit} + if next_page_token: + params["cursor"] = next_page_token + + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + tasks = parse_tasks(data["results"]) + next_cursor = data.get("next_cursor") + + return {"tasks": tasks, "next_page_token": next_cursor} diff --git a/toolkits/todoist/community/arcade_todoist/utils.py b/toolkits/todoist/community/arcade_todoist/utils.py new file mode 100644 index 000000000..26cbe13ab --- /dev/null +++ b/toolkits/todoist/community/arcade_todoist/utils.py @@ -0,0 +1,265 @@ +from typing import Any + +import httpx +from arcade_tdk import ToolContext +from arcade_tdk.errors import ToolExecutionError + +from arcade_todoist.errors import MultipleTasksFoundError, ProjectNotFoundError, TaskNotFoundError + + +class TodoistAuthError(ToolExecutionError): + """Raised when Todoist authentication token is missing.""" + + def __init__(self): + super().__init__("No token found") + + +def get_headers(context: ToolContext) -> dict[str, str]: + """ + Build headers for the Todoist API requests. + """ + + token = context.get_auth_token_or_empty() + + if not token: + raise TodoistAuthError() + + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def get_url( + context: ToolContext, + endpoint: str, + api_version: str = "v1", +) -> str: + """ + Build the URL for the Todoist API request. + """ + + base_url = "https://api.todoist.com" + + return f"{base_url}/api/{api_version}/{endpoint}" + + +def parse_project(project: dict[str, Any]) -> dict[str, Any]: + """ + Parse the project object returned by the Todoist API. + """ + + return { + "id": project["id"], + "name": project["name"], + "created_at": project["created_at"], + } + + +def parse_projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Parse the projects object returned by the Todoist API. + """ + + return [parse_project(project) for project in projects] + + +def parse_task(task: dict[str, Any]) -> dict[str, Any]: + """ + Parse the task object returned by the Todoist API. + """ + + return { + "id": task["id"], + "content": task["content"], + "added_at": task["added_at"], + "checked": task["checked"], + "project_id": task["project_id"], + } + + +def parse_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Parse the tasks object returned by the Todoist API. + """ + + return [parse_task(task) for task in tasks] + + +async def get_tasks_with_pagination( + context: ToolContext, + limit: int = 50, + next_page_token: str | None = None, + project_id: str | None = None, +) -> dict: + """ + Utility function to get tasks with pagination support. + + Args: + context: ToolContext for API access + limit: Number of tasks to return (min: 1, default: 50, max: 200) + next_page_token: Token for pagination, use None for first page + project_id: Optional project ID to filter tasks by project + + Returns: + Dict containing tasks and next_page_token for pagination + """ + + async with httpx.AsyncClient() as client: + url = get_url(context=context, endpoint="tasks") + headers = get_headers(context=context) + + params = {"limit": limit} + if next_page_token: + params["cursor"] = next_page_token + if project_id: + params["project_id"] = project_id + + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + tasks = parse_tasks(data["results"]) + next_cursor = data.get("next_cursor") + + return {"tasks": tasks, "next_page_token": next_cursor} + + +async def resolve_project_id(context: ToolContext, project: str) -> str: + """ + Utility function to resolve a project identifier to project ID. + + Args: + context: ToolContext for API access + project: Project ID or name to resolve + + Returns: + The project ID + + Raises: + ProjectNotFoundError: If the project is not found + """ + from arcade_todoist.tools.projects import get_projects + + projects = await get_projects(context=context) + + for proj in projects["projects"]: + if proj["id"] == project: + return project + + for proj in projects["projects"]: + if proj["name"].lower() == project.lower(): + return proj["id"] + + partial_matches = [] + for proj in projects["projects"]: + if project.lower() in proj["name"].lower(): + partial_matches.append(proj["name"]) + + if partial_matches: + raise ProjectNotFoundError(project, partial_matches) + else: + raise ProjectNotFoundError(project) + + +def _check_exact_id_match(tasks: list[dict], task: str) -> str | None: + """Check if task matches any task ID exactly.""" + for task_obj in tasks: + if task_obj["id"] == task: + return task + return None + + +def _find_exact_content_matches(tasks: list[dict], task: str) -> list[dict]: + """Find tasks with exact content match (case-insensitive).""" + return [task_obj for task_obj in tasks if task_obj["content"].lower() == task.lower()] + + +def _find_partial_content_matches(tasks: list[dict], task: str) -> list[dict]: + """Find tasks with partial content match (case-insensitive).""" + return [task_obj for task_obj in tasks if task.lower() in task_obj["content"].lower()] + + +def _handle_task_matches(task: str, exact_matches: list[dict], partial_matches: list[dict]) -> str: + """Handle task matching logic and raise appropriate errors.""" + if len(exact_matches) == 1: + return exact_matches[0]["id"] + elif len(exact_matches) > 1: + _raise_multiple_tasks_error(task, exact_matches) + + if len(partial_matches) == 1: + return partial_matches[0]["id"] + elif len(partial_matches) > 1: + _raise_multiple_tasks_error(task, partial_matches) + + # If we have partial matches but multiple, convert to content strings for error + if partial_matches: + partial_match_contents = [task_obj["content"] for task_obj in partial_matches] + _raise_task_not_found_error(task, partial_match_contents) + + _raise_task_not_found_error(task) + + +def _raise_multiple_tasks_error(task: str, matches: list[dict]) -> None: + """Raise MultipleTasksFoundError.""" + raise MultipleTasksFoundError(task, matches) + + +def _raise_task_not_found_error(task: str, suggestions: list[str] | None = None) -> None: + """Raise TaskNotFoundError.""" + if suggestions: + raise TaskNotFoundError(task, suggestions) + raise TaskNotFoundError(task) + + +def _resolve_task_from_task_list(tasks: list[dict], task: str) -> str: + """Resolve task ID from a list of tasks.""" + # Check for exact ID match first + exact_id = _check_exact_id_match(tasks, task) + if exact_id: + return exact_id + + # Check for exact and partial content matches + exact_matches = _find_exact_content_matches(tasks, task) + partial_matches = _find_partial_content_matches(tasks, task) + + return _handle_task_matches(task, exact_matches, partial_matches) + + +async def resolve_task_id(context: ToolContext, task: str) -> str: + """ + Utility function to resolve a task identifier to task ID. + + Args: + context: ToolContext for API access + task: Task ID or description/content to resolve + + Returns: + The task ID + + Raises: + TaskNotFoundError: If the task is not found + MultipleTasksFoundError: If multiple tasks match the criteria + """ + from arcade_todoist.tools.tasks import get_tasks_by_filter + + try: + tasks = await get_tasks_by_filter(context=context, filter_query=f"search: {task}") + return _resolve_task_from_task_list(tasks["tasks"], task) + + except (TaskNotFoundError, MultipleTasksFoundError): + # Re-raise these specific errors + raise + except Exception as err: + # If search filter fails, fall back to getting all tasks + try: + from arcade_todoist.tools.tasks import get_all_tasks + + tasks = await get_all_tasks(context=context) + return _resolve_task_from_task_list(tasks["tasks"], task) + except (TaskNotFoundError, MultipleTasksFoundError): + # Re-raise these specific errors from the fallback + raise + except Exception: + # If both methods fail, raise the original error + raise TaskNotFoundError(task) from err diff --git a/toolkits/todoist/evals/eval_todoist.py b/toolkits/todoist/community/evals/eval_todoist.py similarity index 57% rename from toolkits/todoist/evals/eval_todoist.py rename to toolkits/todoist/community/evals/eval_todoist.py index ee9885ef2..bbfb9cca0 100644 --- a/toolkits/todoist/evals/eval_todoist.py +++ b/toolkits/todoist/community/evals/eval_todoist.py @@ -4,20 +4,18 @@ ExpectedToolCall, tool_eval, ) -from arcade_evals.critic import SimilarityCritic +from arcade_evals.critic import BinaryCritic, SimilarityCritic from arcade_tdk import ToolCatalog import arcade_todoist from arcade_todoist.tools.projects import get_projects from arcade_todoist.tools.tasks import ( close_task, - close_task_by_task_id, create_task, delete_task, - delete_task_by_task_id, get_all_tasks, - get_tasks_by_project_id, - get_tasks_by_project_name, + get_tasks_by_filter, + get_tasks_by_project, ) rubric = EvalRubric( @@ -63,10 +61,10 @@ def todoist_eval_suite() -> EvalSuite: name="Getting tasks from a specific project with project id", user_message="What are my tasks in the project with id '12345'?", expected_tool_calls=[ - ExpectedToolCall(func=get_tasks_by_project_id, args={"project_id": "12345"}) + ExpectedToolCall(func=get_tasks_by_project, args={"project": "12345"}) ], rubric=rubric, - critics=[SimilarityCritic(critic_field="project_id", weight=1)], + critics=[SimilarityCritic(critic_field="project", weight=1)], additional_messages=[], ) @@ -74,10 +72,10 @@ def todoist_eval_suite() -> EvalSuite: name="Getting tasks from a specific project with project name", user_message="What do I have left to do in the 'Personal' project?", expected_tool_calls=[ - ExpectedToolCall(func=get_tasks_by_project_name, args={"project_name": "Personal"}) + ExpectedToolCall(func=get_tasks_by_project, args={"project": "Personal"}) ], rubric=rubric, - critics=[SimilarityCritic(critic_field="project_name", weight=1)], + critics=[SimilarityCritic(critic_field="project", weight=1)], additional_messages=[], ) @@ -86,7 +84,7 @@ def todoist_eval_suite() -> EvalSuite: user_message="Hey! create a task to 'Buy groceries'", expected_tool_calls=[ ExpectedToolCall( - func=create_task, args={"description": "Buy groceries", "project_id": None} + func=create_task, args={"description": "Buy groceries", "project": None} ) ], rubric=rubric, @@ -100,13 +98,13 @@ def todoist_eval_suite() -> EvalSuite: expected_tool_calls=[ ExpectedToolCall( func=create_task, - args={"description": "Check the email", "project_name": "Personal"}, + args={"description": "Check the email", "project": "Personal"}, ) ], rubric=rubric, critics=[ SimilarityCritic(critic_field="description", weight=0.5), - SimilarityCritic(critic_field="project_name", weight=0.5), + SimilarityCritic(critic_field="project", weight=0.5), ], additional_messages=[], ) @@ -114,81 +112,123 @@ def todoist_eval_suite() -> EvalSuite: suite.add_case( name="Close a task by ID", user_message="Mark task with ID '12345' as completed", - expected_tool_calls=[ - ExpectedToolCall(func=close_task_by_task_id, args={"task_id": "12345"}) - ], + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "12345"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task_id", weight=1)], + critics=[SimilarityCritic(critic_field="task", weight=1)], additional_messages=[], ) suite.add_case( name="Close a task by Name", user_message="I'm done with the task 'Buy groceries'", - expected_tool_calls=[ - ExpectedToolCall(func=close_task, args={"task_description": "Buy groceries"}) - ], + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "Buy groceries"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task_description", weight=1)], + critics=[SimilarityCritic(critic_field="task", weight=1)], additional_messages=[], ) suite.add_case( name="Complete a task by id", user_message="Please close task with id abc123, I finished it", - expected_tool_calls=[ - ExpectedToolCall(func=close_task_by_task_id, args={"task_id": "abc123"}) - ], + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "abc123"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task_id", weight=1)], + critics=[SimilarityCritic(critic_field="task", weight=1)], additional_messages=[], ) suite.add_case( name="Delete a task by ID", user_message="Delete task with ID 'task_456'", - expected_tool_calls=[ - ExpectedToolCall(func=delete_task_by_task_id, args={"task_id": "task_456"}) - ], + expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task": "task_456"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task_id", weight=1)], + critics=[SimilarityCritic(critic_field="task", weight=1)], additional_messages=[], ) suite.add_case( name="Remove a task by name", user_message="I want to remove task Wash car completely", - expected_tool_calls=[ - ExpectedToolCall(func=delete_task, args={"task_description": "Wash car"}) - ], + expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task": "Wash car"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task_description", weight=1)], + critics=[SimilarityCritic(critic_field="task", weight=1)], additional_messages=[], ) - # Pagination test cases suite.add_case( name="Getting limited number of all tasks", user_message="Get only 10 of my tasks from across the board", expected_tool_calls=[ExpectedToolCall(func=get_all_tasks, args={"limit": 10})], rubric=rubric, - critics=[SimilarityCritic(critic_field="limit", weight=1)], + critics=[BinaryCritic(critic_field="limit", weight=1)], additional_messages=[], ) suite.add_case( name="Getting limited tasks from specific project", user_message="Show me only 5 tasks from the 'Work' project", + expected_tool_calls=[ + ExpectedToolCall(func=get_tasks_by_project, args={"project": "Work", "limit": 5}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="project", weight=0.5), + BinaryCritic(critic_field="limit", weight=0.5), + ], + additional_messages=[], + ) + + suite.add_case( + name="Search tasks using filter query", + user_message="Use filter search to find all tasks that contain the word 'meeting'", + expected_tool_calls=[ + ExpectedToolCall(func=get_tasks_by_filter, args={"filter_query": "meeting"}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="filter_query", weight=1), + ], + additional_messages=[], + ) + + suite.add_case( + name="Search tasks with project filter", + user_message="Use the filter search to find tasks in project 'Work' that contain 'report'", + expected_tool_calls=[ + ExpectedToolCall( + func=get_tasks_by_filter, args={"filter_query": "#Work & search:report"} + ) + ], + rubric=rubric, + critics=[SimilarityCritic(critic_field="filter_query", weight=1)], + additional_messages=[], + ) + + suite.add_case( + name="Search tasks with limit", + user_message="Use filter search to find the first 3 tasks that contain 'urgent'", + expected_tool_calls=[ + ExpectedToolCall(func=get_tasks_by_filter, args={"filter_query": "urgent", "limit": 3}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="filter_query", weight=0.5), + BinaryCritic(critic_field="limit", weight=0.5), + ], + additional_messages=[], + ) + + suite.add_case( + name="Create task with project ID", + user_message="Create a task 'Review documents' in project with ID 'proj_123'", expected_tool_calls=[ ExpectedToolCall( - func=get_tasks_by_project_name, - args={"project_name": "Work", "limit": 5} + func=create_task, args={"description": "Review documents", "project": "proj_123"} ) ], rubric=rubric, critics=[ - SimilarityCritic(critic_field="project_name", weight=0.5), - SimilarityCritic(critic_field="limit", weight=0.5) + SimilarityCritic(critic_field="description", weight=0.6), + SimilarityCritic(critic_field="project", weight=0.4), ], additional_messages=[], ) diff --git a/toolkits/todoist/pyproject.toml b/toolkits/todoist/community/pyproject.toml similarity index 98% rename from toolkits/todoist/pyproject.toml rename to toolkits/todoist/community/pyproject.toml index 0aefa1559..62add1957 100644 --- a/toolkits/todoist/pyproject.toml +++ b/toolkits/todoist/community/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "arcade_todoist" -version = "0.1.0" +version = "0.1.1" description = "Allow agent to connect and interact with Todoist" requires-python = ">=3.10" dependencies = [ diff --git a/toolkits/todoist/tests/__init__.py b/toolkits/todoist/community/tests/__init__.py similarity index 100% rename from toolkits/todoist/tests/__init__.py rename to toolkits/todoist/community/tests/__init__.py diff --git a/toolkits/todoist/tests/conftest.py b/toolkits/todoist/community/tests/conftest.py similarity index 100% rename from toolkits/todoist/tests/conftest.py rename to toolkits/todoist/community/tests/conftest.py diff --git a/toolkits/todoist/community/tests/fakes.py b/toolkits/todoist/community/tests/fakes.py new file mode 100644 index 000000000..140e1ce2b --- /dev/null +++ b/toolkits/todoist/community/tests/fakes.py @@ -0,0 +1,323 @@ +""" +This module contains all mock data used across the test suite, organized by category: +- API response formats (what Todoist API returns) +- Parsed response formats (what our functions return after processing) +- Test scenario specific data +""" + +PROJECTS_API_RESPONSE = { + "results": [ + { + "id": "project_123", + "name": "Work Project", + "created_at": "2021-01-01", + "can_assign_tasks": True, + "child_order": 0, + "color": "blue", + "creator_uid": "user_123", + "is_archived": False, + "is_deleted": False, + "is_favorite": True, + }, + { + "id": "project_456", + "name": "Personal Tasks", + "created_at": "2021-01-01", + "can_assign_tasks": True, + "child_order": 1, + "color": "red", + "creator_uid": "user_123", + "is_archived": False, + "is_deleted": False, + "is_favorite": False, + }, + ] +} + +PROJECTS_PARSED_RESPONSE = { + "projects": [ + {"id": "project_123", "name": "Work Project", "created_at": "2021-01-01"}, + {"id": "project_456", "name": "Personal Tasks", "created_at": "2021-01-01"}, + ] +} + +SINGLE_TASK_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": True, + "description": "Description of the task", + } + ], + "next_cursor": None, +} + +TASKS_WITH_PAGINATION_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": True, + "description": "Description of the task", + } + ], + "next_cursor": "next_page_cursor_123", +} + +MULTIPLE_TASKS_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Need to buy weekly groceries", + }, + { + "id": "2", + "content": "Grocery shopping", + "added_at": "2021-01-01", + "priority": 2, + "project_id": "project_456", + "checked": False, + "description": "Similar to grocery task", + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Take notes during meeting", + }, + ], + "next_cursor": None, +} + +PROJECT_SPECIFIC_TASKS_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Need to buy weekly groceries", + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Take notes during meeting", + }, + ], + "next_cursor": None, +} + +EMPTY_TASKS_API_RESPONSE = { + "results": [], + "next_cursor": None, +} + +CREATE_TASK_API_RESPONSE = { + "id": "2", + "content": "New Task", + "added_at": "2024-01-01", + "project_id": "project_123", + "checked": False, + "priority": 1, + "description": "A new task description", +} + +CUSTOM_LIMIT_TASK_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description", + }, + ], + "next_cursor": None, +} + +PAGINATED_TASKS_API_RESPONSE = { + "results": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "priority": 1, + "project_id": "project_123", + "checked": False, + "description": "Description", + }, + ], + "next_cursor": "next_page_token_456", +} + +SINGLE_TASK_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": True, + } + ], + "next_page_token": None, +} + +TASKS_WITH_PAGINATION_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": True, + } + ], + "next_page_token": "next_page_cursor_123", +} + +MULTIPLE_TASKS_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "2", + "content": "Grocery shopping", + "added_at": "2021-01-01", + "project_id": "project_456", + "checked": False, + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": None, +} + +PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Buy groceries", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": None, +} + +EMPTY_TASKS_PARSED_RESPONSE = { + "tasks": [], + "next_page_token": None, +} + +CREATE_TASK_PARSED_RESPONSE = { + "id": "2", + "content": "New Task", + "added_at": "2024-01-01", + "project_id": "project_123", + "checked": False, +} + +CUSTOM_LIMIT_TASK_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": None, +} + +PAGINATED_TASKS_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Task 1", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": "next_page_token_456", +} + +PARTIAL_MATCH_TASKS_PARSED_RESPONSE = { + "tasks": [ + { + "id": "1", + "content": "Complete task A", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + { + "id": "2", + "content": "Complete task B", + "added_at": "2021-01-01", + "project_id": "project_456", + "checked": False, + }, + ], + "next_page_token": None, +} + +SINGLE_MATCH_TASK_PARSED_RESPONSE = { + "tasks": [ + { + "id": "3", + "content": "Meeting notes", + "added_at": "2021-01-01", + "project_id": "project_123", + "checked": False, + }, + ], + "next_page_token": None, +} + +CLOSE_TASK_SUCCESS_RESPONSE = {"message": "Task closed successfully"} + +DELETE_TASK_SUCCESS_RESPONSE = {"message": "Task deleted successfully"} diff --git a/toolkits/todoist/community/tests/test_projects.py b/toolkits/todoist/community/tests/test_projects.py new file mode 100644 index 000000000..a6c25ca45 --- /dev/null +++ b/toolkits/todoist/community/tests/test_projects.py @@ -0,0 +1,20 @@ +from unittest.mock import MagicMock + +import pytest + +from arcade_todoist.tools.projects import get_projects +from tests.fakes import PROJECTS_API_RESPONSE, PROJECTS_PARSED_RESPONSE + + +@pytest.mark.asyncio +async def test_get_projects_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = PROJECTS_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_projects(context=tool_context) + + assert result == PROJECTS_PARSED_RESPONSE + + httpx_mock.get.assert_called_once() diff --git a/toolkits/todoist/community/tests/test_tasks.py b/toolkits/todoist/community/tests/test_tasks.py new file mode 100644 index 000000000..c1521e6ce --- /dev/null +++ b/toolkits/todoist/community/tests/test_tasks.py @@ -0,0 +1,634 @@ +from unittest.mock import MagicMock + +import httpx +import pytest +from arcade_tdk.errors import ToolExecutionError + +from arcade_todoist.errors import ProjectNotFoundError, TaskNotFoundError +from arcade_todoist.tools.tasks import ( + _close_task_by_task_id, + _create_task_in_project, + _delete_task_by_task_id, + _get_tasks_by_project_id, + close_task, + create_task, + delete_task, + get_all_tasks, + get_tasks_by_filter, + get_tasks_by_project, +) +from tests.fakes import ( + CLOSE_TASK_SUCCESS_RESPONSE, + CREATE_TASK_API_RESPONSE, + CREATE_TASK_PARSED_RESPONSE, + CUSTOM_LIMIT_TASK_API_RESPONSE, + CUSTOM_LIMIT_TASK_PARSED_RESPONSE, + DELETE_TASK_SUCCESS_RESPONSE, + EMPTY_TASKS_API_RESPONSE, + EMPTY_TASKS_PARSED_RESPONSE, + MULTIPLE_TASKS_PARSED_RESPONSE, + PAGINATED_TASKS_API_RESPONSE, + PAGINATED_TASKS_PARSED_RESPONSE, + PARTIAL_MATCH_TASKS_PARSED_RESPONSE, + PROJECT_SPECIFIC_TASKS_API_RESPONSE, + PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE, + PROJECTS_PARSED_RESPONSE, + SINGLE_MATCH_TASK_PARSED_RESPONSE, + SINGLE_TASK_API_RESPONSE, + SINGLE_TASK_PARSED_RESPONSE, + TASKS_WITH_PAGINATION_API_RESPONSE, + TASKS_WITH_PAGINATION_PARSED_RESPONSE, +) + + +@pytest.mark.asyncio +async def test_get_all_tasks_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = SINGLE_TASK_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context) + + assert result == SINGLE_TASK_PARSED_RESPONSE + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_all_tasks_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response + ) + httpx_mock.get.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await get_all_tasks(context=tool_context) + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_task_in_project_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = CREATE_TASK_API_RESPONSE + httpx_mock.post.return_value = mock_response + + result = await _create_task_in_project( + context=tool_context, description="New Task", project_id="project_123" + ) + + assert result == CREATE_TASK_PARSED_RESPONSE + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_task_in_project_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Bad Request", + request=httpx.Request("POST", "http://test.com"), + response=mock_response, + ) + httpx_mock.post.return_value = mock_response + + with pytest.raises(httpx.HTTPStatusError): + await _create_task_in_project( + context=tool_context, description="New Task", project_id="project_123" + ) + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_close_task_by_task_id_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + httpx_mock.post.return_value = mock_response + + result = await _close_task_by_task_id(context=tool_context, task_id="task_123") + + assert result == CLOSE_TASK_SUCCESS_RESPONSE + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_close_task_by_task_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", + request=httpx.Request("POST", "http://test.com"), + response=mock_response, + ) + httpx_mock.post.return_value = mock_response + + with pytest.raises(httpx.HTTPStatusError): + await _close_task_by_task_id(context=tool_context, task_id="task_123") + + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_task_by_task_id_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + httpx_mock.delete.return_value = mock_response + + result = await _delete_task_by_task_id(context=tool_context, task_id="task_123") + + assert result == DELETE_TASK_SUCCESS_RESPONSE + + httpx_mock.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_task_by_task_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", + request=httpx.Request("DELETE", "http://test.com"), + response=mock_response, + ) + httpx_mock.delete.return_value = mock_response + + with pytest.raises(httpx.HTTPStatusError): + await _delete_task_by_task_id(context=tool_context, task_id="task_123") + + httpx_mock.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_task_success_exact_project_match(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks._create_task_in_project") + mock_create_task_in_project.return_value = CREATE_TASK_PARSED_RESPONSE + + result = await create_task(context=tool_context, description="New Task", project="Work Project") + + assert result == CREATE_TASK_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_create_task_in_project.assert_called_once_with( + context=tool_context, description="New Task", project_id="project_123" + ) + + +@pytest.mark.asyncio +async def test_create_task_success_no_project(tool_context, mocker) -> None: + mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks._create_task_in_project") + mock_create_task_in_project.return_value = CREATE_TASK_PARSED_RESPONSE + + result = await create_task(context=tool_context, description="New Task", project=None) + + assert result == CREATE_TASK_PARSED_RESPONSE + mock_create_task_in_project.assert_called_once_with( + context=tool_context, description="New Task", project_id=None + ) + + +@pytest.mark.asyncio +async def test_create_task_project_not_found(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + with pytest.raises(ProjectNotFoundError) as exc_info: + await create_task( + context=tool_context, description="New Task", project="Nonexistent Project" + ) + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_close_task_success_exact_match(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE + + mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._close_task_by_task_id") + mock_close_task_by_task_id.return_value = CLOSE_TASK_SUCCESS_RESPONSE + + result = await close_task(context=tool_context, task="Buy groceries") + + assert result == CLOSE_TASK_SUCCESS_RESPONSE + mock_get_tasks_by_filter.assert_called_once_with( + context=tool_context, filter_query="search: Buy groceries" + ) + mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") + + +@pytest.mark.asyncio +async def test_close_task_not_found(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = {"tasks": [], "next_page_token": None} + + with pytest.raises(TaskNotFoundError) as exc_info: + await close_task(context=tool_context, task="Nonexistent task") + + assert "Task not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_close_task_partial_match(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = PARTIAL_MATCH_TASKS_PARSED_RESPONSE + + with pytest.raises(ToolExecutionError) as exc_info: + await close_task(context=tool_context, task="task") + + error = exc_info.value + error_text = str(error) + if hasattr(error, "developer_message") and error.developer_message: + error_text += " " + error.developer_message + + if error.__cause__: + error_text += " " + str(error.__cause__) + + assert "Multiple tasks found" in error_text + + +@pytest.mark.asyncio +async def test_delete_task_success_exact_match(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE + + mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") + mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE + + result = await delete_task(context=tool_context, task="Meeting notes") + + assert result == DELETE_TASK_SUCCESS_RESPONSE + mock_get_tasks_by_filter.assert_called_once_with( + context=tool_context, filter_query="search: Meeting notes" + ) + mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") + + +@pytest.mark.asyncio +async def test_delete_task_not_found(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = {"tasks": [], "next_page_token": None} + + with pytest.raises(TaskNotFoundError) as exc_info: + await delete_task(context=tool_context, task="Nonexistent task") + + assert "Task not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_delete_task_partial_match(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = SINGLE_MATCH_TASK_PARSED_RESPONSE + + mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") + mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE + + result = await delete_task(context=tool_context, task="notes") + + assert result == DELETE_TASK_SUCCESS_RESPONSE + mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = PROJECT_SPECIFIC_TASKS_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await _get_tasks_by_project_id(context=tool_context, project_id="project_123") + + assert result == PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 50, "project_id": "project_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_empty_result(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = EMPTY_TASKS_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await _get_tasks_by_project_id(context=tool_context, project_id="empty_project") + + assert result == EMPTY_TASKS_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 50, "project_id": "empty_project"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response + ) + httpx_mock.get.return_value = mock_response + + with pytest.raises(httpx.HTTPStatusError): + await _get_tasks_by_project_id(context=tool_context, project_id="project_123") + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_success(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks._get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE + + result = await get_tasks_by_project(context=tool_context, project="Work Project") + + assert result == PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, project_id="project_123", limit=50, next_page_token=None + ) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_not_found(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + with pytest.raises(ProjectNotFoundError) as exc_info: + await get_tasks_by_project(context=tool_context, project="Nonexistent Project") + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_partial_match(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + with pytest.raises(ProjectNotFoundError) as exc_info: + await get_tasks_by_project(context=tool_context, project="Work") + + assert "Project not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_all_tasks_with_custom_limit(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = SINGLE_TASK_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context, limit=25) + + assert result == SINGLE_TASK_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 25} + + +@pytest.mark.asyncio +async def test_get_all_tasks_with_pagination(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = TASKS_WITH_PAGINATION_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_all_tasks(context=tool_context, next_page_token="page_token_123") # noqa: S106 + + assert result == TASKS_WITH_PAGINATION_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 50, "cursor": "page_token_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_with_custom_limit(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = CUSTOM_LIMIT_TASK_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await _get_tasks_by_project_id( + context=tool_context, project_id="project_123", limit=100 + ) + + assert result == CUSTOM_LIMIT_TASK_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"limit": 100, "project_id": "project_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_id_with_pagination(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = PAGINATED_TASKS_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await _get_tasks_by_project_id( + context=tool_context, + project_id="project_123", + limit=25, + next_page_token="previous_page_token", # noqa: S106 + ) + + assert result == PAGINATED_TASKS_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == { + "limit": 25, + "cursor": "previous_page_token", + "project_id": "project_123", + } + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_name_with_pagination(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks._get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = PAGINATED_TASKS_PARSED_RESPONSE + + result = await get_tasks_by_project( + context=tool_context, + project="Work Project", + limit=10, + next_page_token="some_token", # noqa: S106 + ) + + assert result == PAGINATED_TASKS_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, + project_id="project_123", + limit=10, + next_page_token="some_token", # noqa: S106 + ) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_with_id(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks._get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = CUSTOM_LIMIT_TASK_PARSED_RESPONSE + + result = await get_tasks_by_project(context=tool_context, project="project_123") + + assert result == CUSTOM_LIMIT_TASK_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, project_id="project_123", limit=50, next_page_token=None + ) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project_with_name(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_get_tasks_by_project_id = mocker.patch( + "arcade_todoist.tools.tasks._get_tasks_by_project_id" + ) + mock_get_tasks_by_project_id.return_value = CUSTOM_LIMIT_TASK_PARSED_RESPONSE + + result = await get_tasks_by_project(context=tool_context, project="Work Project") + + assert result == CUSTOM_LIMIT_TASK_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_get_tasks_by_project_id.assert_called_once_with( + context=tool_context, project_id="project_123", limit=50, next_page_token=None + ) + + +@pytest.mark.asyncio +async def test_create_task_with_project_id(tool_context, mocker) -> None: + mock_get_projects = mocker.patch("arcade_todoist.tools.projects.get_projects") + mock_get_projects.return_value = PROJECTS_PARSED_RESPONSE + + mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks._create_task_in_project") + mock_create_task_in_project.return_value = CREATE_TASK_PARSED_RESPONSE + + result = await create_task(context=tool_context, description="New Task", project="project_123") + + assert result == CREATE_TASK_PARSED_RESPONSE + mock_get_projects.assert_called_once_with(context=tool_context) + mock_create_task_in_project.assert_called_once_with( + context=tool_context, description="New Task", project_id="project_123" + ) + + +@pytest.mark.asyncio +async def test_close_task_with_task_id(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE + + mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._close_task_by_task_id") + mock_close_task_by_task_id.return_value = CLOSE_TASK_SUCCESS_RESPONSE + + result = await close_task(context=tool_context, task="1") + + assert result == CLOSE_TASK_SUCCESS_RESPONSE + mock_get_tasks_by_filter.assert_called_once_with(context=tool_context, filter_query="search: 1") + mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") + + +@pytest.mark.asyncio +async def test_delete_task_with_task_id(tool_context, mocker) -> None: + mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") + mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE + + mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") + mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE + + result = await delete_task(context=tool_context, task="3") + + assert result == DELETE_TASK_SUCCESS_RESPONSE + mock_get_tasks_by_filter.assert_called_once_with(context=tool_context, filter_query="search: 3") + mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") + + +@pytest.mark.asyncio +async def test_get_tasks_by_filter_success(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = SINGLE_TASK_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_filter(context=tool_context, filter_query="today") + + assert result == SINGLE_TASK_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"query": "today", "limit": 50} + + +@pytest.mark.asyncio +async def test_get_tasks_by_filter_with_pagination(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = TASKS_WITH_PAGINATION_API_RESPONSE + httpx_mock.get.return_value = mock_response + + result = await get_tasks_by_filter( + context=tool_context, + filter_query="p1", + limit=25, + next_page_token="page_token_123", # noqa: S106 + ) + + assert result == TASKS_WITH_PAGINATION_PARSED_RESPONSE + httpx_mock.get.assert_called_once() + + call_args = httpx_mock.get.call_args + assert call_args[1]["params"] == {"query": "p1", "limit": 25, "cursor": "page_token_123"} + + +@pytest.mark.asyncio +async def test_get_tasks_by_filter_failure(tool_context, httpx_mock) -> None: + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message="Bad Request", + request=httpx.Request("GET", "http://test.com"), + response=mock_response, + ) + httpx_mock.get.return_value = mock_response + + with pytest.raises(ToolExecutionError): + await get_tasks_by_filter(context=tool_context, filter_query="invalid filter") + + httpx_mock.get.assert_called_once() diff --git a/toolkits/todoist/tests/test_projects.py b/toolkits/todoist/tests/test_projects.py deleted file mode 100644 index c263c8670..000000000 --- a/toolkits/todoist/tests/test_projects.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from arcade_todoist.tools.projects import get_projects - -fake_projects_response = { - "results": [ - { - "id": "1", - "name": "Project 1", - "created_at": "2021-01-01", - "can_assign_tasks": True, - "child_order": 0, - "color": "string", - "creator_uid": "string", - "is_archived": True, - "is_deleted": True, - "is_favorite": True, - } - ] -} - -faked_parsed_projects = {"projects": [{"id": "1", "name": "Project 1", "created_at": "2021-01-01"}]} - - -@pytest.mark.asyncio -async def test_get_projects_success(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = fake_projects_response - httpx_mock.get.return_value = mock_response - - result = await get_projects(context=tool_context) - - assert result == faked_parsed_projects - - httpx_mock.get.assert_called_once() diff --git a/toolkits/todoist/tests/test_tasks.py b/toolkits/todoist/tests/test_tasks.py deleted file mode 100644 index f5d723291..000000000 --- a/toolkits/todoist/tests/test_tasks.py +++ /dev/null @@ -1,750 +0,0 @@ -from unittest.mock import MagicMock - -import httpx -import pytest -from arcade_tdk.errors import ToolExecutionError - -from arcade_todoist.tools.tasks import ( - ProjectNotFoundError, - TaskNotFoundError, - close_task, - close_task_by_task_id, - create_task, - create_task_in_project, - delete_task, - delete_task_by_task_id, - get_all_tasks, - get_tasks_by_project_id, - get_tasks_by_project_name, -) - -fake_tasks_response = { - "results": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project id", - "checked": True, - "description": "Description of the task", - } - ], - "next_cursor": None -} - -faked_parsed_tasks = { - "tasks": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "project_id": "project id", - "checked": True, - } - ], - "next_page_token": None -} - -fake_paginated_tasks_response = { - "results": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project id", - "checked": True, - "description": "Description of the task", - } - ], - "next_cursor": "next_page_cursor_123" -} - -faked_parsed_paginated_tasks = { - "tasks": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "project_id": "project id", - "checked": True, - } - ], - "next_page_token": "next_page_cursor_123" -} - -fake_create_task_response = { - "id": "2", - "content": "New Task", - "added_at": "2024-01-01", - "project_id": "project_123", - "checked": False, - "priority": 1, - "description": "A new task description", -} - -faked_parsed_create_task = { - "id": "2", - "content": "New Task", - "added_at": "2024-01-01", - "project_id": "project_123", - "checked": False, -} - -expected_close_task_response = {"message": "Task closed successfully"} - -expected_delete_task_response = {"message": "Task deleted successfully"} - - -@pytest.mark.asyncio -async def test_get_all_tasks_success(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = fake_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_all_tasks(context=tool_context) - - assert result == faked_parsed_tasks - - httpx_mock.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_all_tasks_failure(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.json.return_value = {} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response - ) - httpx_mock.get.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await get_all_tasks(context=tool_context) - - httpx_mock.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_task_in_project_success(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = fake_create_task_response - httpx_mock.post.return_value = mock_response - - result = await create_task_in_project( - context=tool_context, description="New Task", project_id="project_123" - ) - - assert result == faked_parsed_create_task - - httpx_mock.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_create_task_in_project_failure(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.json.return_value = {} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message="Bad Request", - request=httpx.Request("POST", "http://test.com"), - response=mock_response, - ) - httpx_mock.post.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await create_task_in_project( - context=tool_context, description="New Task", project_id="project_123" - ) - - httpx_mock.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_close_task_by_task_id_success(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - httpx_mock.post.return_value = mock_response - - result = await close_task_by_task_id(context=tool_context, task_id="task_123") - - assert result == expected_close_task_response - - httpx_mock.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_close_task_by_task_id_failure(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.json.return_value = {} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message="Not Found", - request=httpx.Request("POST", "http://test.com"), - response=mock_response, - ) - httpx_mock.post.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await close_task_by_task_id(context=tool_context, task_id="task_123") - - httpx_mock.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_delete_task_by_task_id_success(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - httpx_mock.delete.return_value = mock_response - - result = await delete_task_by_task_id(context=tool_context, task_id="task_123") - - assert result == expected_delete_task_response - - httpx_mock.delete.assert_called_once() - - -@pytest.mark.asyncio -async def test_delete_task_by_task_id_failure(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.json.return_value = {} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message="Not Found", - request=httpx.Request("DELETE", "http://test.com"), - response=mock_response, - ) - httpx_mock.delete.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await delete_task_by_task_id(context=tool_context, task_id="task_123") - - httpx_mock.delete.assert_called_once() - - -# Additional test data for project-based and description-based tests -fake_projects_response = { - "projects": [ - {"id": "project_123", "name": "Work Project", "created_at": "2021-01-01"}, - {"id": "project_456", "name": "Personal Tasks", "created_at": "2021-01-01"}, - ] -} - -fake_multiple_tasks_response = { - "tasks": [ - { - "id": "1", - "content": "Buy groceries", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - { - "id": "2", - "content": "Grocery shopping", - "added_at": "2021-01-01", - "project_id": "project_456", - "checked": False, - }, - { - "id": "3", - "content": "Meeting notes", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": None -} - - -@pytest.mark.asyncio -async def test_create_task_success_exact_project_match(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - # Mock create_task_in_project - mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks.create_task_in_project") - mock_create_task_in_project.return_value = faked_parsed_create_task - - result = await create_task( - context=tool_context, description="New Task", project_name="Work Project" - ) - - assert result == faked_parsed_create_task - mock_get_projects.assert_called_once_with(context=tool_context) - mock_create_task_in_project.assert_called_once_with( - context=tool_context, description="New Task", project_id="project_123" - ) - - -@pytest.mark.asyncio -async def test_create_task_success_no_project(tool_context, mocker) -> None: - # Mock create_task_in_project - mock_create_task_in_project = mocker.patch("arcade_todoist.tools.tasks.create_task_in_project") - mock_create_task_in_project.return_value = faked_parsed_create_task - - result = await create_task(context=tool_context, description="New Task", project_name=None) - - assert result == faked_parsed_create_task - mock_create_task_in_project.assert_called_once_with( - context=tool_context, description="New Task", project_id=None - ) - - -@pytest.mark.asyncio -async def test_create_task_project_not_found(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - with pytest.raises(ProjectNotFoundError) as exc_info: - await create_task( - context=tool_context, description="New Task", project_name="Nonexistent Project" - ) - - assert "Project not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_create_task_project_partial_match(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - with pytest.raises(ProjectNotFoundError) as exc_info: - await create_task(context=tool_context, description="New Task", project_name="Work") - - assert "Project not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_close_task_success_exact_match(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - # Mock close_task_by_task_id - mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks.close_task_by_task_id") - mock_close_task_by_task_id.return_value = expected_close_task_response - - result = await close_task(context=tool_context, task_description="Buy groceries") - - assert result == expected_close_task_response - mock_get_all_tasks.assert_called_once_with(context=tool_context) - mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") - - -@pytest.mark.asyncio -async def test_close_task_not_found(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - with pytest.raises(TaskNotFoundError) as exc_info: - await close_task(context=tool_context, task_description="Nonexistent task") - - assert "Task not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_close_task_partial_match(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - with pytest.raises(TaskNotFoundError) as exc_info: - await close_task(context=tool_context, task_description="grocery") - - error_message = str(exc_info.value) - assert "Task not found" in error_message - - -@pytest.mark.asyncio -async def test_delete_task_success_exact_match(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - # Mock delete_task_by_task_id - mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks.delete_task_by_task_id") - mock_delete_task_by_task_id.return_value = expected_delete_task_response - - result = await delete_task(context=tool_context, task_description="Meeting notes") - - assert result == expected_delete_task_response - mock_get_all_tasks.assert_called_once_with(context=tool_context) - mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") - - -@pytest.mark.asyncio -async def test_delete_task_not_found(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - with pytest.raises(TaskNotFoundError) as exc_info: - await delete_task(context=tool_context, task_description="Nonexistent task") - - assert "Task not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_delete_task_partial_match(tool_context, mocker) -> None: - # Mock get_all_tasks - mock_get_all_tasks = mocker.patch("arcade_todoist.tools.tasks.get_all_tasks") - mock_get_all_tasks.return_value = fake_multiple_tasks_response - - with pytest.raises(TaskNotFoundError) as exc_info: - await delete_task(context=tool_context, task_description="notes") - - assert "Task not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_id_success(tool_context, httpx_mock) -> None: - # Mock API response for specific project - project_tasks_response = { - "results": [ - { - "id": "1", - "content": "Buy groceries", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project_123", - "checked": False, - "description": "Description of the task", - }, - { - "id": "3", - "content": "Meeting notes", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project_123", - "checked": False, - "description": "Description of the task", - }, - ], - "next_cursor": None - } - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = project_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_tasks_by_project_id(context=tool_context, project_id="project_123") - - # Should only return tasks from project_123 - expected_filtered_tasks = { - "tasks": [ - { - "id": "1", - "content": "Buy groceries", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - { - "id": "3", - "content": "Meeting notes", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": None - } - - assert result == expected_filtered_tasks - httpx_mock.get.assert_called_once() - - # Verify the API was called with the correct query parameter - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"limit": 50, "project_id": "project_123"} - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_id_empty_result(tool_context, httpx_mock) -> None: - # Mock API response with no tasks for the project - empty_tasks_response = {"results": [], "next_cursor": None} - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = empty_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_tasks_by_project_id(context=tool_context, project_id="empty_project") - - # Should return empty tasks list - expected_empty_result = {"tasks": [], "next_page_token": None} - - assert result == expected_empty_result - httpx_mock.get.assert_called_once() - - # Verify the API was called with the correct query parameter - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"limit": 50, "project_id": "empty_project"} - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_id_failure(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.json.return_value = {} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - message="Not Found", request=httpx.Request("GET", "http://test.com"), response=mock_response - ) - httpx_mock.get.return_value = mock_response - - with pytest.raises(ToolExecutionError): - await get_tasks_by_project_id(context=tool_context, project_id="project_123") - - httpx_mock.get.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_name_success(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - # Mock get_tasks_by_project_id - expected_filtered_tasks = { - "tasks": [ - { - "id": "1", - "content": "Buy groceries", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - { - "id": "3", - "content": "Meeting notes", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": None - } - mock_get_tasks_by_project_id = mocker.patch( - "arcade_todoist.tools.tasks.get_tasks_by_project_id" - ) - mock_get_tasks_by_project_id.return_value = expected_filtered_tasks - - result = await get_tasks_by_project_name(context=tool_context, project_name="Work Project") - - assert result == expected_filtered_tasks - mock_get_projects.assert_called_once_with(context=tool_context) - mock_get_tasks_by_project_id.assert_called_once_with( - context=tool_context, project_id="project_123", limit=50, next_page_token=None - ) - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_name_not_found(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - with pytest.raises(ProjectNotFoundError) as exc_info: - await get_tasks_by_project_name(context=tool_context, project_name="Nonexistent Project") - - assert "Project not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_name_partial_match(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - with pytest.raises(ProjectNotFoundError) as exc_info: - await get_tasks_by_project_name(context=tool_context, project_name="Work") - - assert "Project not found" in str(exc_info.value) - - -# Pagination-specific tests -@pytest.mark.asyncio -async def test_get_all_tasks_with_custom_limit(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = fake_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_all_tasks(context=tool_context, limit=25) - - assert result == faked_parsed_tasks - httpx_mock.get.assert_called_once() - - # Verify the API was called with the correct limit parameter - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"limit": 25} - - -@pytest.mark.asyncio -async def test_get_all_tasks_with_pagination(tool_context, httpx_mock) -> None: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = fake_paginated_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_all_tasks(context=tool_context, next_page_token="page_token_123") - - assert result == faked_parsed_paginated_tasks - httpx_mock.get.assert_called_once() - - # Verify the API was called with the cursor parameter - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"limit": 50, "cursor": "page_token_123"} - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_id_with_custom_limit(tool_context, httpx_mock) -> None: - project_tasks_response = { - "results": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project_123", - "checked": False, - "description": "Description", - }, - ], - "next_cursor": None - } - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = project_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_tasks_by_project_id( - context=tool_context, project_id="project_123", limit=100 - ) - - expected_result = { - "tasks": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": None - } - - assert result == expected_result - httpx_mock.get.assert_called_once() - - # Verify the API was called with custom limit and project_id - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == {"limit": 100, "project_id": "project_123"} - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_id_with_pagination(tool_context, httpx_mock) -> None: - project_tasks_response = { - "results": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "priority": 1, - "project_id": "project_123", - "checked": False, - "description": "Description", - }, - ], - "next_cursor": "next_page_token_456" - } - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = project_tasks_response - httpx_mock.get.return_value = mock_response - - result = await get_tasks_by_project_id( - context=tool_context, - project_id="project_123", - limit=25, - next_page_token="previous_page_token" - ) - - expected_result = { - "tasks": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": "next_page_token_456" - } - - assert result == expected_result - httpx_mock.get.assert_called_once() - - # Verify the API was called with all pagination parameters - call_args = httpx_mock.get.call_args - assert call_args[1]["params"] == { - "limit": 25, - "cursor": "previous_page_token", - "project_id": "project_123" - } - - -@pytest.mark.asyncio -async def test_get_tasks_by_project_name_with_pagination(tool_context, mocker) -> None: - # Mock get_projects - mock_get_projects = mocker.patch("arcade_todoist.tools.tasks.get_projects") - mock_get_projects.return_value = fake_projects_response - - # Mock get_tasks_by_project_id - expected_result = { - "tasks": [ - { - "id": "1", - "content": "Task 1", - "added_at": "2021-01-01", - "project_id": "project_123", - "checked": False, - }, - ], - "next_page_token": "next_token_789" - } - mock_get_tasks_by_project_id = mocker.patch( - "arcade_todoist.tools.tasks.get_tasks_by_project_id" - ) - mock_get_tasks_by_project_id.return_value = expected_result - - result = await get_tasks_by_project_name( - context=tool_context, - project_name="Work Project", - limit=10, - next_page_token="some_token" - ) - - assert result == expected_result - mock_get_projects.assert_called_once_with(context=tool_context) - mock_get_tasks_by_project_id.assert_called_once_with( - context=tool_context, - project_id="project_123", - limit=10, - next_page_token="some_token" - ) From d26e8e91776f0c916f9e67ae13f46f7202d91de7 Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Fri, 1 Aug 2025 12:32:23 -0400 Subject: [PATCH 4/6] Scoping functions correctly --- toolkits/todoist/community/arcade_todoist/tools/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkits/todoist/community/arcade_todoist/tools/tasks.py b/toolkits/todoist/community/arcade_todoist/tools/tasks.py index bb704dae0..4e4ec3bbc 100644 --- a/toolkits/todoist/community/arcade_todoist/tools/tasks.py +++ b/toolkits/todoist/community/arcade_todoist/tools/tasks.py @@ -18,7 +18,7 @@ @tool( requires_auth=OAuth2( id="todoist", - scopes=["data:read_write"], + scopes=["data:read"], ), ) async def get_all_tasks( From 660dd3c028efb656b81300fc30bd2fe4ebc1ed21 Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Mon, 4 Aug 2025 10:04:59 -0400 Subject: [PATCH 5/6] Tools that produce side effects require unique params --- .../community/arcade_todoist/tools/tasks.py | 20 ++-- .../todoist/community/evals/eval_todoist.py | 28 +++--- .../todoist/community/tests/test_tasks.py | 92 ++----------------- 3 files changed, 27 insertions(+), 113 deletions(-) diff --git a/toolkits/todoist/community/arcade_todoist/tools/tasks.py b/toolkits/todoist/community/arcade_todoist/tools/tasks.py index 4e4ec3bbc..3f73578f2 100644 --- a/toolkits/todoist/community/arcade_todoist/tools/tasks.py +++ b/toolkits/todoist/community/arcade_todoist/tools/tasks.py @@ -11,7 +11,6 @@ parse_task, parse_tasks, resolve_project_id, - resolve_task_id, ) @@ -82,7 +81,7 @@ async def _get_tasks_by_project_id( @tool( requires_auth=OAuth2( id="todoist", - scopes=["data:read_write"], + scopes=["data:read"], ), ) async def get_tasks_by_project( @@ -222,17 +221,14 @@ async def _close_task_by_task_id( ) async def close_task( context: ToolContext, - task: Annotated[str, "The ID or description/content of the task to be closed."], + task_id: Annotated[str, "The exact ID of the task to be closed."], ) -> Annotated[dict, "The task object returned by the Todoist API."]: """ - Close a task by its ID or description/content. Use this whenever the user wants to + Close a task by its exact ID. Use this whenever the user wants to mark a task as completed, done, or closed. - The function will first try to find a task with the given ID, and if that doesn't exist, - it will search for a task with the given description/content. """ - task_id = await resolve_task_id(context=context, task=task) return await _close_task_by_task_id(context=context, task_id=task_id) @@ -270,24 +266,20 @@ async def _delete_task_by_task_id( ) async def delete_task( context: ToolContext, - task: Annotated[str, "The ID or description/content of the task to be deleted."], + task_id: Annotated[str, "The exact ID of the task to be deleted."], ) -> Annotated[dict, "The task object returned by the Todoist API."]: """ - Delete a task by its ID or description/content. Use this whenever the user wants to + Delete a task by its exact ID. Use this whenever the user wants to delete a task. - - The function will first try to find a task with the given ID, and if that doesn't exist, - it will search for a task with the given description/content. """ - task_id = await resolve_task_id(context=context, task=task) return await _delete_task_by_task_id(context=context, task_id=task_id) @tool( requires_auth=OAuth2( id="todoist", - scopes=["data:read_write"], + scopes=["data:read"], ), ) async def get_tasks_by_filter( diff --git a/toolkits/todoist/community/evals/eval_todoist.py b/toolkits/todoist/community/evals/eval_todoist.py index bbfb9cca0..d814f0c23 100644 --- a/toolkits/todoist/community/evals/eval_todoist.py +++ b/toolkits/todoist/community/evals/eval_todoist.py @@ -112,45 +112,45 @@ def todoist_eval_suite() -> EvalSuite: suite.add_case( name="Close a task by ID", user_message="Mark task with ID '12345' as completed", - expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "12345"})], + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task_id": "12345"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task", weight=1)], + critics=[SimilarityCritic(critic_field="task_id", weight=1)], additional_messages=[], ) suite.add_case( - name="Close a task by Name", - user_message="I'm done with the task 'Buy groceries'", - expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "Buy groceries"})], + name="Close a task by ID", + user_message="I'm done with task ID 'task_456'", + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task_id": "task_456"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task", weight=1)], + critics=[SimilarityCritic(critic_field="task_id", weight=1)], additional_messages=[], ) suite.add_case( name="Complete a task by id", user_message="Please close task with id abc123, I finished it", - expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task": "abc123"})], + expected_tool_calls=[ExpectedToolCall(func=close_task, args={"task_id": "abc123"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task", weight=1)], + critics=[SimilarityCritic(critic_field="task_id", weight=1)], additional_messages=[], ) suite.add_case( name="Delete a task by ID", user_message="Delete task with ID 'task_456'", - expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task": "task_456"})], + expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task_id": "task_456"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task", weight=1)], + critics=[SimilarityCritic(critic_field="task_id", weight=1)], additional_messages=[], ) suite.add_case( - name="Remove a task by name", - user_message="I want to remove task Wash car completely", - expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task": "Wash car"})], + name="Remove a task by ID", + user_message="I want to remove task with ID task_789 completely", + expected_tool_calls=[ExpectedToolCall(func=delete_task, args={"task_id": "task_789"})], rubric=rubric, - critics=[SimilarityCritic(critic_field="task", weight=1)], + critics=[SimilarityCritic(critic_field="task_id", weight=1)], additional_messages=[], ) diff --git a/toolkits/todoist/community/tests/test_tasks.py b/toolkits/todoist/community/tests/test_tasks.py index c1521e6ce..ac12ffba3 100644 --- a/toolkits/todoist/community/tests/test_tasks.py +++ b/toolkits/todoist/community/tests/test_tasks.py @@ -4,7 +4,7 @@ import pytest from arcade_tdk.errors import ToolExecutionError -from arcade_todoist.errors import ProjectNotFoundError, TaskNotFoundError +from arcade_todoist.errors import ProjectNotFoundError from arcade_todoist.tools.tasks import ( _close_task_by_task_id, _create_task_in_project, @@ -26,14 +26,11 @@ DELETE_TASK_SUCCESS_RESPONSE, EMPTY_TASKS_API_RESPONSE, EMPTY_TASKS_PARSED_RESPONSE, - MULTIPLE_TASKS_PARSED_RESPONSE, PAGINATED_TASKS_API_RESPONSE, PAGINATED_TASKS_PARSED_RESPONSE, - PARTIAL_MATCH_TASKS_PARSED_RESPONSE, PROJECT_SPECIFIC_TASKS_API_RESPONSE, PROJECT_SPECIFIC_TASKS_PARSED_RESPONSE, PROJECTS_PARSED_RESPONSE, - SINGLE_MATCH_TASK_PARSED_RESPONSE, SINGLE_TASK_API_RESPONSE, SINGLE_TASK_PARSED_RESPONSE, TASKS_WITH_PAGINATION_API_RESPONSE, @@ -215,89 +212,22 @@ async def test_create_task_project_not_found(tool_context, mocker) -> None: @pytest.mark.asyncio -async def test_close_task_success_exact_match(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE - +async def test_close_task_success_with_id(tool_context, mocker) -> None: mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._close_task_by_task_id") mock_close_task_by_task_id.return_value = CLOSE_TASK_SUCCESS_RESPONSE - result = await close_task(context=tool_context, task="Buy groceries") + result = await close_task(context=tool_context, task_id="1") assert result == CLOSE_TASK_SUCCESS_RESPONSE - mock_get_tasks_by_filter.assert_called_once_with( - context=tool_context, filter_query="search: Buy groceries" - ) mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") @pytest.mark.asyncio -async def test_close_task_not_found(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = {"tasks": [], "next_page_token": None} - - with pytest.raises(TaskNotFoundError) as exc_info: - await close_task(context=tool_context, task="Nonexistent task") - - assert "Task not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_close_task_partial_match(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = PARTIAL_MATCH_TASKS_PARSED_RESPONSE - - with pytest.raises(ToolExecutionError) as exc_info: - await close_task(context=tool_context, task="task") - - error = exc_info.value - error_text = str(error) - if hasattr(error, "developer_message") and error.developer_message: - error_text += " " + error.developer_message - - if error.__cause__: - error_text += " " + str(error.__cause__) - - assert "Multiple tasks found" in error_text - - -@pytest.mark.asyncio -async def test_delete_task_success_exact_match(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE - +async def test_delete_task_success_with_id(tool_context, mocker) -> None: mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE - result = await delete_task(context=tool_context, task="Meeting notes") - - assert result == DELETE_TASK_SUCCESS_RESPONSE - mock_get_tasks_by_filter.assert_called_once_with( - context=tool_context, filter_query="search: Meeting notes" - ) - mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") - - -@pytest.mark.asyncio -async def test_delete_task_not_found(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = {"tasks": [], "next_page_token": None} - - with pytest.raises(TaskNotFoundError) as exc_info: - await delete_task(context=tool_context, task="Nonexistent task") - - assert "Task not found" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_delete_task_partial_match(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = SINGLE_MATCH_TASK_PARSED_RESPONSE - - mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") - mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE - - result = await delete_task(context=tool_context, task="notes") + result = await delete_task(context=tool_context, task_id="3") assert result == DELETE_TASK_SUCCESS_RESPONSE mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") @@ -551,31 +481,23 @@ async def test_create_task_with_project_id(tool_context, mocker) -> None: @pytest.mark.asyncio async def test_close_task_with_task_id(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE - mock_close_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._close_task_by_task_id") mock_close_task_by_task_id.return_value = CLOSE_TASK_SUCCESS_RESPONSE - result = await close_task(context=tool_context, task="1") + result = await close_task(context=tool_context, task_id="1") assert result == CLOSE_TASK_SUCCESS_RESPONSE - mock_get_tasks_by_filter.assert_called_once_with(context=tool_context, filter_query="search: 1") mock_close_task_by_task_id.assert_called_once_with(context=tool_context, task_id="1") @pytest.mark.asyncio async def test_delete_task_with_task_id(tool_context, mocker) -> None: - mock_get_tasks_by_filter = mocker.patch("arcade_todoist.tools.tasks.get_tasks_by_filter") - mock_get_tasks_by_filter.return_value = MULTIPLE_TASKS_PARSED_RESPONSE - mock_delete_task_by_task_id = mocker.patch("arcade_todoist.tools.tasks._delete_task_by_task_id") mock_delete_task_by_task_id.return_value = DELETE_TASK_SUCCESS_RESPONSE - result = await delete_task(context=tool_context, task="3") + result = await delete_task(context=tool_context, task_id="3") assert result == DELETE_TASK_SUCCESS_RESPONSE - mock_get_tasks_by_filter.assert_called_once_with(context=tool_context, filter_query="search: 3") mock_delete_task_by_task_id.assert_called_once_with(context=tool_context, task_id="3") From cc4a70c81b2ec7ef890d9e046dcf74c262c957a5 Mon Sep 17 00:00:00 2001 From: andrestorres123 Date: Tue, 5 Aug 2025 10:52:31 -0400 Subject: [PATCH 6/6] Relocate community folder --- .../{todoist/community => community/todoist}/Makefile | 0 .../{todoist/community => community/todoist}/README.md | 0 .../todoist}/arcade_todoist/__init__.py | 0 .../todoist}/arcade_todoist/errors.py | 0 .../todoist}/arcade_todoist/tools/__init__.py | 0 .../todoist}/arcade_todoist/tools/projects.py | 0 .../todoist}/arcade_todoist/tools/tasks.py | 0 .../todoist}/arcade_todoist/utils.py | 0 .../community => community/todoist}/evals/eval_todoist.py | 3 +-- .../community => community/todoist}/pyproject.toml | 8 +------- .../community => community/todoist}/tests/__init__.py | 0 .../community => community/todoist}/tests/conftest.py | 0 .../community => community/todoist}/tests/fakes.py | 0 .../todoist}/tests/test_projects.py | 2 +- .../community => community/todoist}/tests/test_tasks.py | 2 +- 15 files changed, 4 insertions(+), 11 deletions(-) rename toolkits/{todoist/community => community/todoist}/Makefile (100%) rename toolkits/{todoist/community => community/todoist}/README.md (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/__init__.py (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/errors.py (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/tools/__init__.py (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/tools/projects.py (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/tools/tasks.py (100%) rename toolkits/{todoist/community => community/todoist}/arcade_todoist/utils.py (100%) rename toolkits/{todoist/community => community/todoist}/evals/eval_todoist.py (99%) rename toolkits/{todoist/community => community/todoist}/pyproject.toml (79%) rename toolkits/{todoist/community => community/todoist}/tests/__init__.py (100%) rename toolkits/{todoist/community => community/todoist}/tests/conftest.py (100%) rename toolkits/{todoist/community => community/todoist}/tests/fakes.py (100%) rename toolkits/{todoist/community => community/todoist}/tests/test_projects.py (100%) rename toolkits/{todoist/community => community/todoist}/tests/test_tasks.py (100%) diff --git a/toolkits/todoist/community/Makefile b/toolkits/community/todoist/Makefile similarity index 100% rename from toolkits/todoist/community/Makefile rename to toolkits/community/todoist/Makefile diff --git a/toolkits/todoist/community/README.md b/toolkits/community/todoist/README.md similarity index 100% rename from toolkits/todoist/community/README.md rename to toolkits/community/todoist/README.md diff --git a/toolkits/todoist/community/arcade_todoist/__init__.py b/toolkits/community/todoist/arcade_todoist/__init__.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/__init__.py rename to toolkits/community/todoist/arcade_todoist/__init__.py diff --git a/toolkits/todoist/community/arcade_todoist/errors.py b/toolkits/community/todoist/arcade_todoist/errors.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/errors.py rename to toolkits/community/todoist/arcade_todoist/errors.py diff --git a/toolkits/todoist/community/arcade_todoist/tools/__init__.py b/toolkits/community/todoist/arcade_todoist/tools/__init__.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/tools/__init__.py rename to toolkits/community/todoist/arcade_todoist/tools/__init__.py diff --git a/toolkits/todoist/community/arcade_todoist/tools/projects.py b/toolkits/community/todoist/arcade_todoist/tools/projects.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/tools/projects.py rename to toolkits/community/todoist/arcade_todoist/tools/projects.py diff --git a/toolkits/todoist/community/arcade_todoist/tools/tasks.py b/toolkits/community/todoist/arcade_todoist/tools/tasks.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/tools/tasks.py rename to toolkits/community/todoist/arcade_todoist/tools/tasks.py diff --git a/toolkits/todoist/community/arcade_todoist/utils.py b/toolkits/community/todoist/arcade_todoist/utils.py similarity index 100% rename from toolkits/todoist/community/arcade_todoist/utils.py rename to toolkits/community/todoist/arcade_todoist/utils.py diff --git a/toolkits/todoist/community/evals/eval_todoist.py b/toolkits/community/todoist/evals/eval_todoist.py similarity index 99% rename from toolkits/todoist/community/evals/eval_todoist.py rename to toolkits/community/todoist/evals/eval_todoist.py index d814f0c23..143889b4f 100644 --- a/toolkits/todoist/community/evals/eval_todoist.py +++ b/toolkits/community/todoist/evals/eval_todoist.py @@ -1,3 +1,4 @@ +import arcade_todoist from arcade_evals import ( EvalRubric, EvalSuite, @@ -6,8 +7,6 @@ ) from arcade_evals.critic import BinaryCritic, SimilarityCritic from arcade_tdk import ToolCatalog - -import arcade_todoist from arcade_todoist.tools.projects import get_projects from arcade_todoist.tools.tasks import ( close_task, diff --git a/toolkits/todoist/community/pyproject.toml b/toolkits/community/todoist/pyproject.toml similarity index 79% rename from toolkits/todoist/community/pyproject.toml rename to toolkits/community/todoist/pyproject.toml index 62add1957..3be6f4d70 100644 --- a/toolkits/todoist/community/pyproject.toml +++ b/toolkits/community/todoist/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "arcade-ai[evals]>=2.1.1,<3.0.0", + "arcade-ai[evals]>=2.1.4", "arcade-serve>=2.0.0,<3.0.0", "pytest>=8.3.0,<8.4.0", "pytest-cov>=4.0.0,<4.1.0", @@ -30,12 +30,6 @@ dev = [ [project.entry-points.arcade_toolkits] toolkit_name = "arcade_todoist" -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-ai = { path = "../../", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } - [tool.mypy] files = [ "arcade_todoist/**/*.py",] python_version = "3.10" diff --git a/toolkits/todoist/community/tests/__init__.py b/toolkits/community/todoist/tests/__init__.py similarity index 100% rename from toolkits/todoist/community/tests/__init__.py rename to toolkits/community/todoist/tests/__init__.py diff --git a/toolkits/todoist/community/tests/conftest.py b/toolkits/community/todoist/tests/conftest.py similarity index 100% rename from toolkits/todoist/community/tests/conftest.py rename to toolkits/community/todoist/tests/conftest.py diff --git a/toolkits/todoist/community/tests/fakes.py b/toolkits/community/todoist/tests/fakes.py similarity index 100% rename from toolkits/todoist/community/tests/fakes.py rename to toolkits/community/todoist/tests/fakes.py diff --git a/toolkits/todoist/community/tests/test_projects.py b/toolkits/community/todoist/tests/test_projects.py similarity index 100% rename from toolkits/todoist/community/tests/test_projects.py rename to toolkits/community/todoist/tests/test_projects.py index a6c25ca45..1425a9460 100644 --- a/toolkits/todoist/community/tests/test_projects.py +++ b/toolkits/community/todoist/tests/test_projects.py @@ -1,8 +1,8 @@ from unittest.mock import MagicMock import pytest - from arcade_todoist.tools.projects import get_projects + from tests.fakes import PROJECTS_API_RESPONSE, PROJECTS_PARSED_RESPONSE diff --git a/toolkits/todoist/community/tests/test_tasks.py b/toolkits/community/todoist/tests/test_tasks.py similarity index 100% rename from toolkits/todoist/community/tests/test_tasks.py rename to toolkits/community/todoist/tests/test_tasks.py index ac12ffba3..fff4cd353 100644 --- a/toolkits/todoist/community/tests/test_tasks.py +++ b/toolkits/community/todoist/tests/test_tasks.py @@ -3,7 +3,6 @@ import httpx import pytest from arcade_tdk.errors import ToolExecutionError - from arcade_todoist.errors import ProjectNotFoundError from arcade_todoist.tools.tasks import ( _close_task_by_task_id, @@ -17,6 +16,7 @@ get_tasks_by_filter, get_tasks_by_project, ) + from tests.fakes import ( CLOSE_TASK_SUCCESS_RESPONSE, CREATE_TASK_API_RESPONSE,