### Overview Major restructuring from monolithic `arcade-ai` package to modular library architecture with standardized uv-based dependency management.  ### 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>
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, cast
|
|
|
|
from bs4 import BeautifulSoup
|
|
from msgraph.generated.models.body_type import BodyType
|
|
from msgraph.generated.models.email_address import EmailAddress
|
|
from msgraph.generated.models.item_body import ItemBody
|
|
from msgraph.generated.models.message import Message as GraphMessage
|
|
from msgraph.generated.models.recipient import Recipient as GraphRecipient
|
|
|
|
|
|
@dataclass
|
|
class Recipient:
|
|
"""A recipient of an email message."""
|
|
|
|
email_address: str = ""
|
|
name: str = ""
|
|
|
|
@classmethod
|
|
def from_sdk(cls, recipient: GraphRecipient) -> "Recipient":
|
|
"""Convert a Microsoft Graph SDK Recipient object to a Recipient dataclass."""
|
|
address = (
|
|
recipient.email_address.address
|
|
if recipient and recipient.email_address and recipient.email_address.address
|
|
else ""
|
|
)
|
|
name = (
|
|
recipient.email_address.name
|
|
if recipient and recipient.email_address and recipient.email_address.name
|
|
else ""
|
|
)
|
|
return cls(email_address=address, name=name)
|
|
|
|
def to_dict(self) -> dict[str, str]:
|
|
return {"email_address": self.email_address, "name": self.name}
|
|
|
|
def to_sdk(self) -> GraphRecipient:
|
|
"""Converts the Recipient dataclass to a Microsoft Graph SDK Recipient object."""
|
|
recipient = GraphRecipient()
|
|
email_address = EmailAddress()
|
|
email_address.address = self.email_address
|
|
email_address.name = self.name
|
|
recipient.email_address = email_address
|
|
return recipient
|
|
|
|
|
|
@dataclass
|
|
class Message:
|
|
"""An email message in Outlook."""
|
|
|
|
bcc_recipients: list[Recipient] = field(default_factory=list)
|
|
cc_recipients: list[Recipient] = field(default_factory=list)
|
|
reply_to: list[Recipient] = field(default_factory=list)
|
|
to_recipients: list[Recipient] = field(default_factory=list)
|
|
from_: Recipient = field(default_factory=Recipient)
|
|
subject: str = ""
|
|
body: str = ""
|
|
conversation_id: str = ""
|
|
conversation_index: str = ""
|
|
flag: dict[str, str] = field(default_factory=dict)
|
|
has_attachments: bool = False
|
|
importance: str = ""
|
|
is_read: bool = False
|
|
received_date_time: str = ""
|
|
web_link: str = ""
|
|
is_draft: bool = True
|
|
message_id: str = "" # The unique identifier of the email message. Read-only.
|
|
|
|
@staticmethod
|
|
def _safe_str(value: Any) -> str:
|
|
if not value:
|
|
return ""
|
|
if isinstance(value, bytes | bytearray):
|
|
return value.decode("utf-8", errors="ignore")
|
|
return str(value)
|
|
|
|
@staticmethod
|
|
def _safe_bool(value: Any) -> bool:
|
|
return bool(value)
|
|
|
|
@staticmethod
|
|
def _parse_body(mime: str) -> str:
|
|
if not mime:
|
|
return ""
|
|
soup = BeautifulSoup(mime, "html.parser")
|
|
text = soup.get_text(separator=" ")
|
|
# Replace multiple newlines with a single newline
|
|
text = re.sub(r"\n+", "\n", text)
|
|
# Replace multiple spaces with a single space
|
|
text = re.sub(r"\s+", " ", text)
|
|
# Remove leading/trailing whitespace from each line
|
|
text = "\n".join(line.strip() for line in text.split("\n"))
|
|
|
|
return text
|
|
|
|
@staticmethod
|
|
def _parse_importance(value: Any) -> str:
|
|
return cast(str, value.value) if getattr(value, "value", None) else ""
|
|
|
|
@staticmethod
|
|
def _parse_flag(flag: Any) -> dict[str, str]:
|
|
if not flag:
|
|
return {"flag_status": "", "due_date_time": ""}
|
|
status = flag.flag_status.value if getattr(flag, "flag_status", None) else ""
|
|
due = ""
|
|
if getattr(flag, "due_date_time", None) and getattr(flag.due_date_time, "date_time", None):
|
|
due = Message._safe_str(flag.due_date_time.date_time)
|
|
return {"flag_status": status, "due_date_time": due}
|
|
|
|
@classmethod
|
|
def from_sdk(cls, msg: GraphMessage) -> "Message":
|
|
"""Convert a Microsoft Graph SDK Message object to a Message dataclass."""
|
|
text = cls._parse_body(msg.body.content if msg.body and msg.body.content else "")
|
|
return cls(
|
|
bcc_recipients=[
|
|
Recipient.from_sdk(recipient) for recipient in msg.bcc_recipients or []
|
|
],
|
|
cc_recipients=[Recipient.from_sdk(recipient) for recipient in msg.cc_recipients or []],
|
|
reply_to=[Recipient.from_sdk(recipient) for recipient in msg.reply_to or []],
|
|
to_recipients=[Recipient.from_sdk(recipient) for recipient in msg.to_recipients or []],
|
|
from_=Recipient.from_sdk(msg.from_) if msg.from_ else Recipient(),
|
|
subject=cls._safe_str(msg.subject),
|
|
body=text,
|
|
conversation_id=cls._safe_str(msg.conversation_id),
|
|
conversation_index=(
|
|
msg.conversation_index.decode("utf-8", errors="ignore")
|
|
if isinstance(msg.conversation_index, bytes | bytearray)
|
|
else cls._safe_str(msg.conversation_index)
|
|
),
|
|
flag=cls._parse_flag(msg.flag),
|
|
has_attachments=cls._safe_bool(msg.has_attachments),
|
|
importance=cls._parse_importance(msg.importance),
|
|
is_read=cls._safe_bool(msg.is_read),
|
|
received_date_time=(
|
|
msg.received_date_time.isoformat() if msg.received_date_time else ""
|
|
),
|
|
web_link=cls._safe_str(msg.web_link),
|
|
is_draft=cls._safe_bool(msg.is_draft),
|
|
message_id=cls._safe_str(msg.id),
|
|
)
|
|
|
|
def to_sdk(self) -> GraphMessage:
|
|
"""Converts the Message dataclass to a Microsoft Graph SDK Message object."""
|
|
sdk_msg = GraphMessage()
|
|
sdk_msg.subject = self.subject
|
|
body_obj = ItemBody()
|
|
body_obj.content = self.body
|
|
body_obj.content_type = BodyType.Text
|
|
sdk_msg.body = body_obj
|
|
sdk_msg.is_draft = self.is_draft
|
|
sdk_msg.to_recipients = [r.to_sdk() for r in self.to_recipients]
|
|
sdk_msg.cc_recipients = [r.to_sdk() for r in self.cc_recipients]
|
|
sdk_msg.bcc_recipients = [r.to_sdk() for r in self.bcc_recipients]
|
|
sdk_msg.reply_to = [r.to_sdk() for r in self.reply_to]
|
|
|
|
return sdk_msg
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Converts the Message dataclass to a dictionary."""
|
|
return {
|
|
"bcc_recipients": [recipient.to_dict() for recipient in self.bcc_recipients],
|
|
"cc_recipients": [recipient.to_dict() for recipient in self.cc_recipients],
|
|
"reply_to": [recipient.to_dict() for recipient in self.reply_to],
|
|
"to_recipients": [recipient.to_dict() for recipient in self.to_recipients],
|
|
"from": self.from_.to_dict(),
|
|
"subject": self.subject,
|
|
"body": self.body,
|
|
"conversation_id": self.conversation_id,
|
|
"conversation_index": self.conversation_index,
|
|
"flag": self.flag,
|
|
"has_attachments": self.has_attachments,
|
|
"importance": self.importance,
|
|
"is_read": self.is_read,
|
|
"received_date_time": self.received_date_time,
|
|
"web_link": self.web_link,
|
|
"is_draft": self.is_draft,
|
|
"message_id": self.message_id,
|
|
}
|
|
|
|
def update_recipient_lists(
|
|
self,
|
|
to_add: list[str] | None = None,
|
|
to_remove: list[str] | None = None,
|
|
cc_add: list[str] | None = None,
|
|
cc_remove: list[str] | None = None,
|
|
bcc_add: list[str] | None = None,
|
|
bcc_remove: list[str] | None = None,
|
|
) -> None:
|
|
"""Update each recipient list of the message.
|
|
|
|
This function updates the recipient lists of the message by first adding new recipients
|
|
and then removing existing recipients. Therefore, if an email address is both
|
|
added and removed, then it will not be included in the returned list.
|
|
"""
|
|
for attr, add_emails_input, remove_emails_input in (
|
|
("to_recipients", to_add, to_remove),
|
|
("cc_recipients", cc_add, cc_remove),
|
|
("bcc_recipients", bcc_add, bcc_remove),
|
|
):
|
|
current_recipients = getattr(self, attr) or []
|
|
# Add recipients
|
|
existing_emails = {r.email_address.lower() for r in current_recipients}
|
|
new_additions = [
|
|
Recipient(email_address=email)
|
|
for email in (add_emails_input or [])
|
|
if email.lower() not in existing_emails
|
|
]
|
|
# Remove recipients
|
|
updated_list = current_recipients + new_additions
|
|
remove_emails = {email.lower() for email in (remove_emails_input or [])}
|
|
updated_list = [
|
|
recipient
|
|
for recipient in updated_list
|
|
if recipient.email_address.lower() not in remove_emails
|
|
]
|
|
# Update the message's attribute with the new list
|
|
setattr(self, attr, updated_list)
|