diff --git a/toolkits/mastodon/.pre-commit-config.yaml b/toolkits/mastodon/.pre-commit-config.yaml new file mode 100644 index 000000000..085074e00 --- /dev/null +++ b/toolkits/mastodon/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^.*/mastodon/.* +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/mastodon/.ruff.toml b/toolkits/mastodon/.ruff.toml new file mode 100644 index 000000000..9519fe6c3 --- /dev/null +++ b/toolkits/mastodon/.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/mastodon/Makefile b/toolkits/mastodon/Makefile new file mode 100644 index 000000000..0a8969beb --- /dev/null +++ b/toolkits/mastodon/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 -v --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/mastodon/README.md b/toolkits/mastodon/README.md new file mode 100644 index 000000000..9e00317d5 --- /dev/null +++ b/toolkits/mastodon/README.md @@ -0,0 +1,26 @@ +
+ +
+ +
+ Python version + License + PyPI version +
+ + +
+
+ +# Arcade mastodon Toolkit +Allow the agent to interact with a Mastodon server +## Features + +- The mastodon 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/mastodon/arcade_mastodon/__init__.py b/toolkits/mastodon/arcade_mastodon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/mastodon/arcade_mastodon/tools/__init__.py b/toolkits/mastodon/arcade_mastodon/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/mastodon/arcade_mastodon/tools/statuses.py b/toolkits/mastodon/arcade_mastodon/tools/statuses.py new file mode 100644 index 000000000..339c243b8 --- /dev/null +++ b/toolkits/mastodon/arcade_mastodon/tools/statuses.py @@ -0,0 +1,173 @@ +from typing import Annotated + +import httpx +from arcade_tdk import ToolContext, tool +from arcade_tdk.auth import OAuth2 +from arcade_tdk.errors import RetryableToolError, ToolExecutionError + +from arcade_mastodon.tools.users import lookup_single_user_by_username +from arcade_mastodon.utils import get_headers, get_url, parse_status, parse_statuses + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["write:statuses"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def post_status( + context: ToolContext, + status: Annotated[str, "The status to post"], +) -> Annotated[dict, "The status object from Mastodon"]: + """ + Post a status to Mastodon. + """ + + async with httpx.AsyncClient() as client_http: + response = await client_http.post( + get_url(context=context, endpoint="statuses"), + headers=get_headers(context), + json={"status": status}, + ) + response.raise_for_status() + return parse_status(response.json()) + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["write:statuses"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def delete_status_by_id( + context: ToolContext, + status_id: Annotated[str, "The ID of the status to delete"], +) -> Annotated[dict, "The status object from Mastodon that was deleted"]: + """ + Delete a Mastodon status by its ID. + """ + + async with httpx.AsyncClient() as client_http: + response = await client_http.delete( + get_url(context=context, endpoint=f"statuses/{status_id}"), + headers=get_headers(context), + ) + response.raise_for_status() + return parse_status(response.json()) + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["read:statuses"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def lookup_status_by_id( + context: ToolContext, + status_id: Annotated[str, "The ID of the status to lookup"], +) -> Annotated[dict, "The status object from Mastodon"]: + """ + Lookup a Mastodon status by its ID. + """ + + async with httpx.AsyncClient() as client_http: + response = await client_http.get( + get_url(context=context, endpoint=f"statuses/{status_id}"), + headers=get_headers(context), + ) + response.raise_for_status() + return parse_status(response.json()) + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["read:statuses", "read:accounts"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def search_recent_statuses_by_username( + context: ToolContext, + username: Annotated[str, "The username of the Mastodon account to look up."], + limit: Annotated[ + int, "The maximum number of statuses to return. Default is 20, maximum is 40." + ] = 20, +) -> Annotated[dict, "The statuses from Mastodon"]: + """ + Search for recent statuses by a username. + """ + + account_info = await lookup_single_user_by_username(context, username) + if not account_info["account"]: + raise ToolExecutionError( + message=f"Account {username} not found.", + developer_message=f"Account {username} not found while searching for recent statuses.", + ) + + account_id = account_info["account"]["id"] + + limit = max(1, min(limit, 40)) + + async with httpx.AsyncClient() as client_http: + response = await client_http.get( + get_url(context=context, endpoint=f"accounts/{account_id}/statuses"), + headers=get_headers(context), + params={"limit": limit}, + ) + response.raise_for_status() + return {"statuses": parse_statuses(response.json())} + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["read:statuses", "read:accounts"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def search_recent_statuses_by_keywords( + context: ToolContext, + keywords: Annotated[list[str] | None, "The keywords to search for."] = None, + phrases: Annotated[list[str] | None, "The phrases to search for."] = None, + limit: Annotated[ + int, "The maximum number of statuses to return. Default is 20, maximum is 40." + ] = 20, +) -> Annotated[dict, "The statuses from Mastodon"]: + """ + Search for recent statuses by keywords and phrases. + """ + + if not any([keywords, phrases]): + raise RetryableToolError( + message="At least one keyword or one phrase must be provided to this tool.", + developer_message="The LLM did not provide any keywords or phrases to" + " search for recent statuses.", + additional_prompt_content="Please provide at least one keyword or one phrase" + " to search for recent statuses.", + retry_after_ms=500, + ) + + query = "".join(f'"{phrase}"' for phrase in phrases or []) + if keywords: + query += " ".join(f"{keyword}" for keyword in keywords) + + limit = max(1, min(limit, 40)) + + async with httpx.AsyncClient() as client_http: + response = await client_http.get( + get_url( + context=context, + endpoint="search", + api_version="v2", + ), + headers=get_headers(context), + params={ + "q": query, + "limit": limit, + }, + ) + response.raise_for_status() + return {"statuses": parse_statuses(response.json())} diff --git a/toolkits/mastodon/arcade_mastodon/tools/users.py b/toolkits/mastodon/arcade_mastodon/tools/users.py new file mode 100644 index 000000000..95cfcfcb0 --- /dev/null +++ b/toolkits/mastodon/arcade_mastodon/tools/users.py @@ -0,0 +1,31 @@ +from typing import Annotated + +import httpx +from arcade_tdk import ToolContext, tool +from arcade_tdk.auth import OAuth2 + +from arcade_mastodon.utils import get_headers, get_url + + +@tool( + requires_auth=OAuth2( + id="mastodon", + scopes=["read:accounts"], + ), + requires_secrets=["MASTODON_SERVER_URL"], +) +async def lookup_single_user_by_username( + context: ToolContext, + username: Annotated[str, "The username of the Mastodon account to look up."], +) -> Annotated[dict, "The account object from Mastodon"]: + """ + Lookup a single Mastodon account by its username. + """ + + async with httpx.AsyncClient() as client_http: + response = await client_http.get( + get_url(context=context, endpoint=f"accounts/lookup?acct={username}"), + headers=get_headers(context), + ) + response.raise_for_status() + return {"account": response.json()} diff --git a/toolkits/mastodon/arcade_mastodon/utils.py b/toolkits/mastodon/arcade_mastodon/utils.py new file mode 100644 index 000000000..910f17aa6 --- /dev/null +++ b/toolkits/mastodon/arcade_mastodon/utils.py @@ -0,0 +1,50 @@ +from typing import Any + +from arcade_tdk import ToolContext +from arcade_tdk.errors import ToolExecutionError + + +def get_headers(context: ToolContext) -> dict[str, str]: + """Build headers for Mastodon API requests.""" + token = context.get_auth_token_or_empty() + if not token: + raise ToolExecutionError(message="No Mastodon token found") + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def get_url( + context: ToolContext, + endpoint: str, + api_version: str = "v1", +) -> str: + """Build the full URL for a Mastodon API endpoint.""" + try: + return f"{context.get_secret('MASTODON_SERVER_URL')}/api/{api_version}/{endpoint}" + except ValueError as e: + raise ToolExecutionError( + message="MASTODON_SERVER_URL is not set in the secrets", + ) from e + + +def parse_status(status: dict[str, Any]) -> dict[str, Any]: + """Filter out unnecessary fields from the status object.""" + return { + "id": status["id"], + "url": status["url"], + "content": status["content"], + "created_at": status["created_at"], + "tags": status["tags"], + "media_attachments": status["media_attachments"], + "account_id": status["account"]["id"], + "account_username": status["account"]["username"], + "account_display_name": status["account"]["display_name"], + "favourites_count": status["favourites_count"], + } + + +def parse_statuses(statuses: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter out unnecessary fields from the statuses object.""" + return [parse_status(status) for status in statuses] diff --git a/toolkits/mastodon/conftest.py b/toolkits/mastodon/conftest.py new file mode 100644 index 000000000..439033c07 --- /dev/null +++ b/toolkits/mastodon/conftest.py @@ -0,0 +1,23 @@ +import pytest +from arcade_tdk import ToolContext, ToolSecretItem + + +@pytest.fixture +def tool_context() -> ToolContext: + return ToolContext( + authorization={"token": "test_token"}, + secrets=[ToolSecretItem(key="MASTODON_SERVER_URL", value="https://mastodon.social")], + ) + + +@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 + + +@pytest.fixture +def mock_lookup_single_user_by_username(mocker): + mock_tool = mocker.patch("arcade_mastodon.tools.statuses.lookup_single_user_by_username") + return mock_tool diff --git a/toolkits/mastodon/evals/eval_mastodon.py b/toolkits/mastodon/evals/eval_mastodon.py new file mode 100644 index 000000000..d6d2aa3e1 --- /dev/null +++ b/toolkits/mastodon/evals/eval_mastodon.py @@ -0,0 +1,137 @@ +from arcade_evals import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade_evals.critic import BinaryCritic, NumericCritic, SimilarityCritic +from arcade_tdk import ToolCatalog + +import arcade_mastodon +from arcade_mastodon.tools.statuses import ( + delete_status_by_id, + lookup_status_by_id, + post_status, + search_recent_statuses_by_keywords, + search_recent_statuses_by_username, +) +from arcade_mastodon.tools.users import lookup_single_user_by_username + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_mastodon) + + +@tool_eval() +def mastodon_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="mastodon Tools Evaluation", + system_message=( + "You are an AI assistant with access to mastodon tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Lookup a user by username", + user_message="Please lookup the user with the username @techblogger", + expected_tool_calls=[ + ExpectedToolCall(func=lookup_single_user_by_username, args={"username": "techblogger"}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="username", weight=1.0), + ], + ) + + suite.add_case( + name="Posting a status", + user_message="Post a status to Mastodon saying 'Hello, world from an AI assistant!'", + expected_tool_calls=[ + ExpectedToolCall( + func=post_status, args={"status": "Hello, world from an AI assistant!"} + ) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="status", weight=1.0), + ], + ) + + suite.add_case( + name="Delete a status by ID", + user_message="Please delete the status with the ID 1234567890", + expected_tool_calls=[ + ExpectedToolCall(func=delete_status_by_id, args={"status_id": "1234567890"}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="status_id", weight=1.0), + ], + ) + + suite.add_case( + name="Lookup a status by ID", + user_message="Please lookup the status with the ID 1234567890", + expected_tool_calls=[ + ExpectedToolCall(func=lookup_status_by_id, args={"status_id": "1234567890"}) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="status_id", weight=1.0), + ], + ) + + suite.add_case( + name="Search for statuses by username", + user_message="Show me the last 10 statuses from the user @techblogger", + expected_tool_calls=[ + ExpectedToolCall( + func=search_recent_statuses_by_username, + args={"username": "techblogger", "limit": 10}, + ) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="username", weight=0.5), + NumericCritic(critic_field="limit", weight=0.5, value_range=(9, 11)), + ], + ) + + suite.add_case( + name="Search for statuses by username with default limit", + user_message="Show me the last statuses from the user @techblogger", + expected_tool_calls=[ + ExpectedToolCall( + func=search_recent_statuses_by_username, args={"username": "techblogger"} + ) + ], + rubric=rubric, + critics=[ + SimilarityCritic(critic_field="username", weight=1.0), + ], + ) + + suite.add_case( + name="Search for statuses by keywords", + user_message="Show me the last statuses with the keywords 'mastodon' and 'ai'", + expected_tool_calls=[ + ExpectedToolCall( + func=search_recent_statuses_by_keywords, args={"keywords": ["mastodon", "ai"]} + ) + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="keywords", weight=1.0), + ], + ) + + return suite diff --git a/toolkits/mastodon/pyproject.toml b/toolkits/mastodon/pyproject.toml new file mode 100644 index 000000000..6172e71db --- /dev/null +++ b/toolkits/mastodon/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "arcade_mastodon" +version = "0.1.0" +description = "Allow the agent to interact with a Mastodon server" +requires-python = ">=3.10" +dependencies = [ + "arcade-tdk>=2.0.0,<3.0.0", +] + + +[project.optional-dependencies] +dev = [ + "arcade-ai[evals]>=2.0.5,<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", +] + +# 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_mastodon/**/*.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_mastodon",] diff --git a/toolkits/mastodon/tests/__init__.py b/toolkits/mastodon/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toolkits/mastodon/tests/test_statuses.py b/toolkits/mastodon/tests/test_statuses.py new file mode 100644 index 000000000..74b9d7dc2 --- /dev/null +++ b/toolkits/mastodon/tests/test_statuses.py @@ -0,0 +1,327 @@ +from unittest.mock import MagicMock + +import httpx +import pytest +from arcade_tdk.errors import RetryableToolError, ToolExecutionError + +from arcade_mastodon.tools.statuses import ( + delete_status_by_id, + lookup_status_by_id, + post_status, + search_recent_statuses_by_keywords, + search_recent_statuses_by_username, +) + +full_status_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed non interdum dolor. Pellentesque augue mauris, venenatis dapibus massa eget, +elementum rutrum ex. Donec et purus egestas, pharetra odio rhoncus, feugiat dolor. +Cras sollicitudin, enim sit amet sagittis consequat, metus mauris blandit neque, eu +finibus leo velit a lacus. Curabitur a justo lorem. Integer iaculis feugiat ex sed imperdiet. +Cras feugiat ut justo quis venenatis. Pellentesque efficitur sit amet enim quis facilisis. +Proin malesuada mollis purus, eu tempus sem venenatis a. +Vestibulum convallis tortor ac elementum gravida. +Sed arcu massa, dictum ac suscipit id, pretium non nulla. Aenean lorem urna, convallis nec elit non, +ehoncus sollicitudin eros. Vestibulum eget dolor consectetur, tempor orci quis, elementum neque. +Aenean quis consectetur justo, vitae faucibus velit. Proin ex metus, lacinia sit amet lorem et, +posuere facilisis nunc. Donec ut viverra sem, ac iaculis neque. + +Morbi blandit ante ut tellus varius fringilla. Quisque eget felis non tellus +lobortis congue. Praesent eu malesuada est. Morbi dui dolor, vehicula non est eu, +dapibus pellentesque sapien. In hac habitasse platea dictumst. Praesent ac est scelerisque, +tristique magna et, viverra nunc. Nunc accumsan magna nec felis varius, ut iaculis velit dapibus. +Aenean sit amet porttitor leo.""" + +truncated_status_text = full_status_text[:500] + + +fake_account = { + "acct": "testuser", + "avatar": "https://files.example.com/image.jpg", + "avatar_static": "https://files.example.com/image.jpg", + "bot": False, + "created_at": "2025-06-27T00:00:00.000Z", + "discoverable": True, + "display_name": "Test User", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "group": False, + "header": "https://example.com/headers/original/missing.png", + "header_static": "https://example.com/headers/original/missing.png", + "hide_collections": None, + "id": "0987654321", + "indexable": True, + "last_status_at": "2025-06-30", + "locked": False, + "noindex": False, + "note": "

