arcade-mcp/toolkits/notion/arcade_notion_toolkit/tools/pages.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

152 lines
6 KiB
Python

import asyncio
from typing import Annotated, Any
import httpx
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Notion
from arcade_tdk.errors import ToolExecutionError
from arcade_notion_toolkit.block_to_markdown_converter import BlockToMarkdownConverter
from arcade_notion_toolkit.enums import BlockType, ObjectType
from arcade_notion_toolkit.markdown_to_block_converter import convert_markdown_to_blocks
from arcade_notion_toolkit.tools.search import get_object_metadata
from arcade_notion_toolkit.types import DatabaseParent, PageWithPageParentProperties, create_parent
from arcade_notion_toolkit.utils import (
extract_title,
get_headers,
get_next_page,
get_url,
)
@tool(requires_auth=Notion())
async def get_page_content_by_id(
context: ToolContext, page_id: Annotated[str, "ID of the page to get content from"]
) -> Annotated[str, "The markdown content of the page"]:
"""Get the content of a Notion page as markdown with the page's ID"""
headers = get_headers(context)
params = {"page_size": 100}
converter = BlockToMarkdownConverter(context)
async with httpx.AsyncClient() as client:
async def fetch_blocks(block_id: str) -> list:
"""Fetch all immediate children blocks for a given block ID, handling pagination"""
all_blocks = []
url = get_url("retrieve_block_children", block_id=block_id)
cursor = None
while True:
data, has_more, cursor = await get_next_page(client, url, headers, params, cursor)
all_blocks.extend(data.get("results", []))
if not has_more:
break
return all_blocks
async def process_blocks_to_markdown(blocks: list, indent: str = "") -> str:
"""Process a list of blocks into markdown.
If a block has children, we recurse into the children blocks.
"""
markdown_pieces = []
for block in blocks:
block_markdown = await converter.convert_block(block)
if block_markdown:
# Append each line with indent as a separate piece
for line in block_markdown.rstrip("\n").splitlines():
markdown_pieces.append(indent + line + "\n")
# If the block has children and is not a child page, recurse.
# We don't recurse into child page content, as this would result in fetching
# the children pages' content, which the Notion UI does not show.
if (
block.get("has_children", False)
and block.get("type") != BlockType.CHILD_PAGE.value
):
# Fetch all child blocks first
child_blocks = await fetch_blocks(block["id"])
# Then process them all at once
child_markdown = await process_blocks_to_markdown(child_blocks, indent + " ")
markdown_pieces.append(child_markdown)
return "".join(markdown_pieces)
# Get the title
page_metadata = await get_object_metadata(context, object_id=page_id)
markdown_title = f"# {extract_title(page_metadata)}\n"
# Get all top-level blocks
top_level_blocks = await fetch_blocks(page_id)
chunk_size = max(1, len(top_level_blocks) // 5)
chunks = [
top_level_blocks[i : i + chunk_size]
for i in range(0, len(top_level_blocks), chunk_size)
]
# Process all block content into markdown
results = await asyncio.gather(*[process_blocks_to_markdown(chunk, "") for chunk in chunks])
markdown_content = "".join(results)
return markdown_title + markdown_content
@tool(requires_auth=Notion())
async def get_page_content_by_title(
context: ToolContext, title: Annotated[str, "Title of the page to get content from"]
) -> Annotated[str, "The markdown content of the page"]:
"""Get the content of a Notion page as markdown with the page's title"""
page_metadata = await get_object_metadata(
context, object_title=title, object_type=ObjectType.PAGE
)
page_content: str = await get_page_content_by_id(context, page_metadata["id"])
return page_content
@tool(requires_auth=Notion())
async def create_page(
context: ToolContext,
parent_title: Annotated[
str,
"Title of an existing page/database within which the new page will be created. ",
],
title: Annotated[str, "Title of the new page"],
content: Annotated[str | None, "The content of the new page"] = None,
) -> Annotated[str, "The ID of the new page"]:
"""Create a new Notion page by the title of the new page's parent."""
# Notion API does not support creating a page at the root of the workspace... sigh
parent_metadata = await get_object_metadata(
context,
parent_title,
object_type=ObjectType.PAGE,
)
parent_type = parent_metadata["object"] + "_id"
parent = create_parent({"type": parent_type, parent_type: parent_metadata["id"]})
properties: dict[str, Any] = {}
if isinstance(parent, DatabaseParent):
# TODO: Support creating a page within a database
raise ToolExecutionError(
message="Creating a page within a database is not supported.",
developer_message="Database is not supported as a parent of a new page at this time.",
)
else:
properties = PageWithPageParentProperties(title=title).to_dict()
children = convert_markdown_to_blocks(content) if content else None
body = {
"parent": parent.to_dict(),
"properties": properties,
"children": children,
}
url = get_url("create_a_page")
headers = get_headers(context)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=body)
response.raise_for_status()
return f"Successfully created page with ID: {response.json()['id']}"