arcade-mcp/toolkits/confluence/arcade_confluence/client.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### New Package Structure
- **`arcade-tdk`** - Lightweight toolkit development kit (core
decorators, auth)
- **`arcade-core`** - Core execution engine and catalog functionality  
- **`arcade-serve`** - FastAPI/MCP server components
- **`arcade-ai`** - Meta package that includes CLI functionality.
Optionally include evals via the `evals` extra. Optionally include all
packages via the `all` extra.

### Key Benefits
- **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2
deps) vs full `arcade-ai` (~30+ deps)
- **Faster Builds**: uv provides 10-100x faster dependency resolution
and installation
- **Better Modularity**: Clear separation of concerns, consumers import
only what they need
- **Standard Tooling**: Eliminates custom poetry scripts, uses standard
Python packaging

### Migration Impact
- All 20 toolkits converted from poetry → uv with `arcade-tdk`
dependencies plus `arcade-ai[evals]` and `arcade-serve` dev
dependencies. When developing locally, devs should install toolkits via
`make install-local`.
- Modern Python 3.10+ type hints throughout
- Standardized build system with hatchling backend
- Enhanced Makefile with robust toolkit management commands
- Removed `arcade dev` CLI command
- Reduce the number of files created by `arcade new` and add an option
to not generate a tests and evals folder.

This foundation enables faster development cycles and cleaner dependency
chains for the growing toolkit ecosystem.

### Todo After this PR is merged
- [ ] Post-merge workflow(s) (release & publish containers, etc)
- [ ] Release order plan. @EricGustin suggests releasing in the
following order:
    1. `arcade-core` version 0.1.0
    2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0
    3. `arcade-ai` version 2.0.0
4. Patch release for all toolkits (all changes in toolkits are internal
refactors)
- [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318)

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-06-11 16:48:17 -07:00

617 lines
22 KiB
Python