Test account for Arcade

", + "roles": [], + "statuses_count": 5, + "uri": "https://example.com/users/testuser", + "url": "https://example.com/@testuser", + "username": "testuser", +} + +fake_status_response = { + "account": fake_account, + "application": {"name": "Test Application", "website": "https://example.com"}, + "bookmarked": False, + "card": None, + "content": "

This is a test status

", + "created_at": "2025-06-30T14:53:12.499Z", + "edited_at": None, + "emojis": [], + "favourited": False, + "favourites_count": 0, + "filtered": [], + "id": "1234567890", + "in_reply_to_account_id": None, + "in_reply_to_id": None, + "language": "en", + "media_attachments": [], + "mentions": [], + "muted": False, + "pinned": False, + "poll": None, + "quote": None, + "reblog": None, + "reblogged": False, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": False, + "spoiler_text": "", + "tags": [], + "uri": "https://example.com/users/testuser/statuses/1234567890", + "url": "https://example.com/@testuser/1234567890", + "visibility": "public", +} + + +fake_parsed_status = { + "account_display_name": "Test User", + "account_id": "0987654321", + "account_username": "testuser", + "content": "

This is a test status

", + "created_at": "2025-06-30T14:53:12.499Z", + "favourites_count": 0, + "id": "1234567890", + "media_attachments": [], + "tags": [], + "url": "https://example.com/@testuser/1234567890", +} + + +@pytest.mark.asyncio +async def test_post_status_success( + tool_context, + httpx_mock, +) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_status_response + httpx_mock.post.return_value = mock_response + + status = "Hello, world!" + result = await post_status( + context=tool_context, + status=status, + ) + + assert result == fake_parsed_status + httpx_mock.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_status_by_id_success( + tool_context, + httpx_mock, +): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_status_response + httpx_mock.delete.return_value = mock_response + + status_id = "1234567890" + result = await delete_status_by_id( + context=tool_context, + status_id=status_id, + ) + + assert result == fake_parsed_status + httpx_mock.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_status_by_id_failure( + tool_context, + httpx_mock, +): + mock_response = httpx.HTTPStatusError( + "Internal Server Error", + request=MagicMock(), + response=MagicMock(status_code=404), + ) + httpx_mock.delete.side_effect = mock_response + + status_id = "1234567890" + with pytest.raises(ToolExecutionError): + await delete_status_by_id( + context=tool_context, + status_id=status_id, + ) + + httpx_mock.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_search_recent_statuses_by_username_success( + tool_context, + httpx_mock, + mock_lookup_single_user_by_username, +): + mock_lookup_single_user_by_username.return_value = {"account": fake_account} + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [fake_status_response] * 5 + httpx_mock.get.return_value = mock_response + + username = "testuser" + result = await search_recent_statuses_by_username( + context=tool_context, + username=username, + ) + + assert "statuses" in result + assert len(result["statuses"]) == 5 + assert result["statuses"][0]["content"] == fake_parsed_status["content"] + httpx_mock.get.assert_called_once() + mock_lookup_single_user_by_username.assert_called_once() + + +@pytest.mark.asyncio +async def test_search_recent_statuses_by_username_no_statuses_found( + tool_context, + httpx_mock, + mock_lookup_single_user_by_username, +): + mock_lookup_single_user_by_username.return_value = {"account": fake_account} + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + httpx_mock.get.return_value = mock_response + + username = "testuser" + result = await search_recent_statuses_by_username( + context=tool_context, + username=username, + ) + + assert "statuses" in result + assert len(result["statuses"]) == 0 + httpx_mock.get.assert_called_once() + mock_lookup_single_user_by_username.assert_called_once() + + +@pytest.mark.asyncio +async def test_search_recent_statuses_by_username_failure( + tool_context, + httpx_mock, + mock_lookup_single_user_by_username, +): + mock_lookup_single_user_by_username.return_value = {"account": None} + mock_response = httpx.HTTPStatusError( + "Internal Server Error", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + httpx_mock.get.side_effect = mock_response + + username = "testuser" + with pytest.raises(ToolExecutionError): + await search_recent_statuses_by_username( + context=tool_context, + username=username, + ) + + httpx_mock.get.assert_not_called() + mock_lookup_single_user_by_username.assert_called_once() + + +@pytest.mark.asyncio +async def test_search_recent_statuses_by_keywords_success( + tool_context, + httpx_mock, +): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [fake_status_response] * 5 + httpx_mock.get.return_value = mock_response + + keywords = ["test", "keyword"] + phrases = ["test phrase"] + result = await search_recent_statuses_by_keywords( + context=tool_context, + keywords=keywords, + phrases=phrases, + ) + + assert "statuses" in result + assert len(result["statuses"]) == 5 + assert result["statuses"][0]["content"] == fake_parsed_status["content"] + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_search_recent_statuses_by_keywords_no_keywords_or_phrases( + tool_context, + httpx_mock, +): + with pytest.raises(RetryableToolError): + await search_recent_statuses_by_keywords( + context=tool_context, + ) + + httpx_mock.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_lookup_status_by_id_success( + tool_context, + httpx_mock, +): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_status_response + httpx_mock.get.return_value = mock_response + + status_id = "1234567890" + result = await lookup_status_by_id( + context=tool_context, + status_id=status_id, + ) + + assert result == fake_parsed_status + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_lookup_status_by_id_failure( + tool_context, + httpx_mock, +): + mock_response = httpx.HTTPStatusError( + "Not Found", + request=MagicMock(), + response=MagicMock(status_code=404), + ) + httpx_mock.get.side_effect = mock_response + + status_id = "1234567890" + with pytest.raises(ToolExecutionError): + await lookup_status_by_id( + context=tool_context, + status_id=status_id, + ) + + httpx_mock.get.assert_called_once() diff --git a/toolkits/mastodon/tests/test_users.py b/toolkits/mastodon/tests/test_users.py new file mode 100644 index 000000000..d3702bd56 --- /dev/null +++ b/toolkits/mastodon/tests/test_users.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +import httpx +import pytest +from arcade_tdk.errors import ToolExecutionError + +from arcade_mastodon.tools.users import lookup_single_user_by_username + +fake_account = { + "acct": "testuser", + "avatar": "https://files.example.com/image.jpg", + "avatar_static": "https://files.example.com/image.jpg", + "bot": False, + "created_at": "2025-06-27T00:00:00.000Z", + "discoverable": True, + "display_name": "Test User", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "group": False, + "header": "https://example.com/headers/original/missing.png", + "header_static": "https://example.com/headers/original/missing.png", + "hide_collections": None, + "id": "0987654321", + "indexable": True, + "last_status_at": "2025-06-30", + "locked": False, + "noindex": False, + "note": "

Test account for Arcade

", + "roles": [], + "statuses_count": 5, + "uri": "https://example.com/users/testuser", + "url": "https://example.com/@testuser", + "username": "testuser", +} + + +@pytest.mark.asyncio +async def test_lookup_single_user_by_username_success(tool_context, httpx_mock): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = fake_account + httpx_mock.get.return_value = mock_response + + username = "testuser" + result = await lookup_single_user_by_username( + context=tool_context, + username=username, + ) + + assert result == {"account": fake_account} + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_lookup_single_user_by_username_user_not_found(tool_context, httpx_mock): + mock_response = httpx.HTTPStatusError( + "Not Found", + request=MagicMock(), + response=MagicMock(status_code=404), + ) + httpx_mock.get.side_effect = mock_response + + username = "testuser" + with pytest.raises(ToolExecutionError): + await lookup_single_user_by_username( + context=tool_context, + username=username, + ) + + httpx_mock.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_lookup_single_user_by_username_failure(tool_context, httpx_mock): + mock_response = httpx.HTTPStatusError( + "Internal Server Error", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + httpx_mock.get.side_effect = mock_response + + username = "testuser" + with pytest.raises(ToolExecutionError): + await lookup_single_user_by_username( + context=tool_context, + username=username, + ) + + httpx_mock.get.assert_called_once()