from enum import Enum
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
from arcade_tdk.errors import ToolExecutionError
from arcade_confluence.enums import BodyFormat, PageUpdateMode
from arcade_confluence.utils import (
build_child_url,
build_hierarchy,
remove_none_values,
)
class ConfluenceAPIVersion(str, Enum):
V1 = "wiki/rest/api"
V2 = "wiki/api/v2"
class ConfluenceClient:
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
BASE_URL = "https://api.atlassian.com/ex/confluence"
def __init__(self, token: str, api_version: ConfluenceAPIVersion):
self.token = token
self.cloud_id = self._get_cloud_id()
self.api_version = api_version.value
def _get_cloud_id(self) -> str:
"""
Fetch the cloudId for <workspace_name>.atlassian.net
using the OAuth2 3LO accessible-resources endpoint.
For details on why this is necessary, see: https://developer.atlassian.com/cloud/oauth/getting-started/making-calls-to-api
"""
headers = {"Authorization": f"Bearer {self.token}"}
resp = httpx.get(self.ACCESSIBLE_RESOURCES_URL, headers=headers)
resp.raise_for_status()
resp_json = resp.json()
if len(resp_json) == 0:
raise ToolExecutionError(message="No workspaces found for the authenticated user.")
return resp_json[0].get("id") # type: ignore[no-any-return]
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.token}",
}
async with httpx.AsyncClient() as client:
response = await client.request(
method,
f"{self.BASE_URL}/{self.cloud_id}/{self.api_version}/{path.lstrip('/')}",
headers=headers,
**kwargs,
)
response.raise_for_status()
return response.json()
async def get(self, path: str, **kwargs: Any) -> Any:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs: Any) -> Any:
return await self.request("POST", path, **kwargs)
async def put(self, path: str, **kwargs: Any) -> Any:
return await self.request("PUT", path, **kwargs)
class ConfluenceClientV1(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V1)
def _build_query_cql(self, query: str, enable_fuzzy: bool) -> str:
"""Build CQL for a single query (term or phrase).
Args:
query: The search query (single word term or multi-word phrase)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string for the query
"""
query = query.strip()
if not query:
return ""
# For phrases (multiple words), don't use fuzzy matching
if " " in query:
return f'(text ~ "{query}" OR title ~ "{query}" OR space.title ~ "{query}")'
else:
# For single terms, optionally use fuzzy matching
term_suffix = "~" if enable_fuzzy else ""
return f'(text ~ "{query}{term_suffix}" OR title ~ "{query}{term_suffix}" OR space.title ~ "{query}{term_suffix}")' # noqa: E501
def _build_and_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries that must ALL be present (AND logic).
Args:
queries: List of queries that must all be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with AND logic
"""
and_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
and_parts.append(query_cql)
if not and_parts:
return ""
return f"({' AND '.join(and_parts)})"
def _build_or_cql(self, queries: list[str], enable_fuzzy: bool) -> str:
"""Build CQL for queries where ANY can be present (OR logic).
Args:
queries: List of queries where any can be present
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL string with OR logic
"""
or_parts = []
for query in queries:
query_cql = self._build_query_cql(query, enable_fuzzy)
if query_cql:
or_parts.append(query_cql)
if not or_parts:
return ""
return f"({' OR '.join(or_parts)})"
def construct_cql(
self,
must_contain_all: list[str] | None,
can_contain_any: list[str] | None,
enable_fuzzy: bool = False,
) -> str:
"""Construct CQL query with AND/OR logic.
Learn about advanced searching using CQL here: https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/
Args:
must_contain_all: Queries that must ALL be present (AND logic)
can_contain_any: Queries where ANY can be present (OR logic)
enable_fuzzy: Whether to enable fuzzy matching for single terms
Returns:
CQL query string
Raises:
ToolExecutionError: If no search parameters are provided
"""
cql_parts = []
# Handle must_contain_all (AND logic)
if must_contain_all:
and_cql = self._build_and_cql(must_contain_all, enable_fuzzy)
if and_cql:
cql_parts.append(and_cql)
# Handle can_contain_any (OR logic)
if can_contain_any:
or_cql = self._build_or_cql(can_contain_any, enable_fuzzy)
if or_cql:
cql_parts.append(or_cql)
# If there's only one part, return it
if len(cql_parts) == 1:
return cql_parts[0]
# AND the must_contain_all with the can_contain_any
if len(cql_parts) > 1:
return f"({' AND '.join(cql_parts)})"
raise ToolExecutionError(message="At least one search parameter must be provided")
def transform_search_content_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /search endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
base_url = response.get("_links", {}).get("base", "")
transformed_results = []
for result in response.get("results", []):
content = result.get("content", {})
transformed_result = {
"id": content.get("id"),
"title": content.get("title"),
"type": content.get("type"),
"status": content.get("status"),
"excerpt": result.get("excerpt"),
"url": f"{base_url}{result.get('url')}",
}
transformed_results.append(transformed_result)
return {"results": transformed_results}
class ConfluenceClientV2(ConfluenceClient):
def __init__(self, token: str):
super().__init__(token, api_version=ConfluenceAPIVersion.V2)
def _transform_links(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, Any]:
"""
Transform the links in a page response by converting relative URLs to absolute URLs.
Args:
response: A page object from the API
base_url: The base URL to use for the transformation
Returns:
The transformed response
"""
result = response.copy()
if "_links" in result:
base_url = base_url or result["_links"].get("base", "")
webui_path = result["_links"].get("webui", "")
result["url"] = f"{base_url}{webui_path}"
del result["_links"]
return result
def transform_get_spaces_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""
Transform the response from the GET /spaces endpoint by converting relative webui paths
to absolute URLs using the base URL from the response.
"""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
results = response.get("results", [])
transformed_results = []
for space in results:
space_copy = space.copy()
if "_links" in space_copy and "webui" in space_copy["_links"]:
webui_path = space_copy["_links"]["webui"]
space_copy["url"] = base_url + webui_path
del space_copy["_links"]
transformed_results.append(space_copy)
results = {"spaces": transformed_results, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_list_pages_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
results = {"pages": pages, "pagination_token": pagination_token}
return remove_none_values(results)
def transform_get_multiple_pages_response(
self, response: dict[str, Any]
) -> dict[str, list[dict[str, Any]]]:
"""Transform the response from the GET /pages endpoint."""
base_url = response.get("_links", {}).get("base", "")
pages = [self._transform_links(page, base_url) for page in response["results"]]
return {"pages": pages}
def transform_space_response(
self, response: dict[str, Any], base_url: str | None = None
) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a space object."""
return {"space": self._transform_links(response, base_url)}
def transform_page_response(self, response: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Transform API responses that return a page object."""
return {"page": self._transform_links(response)}
def transform_get_attachments_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Transform the response from the GET /pages/{id}/attachments endpoint."""
pagination_token = parse_qs(urlparse(response.get("_links", {}).get("next", "")).query).get(
"cursor",
[None], # type: ignore[list-item]
)[0]
base_url = response.get("_links", {}).get("base", "")
attachments = []
for attachment in response["results"]:
result = attachment.copy()
if "_links" in result:
webui_path = result["_links"].get("webui", "")
download_path = result["_links"].get("download", "")
result["url"] = f"{base_url}{webui_path}"
result["download_link"] = f"{base_url}{download_path}"
del result["_links"]
del result["webuiLink"]
del result["downloadLink"]
del result["version"]
attachments.append(result)
return {"attachments": attachments, "pagination_token": pagination_token}
def prepare_update_page_payload(
self,
page_id: str,
status: str,
title: str,
body_representation: str,
body_value: str,
version_number: int,
version_message: str,
) -> dict[str, Any]:
"""Prepare a payload for the PUT /pages/{id} endpoint."""
return {
"id": page_id,
"status": status,
"title": title,
"body": {
"representation": body_representation,
"value": body_value,
},
"version": {
"number": version_number,
"message": version_message,
},
}
def prepare_update_page_content_payload(
self,
content: str,
update_mode: PageUpdateMode,
old_content: str,
page_id: str,
status: str,
title: str,
body_representation: BodyFormat,
old_version_number: int,
) -> dict[str, Any]:
"""Prepare a payload for when updating the content of a page
Args:
content: The content to update the page with
update_mode: The mode of update to use
old_content: The content of the page before the update
page_id: The ID of the page to update
status: The status of the page
title: The title of the page
body_representation: The format that the body (content) is in
old_version_number: The version number of the page before the update
Returns:
A payload for the PUT /pages/{id} endpoint's json body
"""
updated_content = ""
updated_message = ""
if update_mode == PageUpdateMode.APPEND:
updated_content = f"{old_content}<br/>{content}"
updated_message = "Append content to the page"
elif update_mode == PageUpdateMode.PREPEND:
updated_content = f"{content}<br/>{old_content}"
updated_message = "Prepend content to the page"
elif update_mode == PageUpdateMode.REPLACE:
updated_content = content
updated_message = "Replace the page content"
payload = self.prepare_update_page_payload(
page_id=page_id,
status=status,
title=title,
body_representation=body_representation.to_api_value(),
body_value=updated_content,
version_number=old_version_number + 1,
version_message=updated_message,
)
return payload
async def get_root_pages_in_space(self, space_id: str) -> dict[str, Any]:
"""
Get the root pages in a space.
Requires Confluence scope 'read:page:confluence'
"""
params = {
"depth": "root",
"limit": 250,
}
pages = await self.get(f"spaces/{space_id}/pages", params=params)
base_url = pages.get("_links", {}).get("base", "")
return {"pages": [self._transform_links(page, base_url) for page in pages["results"]]}
async def get_space_homepage(self, space_id: str) -> dict[str, Any]:
"""
Get the homepage of a space.
Requires Confluence scope 'read:page:confluence'
"""
root_pages = await self.get_root_pages_in_space(space_id)
for page in root_pages["pages"]:
if page.get("url", "").endswith("overview"):
return self._transform_links(page)
raise ToolExecutionError(message="No homepage found for space.")
async def get_page_by_id(
self, page_id: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its ID.
Requires Confluence scope 'read:page:confluence'
Args:
page_id: The ID of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = remove_none_values({
"body-format": content_format.to_api_value(),
})
try:
page = await self.get(f"pages/{page_id}", params=params)
except httpx.HTTPStatusError as e:
# If the page is not found, return an empty page object
if e.response.status_code in [400, 404]:
return self.transform_page_response({})
raise
return self.transform_page_response(page)
async def get_page_by_title(
self, page_title: str, content_format: BodyFormat = BodyFormat.STORAGE
) -> dict[str, Any]:
"""Get a page by its title.
Requires Confluence scope 'read:page:confluence'
Args:
page_title: The title of the page to get
content_format: The format of the page content
Returns:
The page object
"""
params = {
"title": page_title,
"body-format": content_format.to_api_value(),
}
response = await self.get("pages", params=params)
pages = response.get("results", [])
if not pages:
# If the page is not found, return an empty page object
return self.transform_page_response({})
return self.transform_page_response(pages[0])
async def get_space_by_id(self, space_id: str) -> dict[str, Any]:
"""Get a space by its ID.
Requires Confluence scope 'read:space:confluence'
Args:
space_id: The ID of the space to get
Returns:
The space object
"""
space = await self.get(f"spaces/{space_id}")
return self.transform_space_response(space)
async def get_space_by_key(self, space_key: str) -> dict[str, Any]:
"""Get a space by its key.
Requires Confluence scope 'read:space:confluence'
Args:
space_key: The key of the space to get
Returns:
The space object
"""
response = await self.get("spaces", params={"keys": [space_key]})
base_url = response.get("_links", {}).get("base", "")
spaces = response.get("results", [])
if not spaces:
raise ToolExecutionError(message=f"No space found with key: '{space_key}'")
return self.transform_space_response(spaces[0], base_url=base_url)
async def get_space(self, space_identifier: str) -> dict[str, Any]:
"""Get a space from its identifier.
Requires Confluence scope 'read:space:confluence'
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or key.
"""
return (
await self.get_space_by_id(space_identifier)
if space_identifier.isdigit()
else await self.get_space_by_key(space_identifier)
)
async def get_page_id(self, page_identifier: str) -> str:
"""Get the ID of a page from its identifier.
Args:
page_identifier: The identifier of the page to get. Can be a page's ID or title.
Returns:
The ID of the page
"""
if page_identifier.isdigit():
page_id = page_identifier
else:
page = await self.get_page_by_title(page_identifier)
page_id = page.get("page", {}).get("id")
if not page_id:
raise ToolExecutionError(message=f"No page found with identifier: '{page_identifier}'")
return page_id
async def get_space_id(self, space_identifier: str) -> str:
"""Get the ID of a space from its identifier.
Args:
space_identifier: The identifier of the space to get. Can be a space's ID or title.
"""
if space_identifier.isdigit():
space_id = space_identifier
else:
space = await self.get_space_by_key(space_identifier)
space_id = space.get("space", {}).get("id")
return space_id
def create_space_tree(self, space: dict) -> dict:
"""Create the initial tree structure for a space.
Args:
space: The transformed space object
Returns:
A dictionary representing the root of the space hierarchy tree without any children
"""
space_internal = space.get("space", {})
return {
"key": space_internal.get("key"),
"id": space_internal.get("id"),
"type": "space",
"url": space_internal.get("url"),
"children": [],
}
def convert_root_pages_to_tree_nodes(self, pages: list) -> list:
"""Convert root pages of a space to tree nodes.
Args:
pages: List of page objects from the API
Returns:
A list of tree nodes representing the root pages
"""
return [
{
"title": page.get("title"),
"id": page.get("id"),
"type": "page",
"url": page.get("url"),
"children": [],
}
for page in pages
]
async def process_page_descendants(self, root_children: list, base_url: str) -> None:
"""Process descendants for each page and build the hierarchy.
Args:
root_children: The root children of the space
base_url: The base URL for the Confluence space
Returns:
None (modifies root_children in place)
"""
descendent_params = {"limit": 250, "depth": 5}
for i, child in enumerate(root_children):
page_id = child["id"]
descendants = await self.get(f"pages/{page_id}/descendants", params=descendent_params)
# Transform descendants into our desired format
transformed_children = []
for descendant in descendants.get("results", []):
transformed_child = {
"title": descendant.get("title"),
"id": descendant.get("id"),
"type": descendant.get("type"),
"parent_id": page_id
if descendant.get("parentId") is None
else descendant.get("parentId"),
"parent_type": descendant.get("parentType", "TODO"),
"url": build_child_url(base_url, descendant),
"children": [],
"status": descendant.get("status"),
}
transformed_children.append(transformed_child)
# Build the hierarchy for the current root page
build_hierarchy(transformed_children, page_id, root_children[i])