Add Outlook Calendar tools (#382)

This commit is contained in:
Eric Gustin 2025-05-09 13:34:40 -08:00 committed by GitHub
parent c128717e53
commit 419a0ac717
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1592 additions and 4 deletions

View file

@ -4,7 +4,7 @@ from typing import Any
from azure.core.credentials import AccessToken, TokenCredential
from msgraph import GraphServiceClient
from arcade_microsoft.outlook_mail.constants import DEFAULT_SCOPE
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
class StaticTokenCredential(TokenCredential):

View file

@ -0,0 +1,7 @@
from arcade_microsoft.outlook_calendar.tools import (
create_event,
get_event,
list_events_in_time_range,
)
__all__ = ["create_event", "get_event", "list_events_in_time_range"]

View file

@ -0,0 +1,225 @@
import re
from datetime import datetime
from typing import Any
import pytz
from arcade.sdk.errors import ToolExecutionError
from kiota_abstractions.base_request_configuration import RequestConfiguration
from kiota_abstractions.headers_collection import HeadersCollection
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mailbox_settings.mailbox_settings_request_builder import (
MailboxSettingsRequestBuilder,
)
from arcade_microsoft.outlook_calendar.constants import WINDOWS_TO_IANA
def validate_date_times(start_date_time: str, end_date_time: str) -> None:
"""
Validate date times are in ISO 8601 format and
that end time is after start time (ignoring timezone offsets).
Args:
start_date_time: The start date time string to validate.
end_date_time: The end date time string to validate.
Raises:
ValueError: If the date times are not in ISO 8601 format
ToolExecutionError: If end time is not after start time.
Note:
This function ignores timezone offsets.
"""
# parse into offset-aware datetimes
start_aware = datetime.fromisoformat(start_date_time)
end_aware = datetime.fromisoformat(end_date_time)
# drop tzinfo to treat both as naïve local times
start_naive = start_aware.replace(tzinfo=None)
end_naive = end_aware.replace(tzinfo=None)
if start_naive >= end_naive:
raise ToolExecutionError(
message="Start time must be before end time",
developer_message=(
f"The start time '{start_naive}' is not before the end time '{end_naive}'"
),
)
def prepare_meeting_body(
body: str, custom_meeting_url: str | None, is_online_meeting: bool
) -> tuple[str, bool]:
"""Prepare meeting body and determine final online meeting status.
Args:
body: The original meeting body text
custom_meeting_url: Custom URL for the meeting, if one exists
is_online_meeting: Whether this should be an online meeting
Returns:
tuple: (Updated meeting body, final online meeting status)
Note:
If a custom meeting URL is provided, is_online_meeting will be set to False
to prevent Microsoft from generating its own meeting URL. The custom meeting
URL will then be added to the body of the meeting.
"""
is_online_meeting = not custom_meeting_url and is_online_meeting
if custom_meeting_url:
body = f"""{body}\n
.........................................................................
Join online meeting
{custom_meeting_url}"""
return body, is_online_meeting
def validate_emails(emails: list[str]) -> None:
"""Validate a list of email addresses.
Args:
emails: The list of email addresses to validate.
Raises:
ToolExecutionError: If any email address is invalid.
"""
invalid_emails = []
for email in emails:
if not is_valid_email(email):
invalid_emails.append(email)
if invalid_emails:
raise ToolExecutionError(message=f"Invalid email address(es): {', '.join(invalid_emails)}")
def is_valid_email(email: str) -> bool:
"""Simple check to see if an email address is valid.
Args:
email: The email address to check.
Returns:
True if the email address is valid, False otherwise.
"""
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return re.match(pattern, email) is not None
def remove_timezone_offset(date_time: str) -> str:
"""Remove the timezone offset from the date_time string."""
return re.sub(r"[+-][0-9]{2}:[0-9]{2}$|Z$", "", date_time)
def replace_timezone_offset(date_time: str, time_zone_offset: str) -> str:
"""Replace the timezone offset in the date_time string with the time_zone_offset.
If the date_time str already contains a timezone offset, it will be replaced.
If the date_time str does not contain a timezone offset, the time_zone_offset will be appended
Args:
date_time: The date_time string to replace the timezone offset in.
time_zone_offset: The timezone offset to replace the existing timezone offset with.
Returns:
The date_time string with the timezone offset replaced or appended.
"""
date_time = remove_timezone_offset(date_time)
return f"{date_time}{time_zone_offset}"
def convert_timezone_to_offset(time_zone: str) -> str:
"""
Convert a timezone (Windows or IANA) to ISO 8601 offset.
First tries Windows timezone format, then IANA, then falls back to UTC if both fail.
Args:
time_zone: The timezone (Windows or IANA) to convert to ISO 8601 offset.
Returns:
The timezone offset in ISO 8601 format (e.g. '+08:00', '-07:00', or 'Z' for UTC)
"""
# Try Windows timezone format
iana_timezone = WINDOWS_TO_IANA.get(time_zone)
if iana_timezone:
try:
tz = pytz.timezone(iana_timezone)
now = datetime.now(tz)
tz_offset = now.strftime("%z")
if len(tz_offset) == 5: # +HHMM format
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
return tz_offset # noqa: TRY300
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
pass
# Try IANA timezone format
try:
tz = pytz.timezone(time_zone)
now = datetime.now(tz)
tz_offset = now.strftime("%z")
if len(tz_offset) == 5: # +HHMM format
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
return tz_offset # noqa: TRY300
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
# Fallback to UTC
return "Z"
async def get_default_calendar_timezone(client: GraphServiceClient) -> str:
"""Get the authenticated user's default calendar's timezone.
Args:
client: The GraphServiceClient to use to get
the authenticated user's default calendar's timezone.
Returns:
The timezone in "Windows timezone format" or "IANA timezone format".
"""
query_params = MailboxSettingsRequestBuilder.MailboxSettingsRequestBuilderGetQueryParameters(
select=["timeZone"]
)
request_config = RequestConfiguration(
query_parameters=query_params,
)
response = await client.me.mailbox_settings.get(request_config)
if response and response.time_zone:
return response.time_zone
return "UTC"
def create_timezone_headers(time_zone: str) -> HeadersCollection:
"""
Create headers with timezone preference.
Args:
time_zone: The timezone to set in the headers.
Returns:
Headers collection with timezone preference set.
"""
headers = HeadersCollection()
headers.try_add("Prefer", f'outlook.timezone="{time_zone}"')
return headers
def create_timezone_request_config(
time_zone: str, query_parameters: Any | None = None
) -> RequestConfiguration:
"""
Create a request configuration with timezone headers and optional query parameters.
Args:
time_zone: The timezone to set in the headers.
query_parameters: Optional query parameters to include in the configuration.
Returns:
Request configuration with timezone headers and optional query parameters.
"""
headers = create_timezone_headers(time_zone)
return RequestConfiguration(
headers=headers,
query_parameters=query_parameters,
)

View file

@ -0,0 +1,138 @@
# Maps "Windows timezone format" to "IANA timezone format"
# Does not include all Windows timezones.
WINDOWS_TO_IANA = {
"Dateline Standard Time": "Etc/GMT+12",
"UTC-11": "Etc/GMT+11",
"Aleutian Standard Time": "America/Adak",
"Hawaiian Standard Time": "Pacific/Honolulu",
"Marquesas Standard Time": "Pacific/Marquesas",
"Alaskan Standard Time": "America/Anchorage",
"UTC-09": "Etc/GMT+9",
"Pacific Standard Time (Mexico)": "America/Tijuana",
"UTC-08": "Etc/GMT+8",
"Pacific Standard Time": "America/Los_Angeles",
"US Mountain Standard Time": "America/Phoenix",
"Mountain Standard Time (Mexico)": "America/Chihuahua",
"Mountain Standard Time": "America/Denver",
"Central America Standard Time": "America/Guatemala",
"Central Standard Time": "America/Chicago",
"Easter Island Standard Time": "Pacific/Easter",
"Central Standard Time (Mexico)": "America/Mexico_City",
"Canada Central Standard Time": "America/Regina",
"SA Pacific Standard Time": "America/Bogota",
"Eastern Standard Time (Mexico)": "America/Cancun",
"Eastern Standard Time": "America/New_York",
"Haiti Standard Time": "America/Port-au-Prince",
"Cuba Standard Time": "America/Havana",
"US Eastern Standard Time": "America/Indianapolis",
"Turks And Caicos Standard Time": "America/Grand_Turk",
"Paraguay Standard Time": "America/Asuncion",
"Atlantic Standard Time": "America/Halifax",
"Venezuela Standard Time": "America/Caracas",
"Central Brazilian Standard Time": "America/Cuiaba",
"SA Western Standard Time": "America/La_Paz",
"Pacific SA Standard Time": "America/Santiago",
"Newfoundland Standard Time": "America/St_Johns",
"Tocantins Standard Time": "America/Araguaina",
"E. South America Standard Time": "America/Sao_Paulo",
"SA Eastern Standard Time": "America/Cayenne",
"Argentina Standard Time": "America/Buenos_Aires",
"Greenland Standard Time": "America/Godthab",
"Montevideo Standard Time": "America/Montevideo",
"Magallanes Standard Time": "America/Punta_Arenas",
"Saint Pierre Standard Time": "America/Miquelon",
"Bahia Standard Time": "America/Bahia",
"UTC-02": "Etc/GMT+2",
"Azores Standard Time": "Atlantic/Azores",
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
"UTC": "Etc/UTC",
"GMT Standard Time": "Europe/London",
"Greenwich Standard Time": "Atlantic/Reykjavik",
"W. Europe Standard Time": "Europe/Berlin",
"Central Europe Standard Time": "Europe/Budapest",
"Romance Standard Time": "Europe/Paris",
"Central European Standard Time": "Europe/Warsaw",
"W. Central Africa Standard Time": "Africa/Lagos",
"Jordan Standard Time": "Asia/Amman",
"GTB Standard Time": "Europe/Bucharest",
"Middle East Standard Time": "Asia/Beirut",
"Egypt Standard Time": "Africa/Cairo",
"E. Europe Standard Time": "Europe/Chisinau",
"Syria Standard Time": "Asia/Damascus",
"West Bank Standard Time": "Asia/Hebron",
"South Africa Standard Time": "Africa/Johannesburg",
"FLE Standard Time": "Europe/Kiev",
"Israel Standard Time": "Asia/Jerusalem",
"Kaliningrad Standard Time": "Europe/Kaliningrad",
"Sudan Standard Time": "Africa/Khartoum",
"Libya Standard Time": "Africa/Tripoli",
"Namibia Standard Time": "Africa/Windhoek",
"Arabic Standard Time": "Asia/Baghdad",
"Turkey Standard Time": "Europe/Istanbul",
"Arab Standard Time": "Asia/Riyadh",
"Belarus Standard Time": "Europe/Minsk",
"Russian Standard Time": "Europe/Moscow",
"E. Africa Standard Time": "Africa/Nairobi",
"Iran Standard Time": "Asia/Tehran",
"Arabian Standard Time": "Asia/Dubai",
"Astrakhan Standard Time": "Europe/Astrakhan",
"Azerbaijan Standard Time": "Asia/Baku",
"Russia Time Zone 3": "Europe/Samara",
"Mauritius Standard Time": "Indian/Mauritius",
"Saratov Standard Time": "Europe/Saratov",
"Georgian Standard Time": "Asia/Tbilisi",
"Volgograd Standard Time": "Europe/Volgograd",
"Caucasus Standard Time": "Asia/Yerevan",
"Afghanistan Standard Time": "Asia/Kabul",
"West Asia Standard Time": "Asia/Tashkent",
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
"Pakistan Standard Time": "Asia/Karachi",
"India Standard Time": "Asia/Calcutta",
"Sri Lanka Standard Time": "Asia/Colombo",
"Nepal Standard Time": "Asia/Kathmandu",
"Central Asia Standard Time": "Asia/Almaty",
"Bangladesh Standard Time": "Asia/Dhaka",
"Omsk Standard Time": "Asia/Omsk",
"Myanmar Standard Time": "Asia/Rangoon",
"SE Asia Standard Time": "Asia/Bangkok",
"Altai Standard Time": "Asia/Barnaul",
"W. Mongolia Standard Time": "Asia/Hovd",
"North Asia Standard Time": "Asia/Krasnoyarsk",
"N. Central Asia Standard Time": "Asia/Novosibirsk",
"Tomsk Standard Time": "Asia/Tomsk",
"China Standard Time": "Asia/Shanghai",
"North Asia East Standard Time": "Asia/Irkutsk",
"Singapore Standard Time": "Asia/Singapore",
"W. Australia Standard Time": "Australia/Perth",
"Taipei Standard Time": "Asia/Taipei",
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
"North Korea Standard Time": "Asia/Pyongyang",
"Aus Central W. Standard Time": "Australia/Eucla",
"Transbaikal Standard Time": "Asia/Chita",
"Tokyo Standard Time": "Asia/Tokyo",
"Korea Standard Time": "Asia/Seoul",
"Yakutsk Standard Time": "Asia/Yakutsk",
"Cen. Australia Standard Time": "Australia/Adelaide",
"AUS Central Standard Time": "Australia/Darwin",
"E. Australia Standard Time": "Australia/Brisbane",
"AUS Eastern Standard Time": "Australia/Sydney",
"West Pacific Standard Time": "Pacific/Port_Moresby",
"Tasmania Standard Time": "Australia/Hobart",
"Vladivostok Standard Time": "Asia/Vladivostok",
"Lord Howe Standard Time": "Australia/Lord_Howe",
"Bougainville Standard Time": "Pacific/Bougainville",
"Russia Time Zone 10": "Asia/Srednekolymsk",
"Magadan Standard Time": "Asia/Magadan",
"Norfolk Standard Time": "Pacific/Norfolk",
"Sakhalin Standard Time": "Asia/Sakhalin",
"Central Pacific Standard Time": "Pacific/Guadalcanal",
"Russia Time Zone 11": "Asia/Kamchatka",
"New Zealand Standard Time": "Pacific/Auckland",
"UTC+12": "Etc/GMT-12",
"Fiji Standard Time": "Pacific/Fiji",
"Chatham Islands Standard Time": "Pacific/Chatham",
"UTC+13": "Etc/GMT-13",
"Tonga Standard Time": "Pacific/Tongatapu",
"Samoa Standard Time": "Pacific/Apia",
"Line Islands Standard Time": "Pacific/Kiritimati",
}

View file

@ -0,0 +1,288 @@
import re
from dataclasses import dataclass, field
from typing import Any
from bs4 import BeautifulSoup
from msgraph.generated.models.attendee import Attendee as GraphAttendee
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone
from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress
from msgraph.generated.models.event import Event as GraphEvent
from msgraph.generated.models.event_type import EventType as GraphEventType
from msgraph.generated.models.free_busy_status import FreeBusyStatus as GraphFreeBusyStatus
from msgraph.generated.models.importance import Importance as GraphImportance
from msgraph.generated.models.item_body import ItemBody as GraphItemBody
from msgraph.generated.models.location import Location as GraphLocation
from msgraph.generated.models.recipient import Recipient as GraphRecipient
from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus
from msgraph.generated.models.response_type import ResponseType as GraphResponseType
@dataclass
class Attendee:
"""An attendee of a calendar event."""
name: str = ""
address: str = ""
response: str = ""
@classmethod
def from_sdk(cls, attendee: GraphAttendee) -> "Attendee":
"""Convert a Microsoft Graph SDK Attendee object to an Attendee dataclass."""
return cls(
name=attendee.email_address.name
if attendee.email_address and attendee.email_address.name
else "",
address=attendee.email_address.address
if attendee.email_address and attendee.email_address.address
else "",
response=attendee.status.response
if attendee.status and attendee.status.response
else "",
)
def to_dict(self) -> dict[str, str]:
return {
"name": self.name,
"address": self.address,
"response": self.response,
}
def to_sdk(self) -> GraphAttendee:
"""Convert an Attendee dataclass to a Microsoft Graph SDK Attendee object."""
return GraphAttendee(
email_address=GraphEmailAddress(name=self.name, address=self.address),
status=GraphResponseStatus(
response=GraphResponseType(self.response)
if self.response
else GraphResponseType.None_
),
)
@dataclass
class Organizer:
"""The organizer of an event."""
name: str = ""
address: str = ""
@classmethod
def from_sdk(cls, organizer: GraphRecipient) -> "Organizer":
"""Convert a Microsoft Graph SDK Organizer object to an Organizer dataclass."""
return cls(
name=organizer.email_address.name
if organizer.email_address and organizer.email_address.name
else "",
address=organizer.email_address.address
if organizer.email_address and organizer.email_address.address
else "",
)
def to_dict(self) -> dict[str, str]:
return {
"name": self.name,
"address": self.address,
}
def to_sdk(self) -> GraphRecipient:
"""Convert an Organizer dataclass to a Microsoft Graph SDK Organizer object."""
recipient = GraphRecipient(
email_address=GraphEmailAddress(name=self.name, address=self.address)
)
return recipient
@dataclass
class DateTimeTimeZone:
"""Time information for an event."""
date_time: str = ""
time_zone: str = ""
@classmethod
def from_sdk(cls, date_time_time_zone: GraphDateTimeTimeZone) -> "DateTimeTimeZone":
"""Convert a Microsoft Graph SDK DateTimeTimeZone object to a TimeInfo dataclass."""
return cls(
date_time=date_time_time_zone.date_time or "",
time_zone=date_time_time_zone.time_zone or "",
)
def to_dict(self) -> dict[str, str]:
return {
"dateTime": self.date_time,
"timeZone": self.time_zone,
}
def to_sdk(self) -> GraphDateTimeTimeZone:
"""Convert a TimeInfo dataclass to a Microsoft Graph SDK DateTimeTimeZone object."""
return GraphDateTimeTimeZone(date_time=self.date_time, time_zone=self.time_zone)
@dataclass
class ResponseStatus:
"""The response status for an event."""
response: str = ""
@classmethod
def from_sdk(cls, response_status: GraphResponseStatus) -> "ResponseStatus":
"""Convert a Microsoft Graph SDK ResponseStatus object to a ResponseStatus dataclass."""
response_value = (
str(response_status.response.value)
if response_status.response and hasattr(response_status.response, "value")
else ""
)
return cls(response=response_value)
def to_dict(self) -> dict[str, str]:
return {
"response": self.response,
}
def to_sdk(self) -> GraphResponseStatus:
"""Convert a ResponseStatus dataclass to a Microsoft Graph SDK ResponseStatus object."""
return GraphResponseStatus(response=GraphResponseType(self.response))
@dataclass
class Event:
"""A calendar event in Outlook."""
attendees: list[Attendee] = field(default_factory=list)
body: str = ""
end: DateTimeTimeZone | None = None
has_attachments: bool = False
importance: str = ""
is_all_day: bool = False
is_cancelled: bool = False
is_draft: bool = False
is_online_meeting: bool = False
is_organizer: bool = False
location: str = ""
online_meeting_url: str = ""
organizer: Organizer | None = None
id: str = ""
response_status: ResponseStatus | None = None
show_as: str = ""
start: DateTimeTimeZone | None = None
subject: str = ""
type: str = ""
web_link: str = ""
event_id: str = "" # The unique identifier of the event. 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)
# Replace sequences of dots (likely from horizontal lines) with a single newline
text = re.sub(r"\.{3,}", "\n---\n", text)
# Remove leading/trailing whitespace from each line
text = "\n".join(line.strip() for line in text.split("\n"))
return text
@classmethod
def from_sdk(cls, event: GraphEvent) -> "Event":
"""Convert a Microsoft Graph SDK Event object to an Event dataclass."""
body_mime = event.body.content if event.body and event.body.content else ""
body = cls._parse_body(body_mime)
attendees = [Attendee.from_sdk(a) for a in event.attendees if a] if event.attendees else []
start = DateTimeTimeZone.from_sdk(event.start) if event.start else None
end = DateTimeTimeZone.from_sdk(event.end) if event.end else None
organizer = Organizer.from_sdk(event.organizer) if event.organizer else None
response_status = (
ResponseStatus.from_sdk(event.response_status) if event.response_status else None
)
return cls(
attendees=attendees,
body=body,
end=end,
has_attachments=cls._safe_bool(event.has_attachments),
importance=cls._safe_str(str(event.importance.value)) if event.importance else "",
is_all_day=cls._safe_bool(event.is_all_day),
is_cancelled=cls._safe_bool(event.is_cancelled),
is_draft=cls._safe_bool(event.is_draft),
is_online_meeting=cls._safe_bool(event.is_online_meeting),
is_organizer=cls._safe_bool(event.is_organizer),
location=cls._safe_str(event.location.display_name if event.location else ""),
online_meeting_url=cls._safe_str(event.online_meeting_url),
organizer=organizer,
id=cls._safe_str(event.id),
response_status=response_status,
show_as=cls._safe_str(str(event.show_as.value)) if event.show_as else "",
start=start,
subject=cls._safe_str(event.subject),
type=cls._safe_str(str(event.type.value)) if event.type else "",
web_link=cls._safe_str(event.web_link),
event_id=cls._safe_str(event.id),
)
def to_dict(self) -> dict[str, Any]:
"""Converts the Event dataclass to a dictionary."""
return {
"attendees": [attendee.to_dict() for attendee in self.attendees],
"body": self.body,
"end": self.end.to_dict() if self.end else None,
"has_attachments": self.has_attachments,
"importance": self.importance,
"is_all_day": self.is_all_day,
"is_cancelled": self.is_cancelled,
"is_draft": self.is_draft,
"is_online_meeting": self.is_online_meeting,
"is_organizer": self.is_organizer,
"location": self.location,
"online_meeting_url": self.online_meeting_url,
"organizer": self.organizer.to_dict() if self.organizer else None,
"id": self.id,
"response_status": self.response_status.to_dict() if self.response_status else None,
"show_as": self.show_as,
"start": self.start.to_dict() if self.start else None,
"subject": self.subject,
"type": self.type,
"web_link": self.web_link,
"event_id": self.event_id,
}
def to_sdk(self) -> GraphEvent:
"""Convert an Event dataclass to a Microsoft Graph SDK Event object."""
return GraphEvent(
attendees=[attendee.to_sdk() for attendee in self.attendees],
body=GraphItemBody(content=self.body),
end=self.end.to_sdk() if self.end else None,
has_attachments=self.has_attachments,
importance=GraphImportance(self.importance) if self.importance else None,
is_all_day=self.is_all_day,
is_cancelled=self.is_cancelled,
is_draft=self.is_draft,
is_online_meeting=self.is_online_meeting,
is_organizer=self.is_organizer,
location=GraphLocation(display_name=self.location),
online_meeting_url=self.online_meeting_url,
organizer=self.organizer.to_sdk() if self.organizer else None,
id=self.id,
response_status=self.response_status.to_sdk() if self.response_status else None,
show_as=GraphFreeBusyStatus(self.show_as) if self.show_as else None,
start=self.start.to_sdk() if self.start else None,
subject=self.subject,
type=GraphEventType(self.type) if self.type else None,
web_link=self.web_link,
)

View file

@ -0,0 +1,7 @@
from arcade_microsoft.outlook_calendar.tools.create_event import create_event
from arcade_microsoft.outlook_calendar.tools.get_event import get_event
from arcade_microsoft.outlook_calendar.tools.list_events_in_time_range import (
list_events_in_time_range,
)
__all__ = ["create_event", "get_event", "list_events_in_time_range"]

View file

@ -0,0 +1,81 @@
from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Microsoft
from arcade_microsoft.client import get_client
from arcade_microsoft.outlook_calendar._utils import (
create_timezone_request_config,
get_default_calendar_timezone,
prepare_meeting_body,
remove_timezone_offset,
validate_date_times,
validate_emails,
)
from arcade_microsoft.outlook_calendar.models import (
Attendee,
DateTimeTimeZone,
Event,
)
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadWrite"]))
async def create_event(
context: ToolContext,
subject: Annotated[str, "The text of the event's subject (title) line."],
body: Annotated[str, "The body of the event"],
start_date_time: Annotated[
str,
"The datetime of the event's start, represented in "
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:00:00",
],
end_date_time: Annotated[
str,
"The datetime of the event's end, represented in "
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:30:00",
],
location: Annotated[str | None, "The location of the event"] = None,
attendee_emails: Annotated[
list[str] | None,
"The email addresses of the attendees of the event. "
"Must be valid email addresses e.g., username@domain.com.",
] = None,
is_online_meeting: Annotated[
bool, "Whether the event is an online meeting. Defaults to False"
] = False,
custom_meeting_url: Annotated[
str | None,
"The URL of the online meeting. If not provided and is_online_meeting is True, "
"then a url will be generated for you",
] = None,
) -> Annotated[dict, "A dictionary containing the created event details"]:
"""Create an event in the authenticated user's default calendar.
Ignores timezone offsets provided in the start_date_time and end_date_time parameters.
Instead, uses the user's default calendar timezone to filter events.
If the user has not set a timezone for their calendar, then the timezone will be UTC.
"""
# Validate & cleanse inputs
validate_emails(attendee_emails or [])
validate_date_times(start_date_time, end_date_time)
body, is_online_meeting = prepare_meeting_body(body, custom_meeting_url, is_online_meeting)
client = get_client(context.get_auth_token_or_empty())
time_zone = await get_default_calendar_timezone(client)
start_date_time = remove_timezone_offset(start_date_time)
end_date_time = remove_timezone_offset(end_date_time)
event = Event(
subject=subject,
body=body,
start=DateTimeTimeZone(date_time=start_date_time, time_zone=time_zone),
end=DateTimeTimeZone(date_time=end_date_time, time_zone=time_zone),
location=location or "",
attendees=[Attendee(address=attendee) for attendee in attendee_emails or []],
is_online_meeting=is_online_meeting,
).to_sdk()
request_config = create_timezone_request_config(time_zone)
response = await client.me.events.post(body=event, request_configuration=request_config)
return Event.from_sdk(response).to_dict() # type: ignore[arg-type]

View file

@ -0,0 +1,29 @@
from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Microsoft
from arcade_microsoft.client import get_client
from arcade_microsoft.outlook_calendar._utils import (
create_timezone_request_config,
get_default_calendar_timezone,
)
from arcade_microsoft.outlook_calendar.models import Event
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"]))
async def get_event(
context: ToolContext,
event_id: Annotated[str, "The ID of the event to get"],
) -> Annotated[dict, "A dictionary containing the event details"]:
"""Get an event by its ID from the user's calendar."""
client = get_client(context.get_auth_token_or_empty())
time_zone = await get_default_calendar_timezone(client)
request_config = create_timezone_request_config(time_zone)
response = await client.me.events.by_event_id(event_id).get(
request_configuration=request_config
)
return Event.from_sdk(response).to_dict() # type: ignore[arg-type]

View file

@ -0,0 +1,59 @@
from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Microsoft
from msgraph.generated.users.item.calendar.calendar_view.calendar_view_request_builder import (
CalendarViewRequestBuilder,
)
from arcade_microsoft.client import get_client
from arcade_microsoft.outlook_calendar._utils import (
convert_timezone_to_offset,
create_timezone_request_config,
get_default_calendar_timezone,
replace_timezone_offset,
validate_date_times,
)
from arcade_microsoft.outlook_calendar.models import Event
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"]))
async def list_events_in_time_range(
context: ToolContext,
start_date_time: Annotated[
str,
"The start date and time of the time range, represented in "
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:00:00",
],
end_date_time: Annotated[
str,
"The end date and time of the time range, represented in "
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:30:00",
],
limit: Annotated[int, "The maximum number of events to return. Max 1000. Defaults to 10"] = 10,
) -> Annotated[dict, "A dictionary containing a list of events"]:
"""List events in the user's calendar in a specific time range.
Ignores timezone offsets provided in the start_date_time and end_date_time parameters.
Instead, uses the user's default calendar timezone to filter events.
If the user has not set a timezone for their calendar, then the timezone will be UTC.
"""
# Validate inputs
validate_date_times(start_date_time, end_date_time)
client = get_client(context.get_auth_token_or_empty())
time_zone = await get_default_calendar_timezone(client)
time_zone_offset = convert_timezone_to_offset(time_zone)
start_date_time = replace_timezone_offset(start_date_time, time_zone_offset)
end_date_time = replace_timezone_offset(end_date_time, time_zone_offset)
query_params = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters(
start_date_time=start_date_time,
end_date_time=end_date_time,
top=max(1, min(limit, 1000)),
)
request_config = create_timezone_request_config(time_zone, query_params)
response = await client.me.calendar.calendar_view.get(request_config)
events = [Event.from_sdk(event).to_dict() for event in response.value] # type: ignore[union-attr]
return {"events": events, "num_events": len(events)}

View file

@ -1,5 +1,3 @@
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
DEFAULT_MESSAGE_FIELDS = [
"bccRecipients",
"body",

View file

@ -0,0 +1,28 @@
get_event_additional_messages = [
{"role": "system", "content": "Today is 2025-04-22, Tuesday."},
{"role": "user", "content": "show me my meetings for today"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_def456",
"type": "function",
"function": {
"name": "Microsoft_ListEventsInTimeRange",
"arguments": '{"start_date_time":"2025-04-22T00:00:00","end_date_time":"2025-04-22T23:59:59"}', # noqa: E501
},
}
],
},
{
"role": "tool",
"content": '{"events":[{"attendees":[{"email":"john@example.com","name":"John Smith"},{"email":"alice@example.com","name":"Alice Johnson"}],"body":"Quarterly review meeting","end":{"date_time":"2025-04-22T15:00:00.0000000","time_zone":"Pacific Standard Time"},"has_attachments":true,"id":"AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4","importance":"high","is_all_day":false,"is_cancelled":false,"is_online_meeting":true,"is_organizer":true,"location":"Online","online_meeting_url":"https://teams.microsoft.com/l/meetup-join/meeting_id","organizer":{"email":"user@example.com","name":"User Name"},"start":{"date_time":"2025-04-22T14:00:00.0000000","time_zone":"Pacific Standard Time"},"subject":"Q1 Review","web_link":"https://outlook.office365.com/owa/?itemid=AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4&exvsurl=1&path=/calendar/item"}],"num_events":1}', # noqa: E501
"tool_call_id": "call_def456",
"name": "Microsoft_ListEventsInTimeRange",
},
{
"role": "assistant",
"content": "You have 1 meeting scheduled for today:\n\n1. **Q1 Review** - Today, 2:00 PM - 3:00 PM\n Location: Online\n Attendees: John Smith, Alice Johnson\n This is a high importance meeting with attachments.", # noqa: E501
},
]

View file

@ -0,0 +1,94 @@
from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade.sdk.eval.critic import SimilarityCritic
from arcade_microsoft.outlook_calendar import create_event
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_tool(create_event, "Microsoft")
@tool_eval()
def outlook_calendar_create_event_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Outlook Calendar create event tool."""
suite = EvalSuite(
name="Outlook Calendar Create Event Evaluation",
system_message=(
"You are an AI that has access to tools to view and manage calendar events. "
"The current time date and time is April 25, 2025, 5:18 PM PST."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Create virtual event",
user_message=(
"schedule a virtual team meeting 'Standup' tomorrow at 3pm for 1 hour. "
"john@example.com and sarah@example.com need to be there"
),
expected_tool_calls=[
ExpectedToolCall(
func=create_event,
args={
"subject": "Standup",
"start_date_time": "2025-04-26T15:00:00",
"end_date_time": "2025-04-26T16:00:00",
"attendee_emails": ["john@example.com", "sarah@example.com"],
"is_online_meeting": True,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 5),
BinaryCritic(critic_field="start_date_time", weight=1 / 5),
BinaryCritic(critic_field="end_date_time", weight=1 / 5),
BinaryCritic(critic_field="attendee_emails", weight=1 / 5),
BinaryCritic(critic_field="is_online_meeting", weight=1 / 5),
],
)
suite.add_case(
name="Create event with physical location and virtual link",
user_message=(
"schedule a team meeting 'All hands' tomorrow at 3pm for 1 hour. "
"john@example.com and sarah@example.com need to be there. "
"The meeting will be in Conference Room A, but there will be a virtual link "
"for those who cannot attend in person."
),
expected_tool_calls=[
ExpectedToolCall(
func=create_event,
args={
"subject": "All hands",
"start_date_time": "2025-04-26T15:00:00",
"end_date_time": "2025-04-26T16:00:00",
"location": "Conference Room A",
"attendee_emails": ["john@example.com", "sarah@example.com"],
"is_online_meeting": True,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 6),
BinaryCritic(critic_field="start_date_time", weight=1 / 6),
BinaryCritic(critic_field="end_date_time", weight=1 / 6),
SimilarityCritic(critic_field="location", weight=1 / 6),
BinaryCritic(critic_field="attendee_emails", weight=1 / 6),
BinaryCritic(critic_field="is_online_meeting", weight=1 / 6),
],
)
return suite

View file

@ -0,0 +1,53 @@
from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_microsoft.outlook_calendar import get_event
from evals.outlook_calendar.additional_messages import get_event_additional_messages
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_tool(get_event, "Microsoft")
@tool_eval()
def outlook_calendar_get_event_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Outlook Calendar get event tool."""
suite = EvalSuite(
name="Outlook Calendar Get Event Evaluation",
system_message=(
"You are an AI that has access to tools to view and manage calendar events. "
"The current time date and time is April 25, 2025, 5:18 PM PST."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get event by id after listing events",
user_message="tell me more about the first event",
expected_tool_calls=[
ExpectedToolCall(
func=get_event,
args={
"event_id": "AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", # noqa: E501
},
)
],
critics=[
BinaryCritic(critic_field="event_id", weight=1.0),
],
additional_messages=get_event_additional_messages,
)
return suite

View file

@ -0,0 +1,77 @@
from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade.sdk.eval.critic import DatetimeCritic
from arcade_microsoft.outlook_calendar import list_events_in_time_range
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_tool(list_events_in_time_range, "Microsoft")
@tool_eval()
def outlook_calendar_list_events_in_time_range_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Outlook Calendar list events tool."""
suite = EvalSuite(
name="Outlook Calendar List Events Evaluation",
system_message=(
"You are an AI that has access to tools to view and manage calendar events. "
"The current time date and time is Friday, April 25, 2025, 5:18 PM PST."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="List events in time range",
user_message="what are my meetings on monday",
expected_tool_calls=[
ExpectedToolCall(
func=list_events_in_time_range,
args={
"start_date_time": "2025-04-28T00:00:00",
"end_date_time": "2025-04-28T23:59:59",
},
)
],
critics=[
DatetimeCritic(critic_field="start_date_time", weight=0.5),
DatetimeCritic(critic_field="end_date_time", weight=0.5),
],
)
suite.add_case(
name="List events in time range with limit",
user_message=(
"get my first 10 meetings for the next work-week through thursday, "
"starting tuesday (mon is holiday)"
),
expected_tool_calls=[
ExpectedToolCall(
func=list_events_in_time_range,
args={
"start_date_time": "2025-04-29T00:00:00",
"end_date_time": "2025-05-01T23:59:59",
"limit": 10,
},
)
],
critics=[
DatetimeCritic(critic_field="start_date_time", weight=0.3),
DatetimeCritic(critic_field="end_date_time", weight=0.3),
BinaryCritic(critic_field="limit", weight=0.4),
],
)
return suite

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "arcade_microsoft"
version = "0.1.0"
version = "0.2.0"
description = "Arcade.dev LLM tools for Outlook Mail"
authors = ["Arcade <dev@arcade.dev>"]
@ -9,6 +9,7 @@ python = "^3.10"
arcade-ai = "^1.3.2"
msgraph-sdk = "^1.28.0"
beautifulsoup4 = "^4.10.0"
pytz = "^2024.2"
[tool.poetry.dev-dependencies]
pytest = "^8.3.0"

View file

@ -0,0 +1,385 @@
import pytest
from msgraph.generated.models.attendee import Attendee as GraphAttendee
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone
from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress
from msgraph.generated.models.event import Event as GraphEvent
from msgraph.generated.models.location import Location as GraphLocation
from msgraph.generated.models.recipient import Recipient as GraphRecipient
from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus
from msgraph.generated.models.response_type import ResponseType as GraphResponseType
from arcade_microsoft.outlook_calendar.models import (
Attendee,
DateTimeTimeZone,
Event,
Organizer,
ResponseStatus,
)
class DummyBody:
def __init__(self, content):
self.content = content
class DummyEventType:
def __init__(self, value):
self.value = value
class DummyImportance:
def __init__(self, value):
self.value = value
class DummyFreeBusyStatus:
def __init__(self, value):
self.value = value
class DummyResponseType:
def __init__(self, value):
self.value = value
@pytest.mark.parametrize(
"input_data, expected",
[
(
{"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"},
{"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"},
),
(
{"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"},
{"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"},
),
(
{"name": None, "address": None, "response": "none"},
{"name": "", "address": "", "response": "none"},
),
],
)
def test_attendee_conversion(input_data, expected):
sdk_attendee = GraphAttendee()
sdk_attendee.email_address = GraphEmailAddress()
sdk_attendee.email_address.name = input_data["name"]
sdk_attendee.email_address.address = input_data["address"]
sdk_attendee.status = GraphResponseStatus()
sdk_attendee.status.response = GraphResponseType(input_data["response"])
# Test from_sdk method
attendee = Attendee.from_sdk(sdk_attendee)
assert attendee.name == expected["name"]
assert attendee.address == expected["address"]
assert attendee.response == expected["response"]
# Test to_dict method
dict_result = attendee.to_dict()
assert dict_result == expected
# Test to_sdk method
sdk_result = attendee.to_sdk()
assert sdk_result.email_address.name == expected["name"]
assert sdk_result.email_address.address == expected["address"]
assert sdk_result.status.response == GraphResponseType(expected["response"])
@pytest.mark.parametrize(
"input_data, expected",
[
(
{"name": "Jane Smith", "address": "jane.smith@example.com"},
{"name": "Jane Smith", "address": "jane.smith@example.com"},
),
(
{"name": "", "address": "unknown@example.com"},
{"name": "", "address": "unknown@example.com"},
),
({"name": None, "address": None}, {"name": "", "address": ""}),
],
)
def test_organizer_conversion(input_data, expected):
sdk_organizer = GraphRecipient()
sdk_organizer.email_address = GraphEmailAddress()
sdk_organizer.email_address.name = input_data["name"]
sdk_organizer.email_address.address = input_data["address"]
# Test from_sdk method
organizer = Organizer.from_sdk(sdk_organizer)
assert organizer.name == expected["name"]
assert organizer.address == expected["address"]
# Test to_dict method
dict_result = organizer.to_dict()
assert dict_result == expected
# Test to_sdk method
sdk_result = organizer.to_sdk()
assert sdk_result.email_address.name == expected["name"]
assert sdk_result.email_address.address == expected["address"]
@pytest.mark.parametrize(
"input_data, expected",
[
(
{"date_time": "2023-05-10T14:00:00", "time_zone": "Pacific Standard Time"},
{"dateTime": "2023-05-10T14:00:00", "timeZone": "Pacific Standard Time"},
),
({"date_time": "", "time_zone": "UTC"}, {"dateTime": "", "timeZone": "UTC"}),
({"date_time": None, "time_zone": None}, {"dateTime": "", "timeZone": ""}),
],
)
def test_date_time_time_zone_conversion(input_data, expected):
sdk_date_time = GraphDateTimeTimeZone()
sdk_date_time.date_time = input_data["date_time"]
sdk_date_time.time_zone = input_data["time_zone"]
# Test from_sdk method
date_time_tz = DateTimeTimeZone.from_sdk(sdk_date_time)
assert date_time_tz.date_time == (input_data["date_time"] or "")
assert date_time_tz.time_zone == (input_data["time_zone"] or "")
# Test to_dict method
dict_result = date_time_tz.to_dict()
assert dict_result == expected
# Test to_sdk method
sdk_result = date_time_tz.to_sdk()
assert sdk_result.date_time == date_time_tz.date_time
assert sdk_result.time_zone == date_time_tz.time_zone
@pytest.mark.parametrize(
"input_data, expected",
[
({"response": "accepted"}, {"response": "accepted"}),
({"response": "declined"}, {"response": "declined"}),
({"response": "none"}, {"response": "none"}),
],
)
def test_response_status_conversion(input_data, expected):
sdk_response_status = GraphResponseStatus()
sdk_response_status.response = GraphResponseType(input_data["response"])
# Test from_sdk method
response_status = ResponseStatus.from_sdk(sdk_response_status)
assert response_status.response == expected["response"]
# Test to_dict method
dict_result = response_status.to_dict()
assert dict_result == expected
# Test to_sdk method
sdk_result = response_status.to_sdk()
assert sdk_result.response == GraphResponseType(expected["response"])
@pytest.mark.parametrize(
"input_data, expected",
[
(
{
"body_content": "<p>Team <b>Meeting</b></p>",
"has_attachments": True,
"importance": "high",
"is_all_day": False,
"is_cancelled": False,
"is_draft": False,
"is_online_meeting": True,
"is_organizer": True,
"location": "Conference Room A",
"online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123",
"id": "event-123",
"show_as": "busy",
"subject": "Weekly Team Sync",
"type": "singleInstance",
"web_link": "https://outlook.office.com/calendar/item/123",
"attendees": [
{"name": "Alice", "address": "alice@example.com", "response": "accepted"},
{
"name": "Bob",
"address": "bob@example.com",
"response": "tentativelyAccepted",
},
],
"organizer": {"name": "Manager", "address": "manager@example.com"},
"start": {"date_time": "2023-05-10T10:00:00", "time_zone": "Eastern Standard Time"},
"end": {"date_time": "2023-05-10T11:00:00", "time_zone": "Eastern Standard Time"},
"response_status": {"response": "accepted"},
},
{
"body": "Team Meeting",
"has_attachments": True,
"importance": "high",
"is_all_day": False,
"is_cancelled": False,
"is_draft": False,
"is_online_meeting": True,
"is_organizer": True,
"location": "Conference Room A",
"online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123",
"id": "event-123",
"show_as": "busy",
"subject": "Weekly Team Sync",
"type": "singleInstance",
"web_link": "https://outlook.office.com/calendar/item/123",
"event_id": "event-123",
"attendees": [
{"name": "Alice", "address": "alice@example.com", "response": "accepted"},
{
"name": "Bob",
"address": "bob@example.com",
"response": "tentativelyAccepted",
},
],
"organizer": {"name": "Manager", "address": "manager@example.com"},
"start": {"dateTime": "2023-05-10T10:00:00", "timeZone": "Eastern Standard Time"},
"end": {"dateTime": "2023-05-10T11:00:00", "timeZone": "Eastern Standard Time"},
"response_status": {"response": "accepted"},
},
),
(
{
"body_content": "<p>All day <i>event</i> description</p>",
"has_attachments": False,
"importance": "normal",
"is_all_day": True,
"is_cancelled": True,
"is_draft": True,
"is_online_meeting": False,
"is_organizer": False,
"location": "",
"online_meeting_url": "",
"id": "event-456",
"show_as": "free",
"subject": "Company Holiday",
"type": "occurrence",
"web_link": "https://outlook.office.com/calendar/item/456",
"attendees": [],
"organizer": {"name": "HR Department", "address": "hr@example.com"},
"start": {"date_time": "2023-07-04T00:00:00", "time_zone": "UTC"},
"end": {"date_time": "2023-07-05T00:00:00", "time_zone": "UTC"},
"response_status": {"response": "notResponded"},
},
{
"body": "All day event description",
"has_attachments": False,
"importance": "normal",
"is_all_day": True,
"is_cancelled": True,
"is_draft": True,
"is_online_meeting": False,
"is_organizer": False,
"location": "",
"online_meeting_url": "",
"id": "event-456",
"show_as": "free",
"subject": "Company Holiday",
"type": "occurrence",
"web_link": "https://outlook.office.com/calendar/item/456",
"event_id": "event-456",
"attendees": [],
"organizer": {"name": "HR Department", "address": "hr@example.com"},
"start": {"dateTime": "2023-07-04T00:00:00", "timeZone": "UTC"},
"end": {"dateTime": "2023-07-05T00:00:00", "timeZone": "UTC"},
"response_status": {"response": "notResponded"},
},
),
],
)
def test_event_conversion(input_data, expected):
def make_graph_attendee(attendee_data):
attendee = GraphAttendee()
attendee.email_address = GraphEmailAddress()
attendee.email_address.name = attendee_data.get("name", "")
attendee.email_address.address = attendee_data.get("address", "")
attendee.status = GraphResponseStatus()
attendee.status.response = GraphResponseType(attendee_data.get("response", ""))
return attendee
def make_graph_organizer(organizer_data):
organizer = GraphRecipient()
organizer.email_address = GraphEmailAddress()
organizer.email_address.name = organizer_data.get("name", "")
organizer.email_address.address = organizer_data.get("address", "")
return organizer
def make_graph_date_time(date_time_data):
date_time = GraphDateTimeTimeZone()
date_time.date_time = date_time_data.get("date_time", "")
date_time.time_zone = date_time_data.get("time_zone", "")
return date_time
sdk_event = GraphEvent()
sdk_event.body = DummyBody(input_data["body_content"])
sdk_event.has_attachments = input_data["has_attachments"]
sdk_event.importance = DummyImportance(input_data["importance"])
sdk_event.is_all_day = input_data["is_all_day"]
sdk_event.is_cancelled = input_data["is_cancelled"]
sdk_event.is_draft = input_data["is_draft"]
sdk_event.is_online_meeting = input_data["is_online_meeting"]
sdk_event.is_organizer = input_data["is_organizer"]
sdk_event.location = GraphLocation(display_name=input_data["location"])
sdk_event.online_meeting_url = input_data["online_meeting_url"]
sdk_event.id = input_data["id"]
sdk_event.show_as = DummyFreeBusyStatus(input_data["show_as"])
sdk_event.subject = input_data["subject"]
sdk_event.type = DummyEventType(input_data["type"])
sdk_event.web_link = input_data["web_link"]
sdk_event.attendees = [make_graph_attendee(a) for a in input_data["attendees"]]
sdk_event.organizer = make_graph_organizer(input_data["organizer"])
sdk_event.start = make_graph_date_time(input_data["start"])
sdk_event.end = make_graph_date_time(input_data["end"])
sdk_event.response_status = GraphResponseStatus()
sdk_event.response_status.response = GraphResponseType(
input_data["response_status"]["response"]
)
# Test from_sdk method
event = Event.from_sdk(sdk_event)
assert event.body == expected["body"]
assert event.has_attachments == expected["has_attachments"]
assert event.importance == expected["importance"]
assert event.is_all_day == expected["is_all_day"]
assert event.is_cancelled == expected["is_cancelled"]
assert event.is_draft == expected["is_draft"]
assert event.is_online_meeting == expected["is_online_meeting"]
assert event.is_organizer == expected["is_organizer"]
assert event.location == expected["location"]
assert event.online_meeting_url == expected["online_meeting_url"]
assert event.id == expected["id"]
assert event.show_as == expected["show_as"]
assert event.subject == expected["subject"]
assert event.type == expected["type"]
assert event.web_link == expected["web_link"]
assert event.event_id == expected["event_id"]
assert len(event.attendees) == len(expected["attendees"])
for i, attendee in enumerate(event.attendees):
assert attendee.name == expected["attendees"][i]["name"]
assert attendee.address == expected["attendees"][i]["address"]
assert attendee.response == expected["attendees"][i]["response"]
if event.start:
assert event.start.date_time == expected["start"]["dateTime"]
assert event.start.time_zone == expected["start"]["timeZone"]
if event.end:
assert event.end.date_time == expected["end"]["dateTime"]
assert event.end.time_zone == expected["end"]["timeZone"]
if event.organizer:
assert event.organizer.name == expected["organizer"]["name"]
assert event.organizer.address == expected["organizer"]["address"]
if event.response_status:
assert event.response_status.response == expected["response_status"]["response"]
# Test to_dict method
dict_result = event.to_dict()
assert dict_result["body"] == expected["body"]
assert dict_result["subject"] == expected["subject"]
assert dict_result["event_id"] == expected["event_id"]
# Test to_sdk method
sdk_result = event.to_sdk()
assert sdk_result.subject == event.subject
assert sdk_result.is_all_day == event.is_all_day
assert sdk_result.location.display_name == event.location
assert len(sdk_result.attendees) == len(event.attendees)

View file

@ -0,0 +1,118 @@
import pytest
from arcade.core.errors import ToolExecutionError
from arcade_microsoft.outlook_calendar._utils import (
convert_timezone_to_offset,
is_valid_email,
remove_timezone_offset,
replace_timezone_offset,
validate_date_times,
validate_emails,
)
@pytest.mark.parametrize(
"start_date_time, end_date_time, error_type",
[
(
"2026-01-01T10:00:00",
"2026-01-01T17:00:00",
None,
),
# end_date_time before start_date_time
(
"2026-01-01T10:00:00",
"2026-01-01T10:00:00",
ToolExecutionError,
),
# end_date_time before start_date_time because timezone offset is ignored
(
"2026-01-01T10:00:00-07:00",
"2026-01-01T09:00:00-08:00",
ToolExecutionError,
),
# not ISO 8601 format
(
"20260101T10:00:00",
"2026-01-0109:00:00",
ValueError,
),
],
)
def test_validate_date_times(start_date_time, end_date_time, error_type):
if error_type:
with pytest.raises(error_type):
validate_date_times(start_date_time, end_date_time)
else:
validate_date_times(start_date_time, end_date_time)
@pytest.mark.parametrize(
"emails, expect_error",
[
(["test@test.com"], False),
(["test@test.com", "test@test.com.au"], False),
(["test@test.com", "test@test.com.au."], True),
(["#$&*@test.com"], True),
],
)
def test_validate_emails(emails, expect_error):
if expect_error:
with pytest.raises(ToolExecutionError):
validate_emails(emails)
else:
validate_emails(emails)
@pytest.mark.parametrize(
"email, is_valid",
[
("test@test.com", True),
("test@test", False),
("test@test.com.au", True),
("test@test.com.au.", False),
],
)
def test_is_valid_email(email, is_valid):
assert is_valid_email(email) == is_valid
@pytest.mark.parametrize(
"input_date_time, expected_date_time",
[
("2021-01-01T10:00:00+07:00", "2021-01-01T10:00:00"),
("2021-01-01T10:00:00-07:00", "2021-01-01T10:00:00"),
("2021-01-01T10:00:00Z", "2021-01-01T10:00:00"),
],
)
def test_remove_timezone_offset(input_date_time, expected_date_time):
assert remove_timezone_offset(input_date_time) == expected_date_time
@pytest.mark.parametrize(
"input_date_time, time_zone_offset, expected_date_time",
[
# without existing offset
("2021-01-01T10:00:00", "+07:00", "2021-01-01T10:00:00+07:00"),
("2021-01-01T10:00:00", "-07:00", "2021-01-01T10:00:00-07:00"),
("2021-01-01T10:00:00", "Z", "2021-01-01T10:00:00Z"),
# with existing offset
("2021-01-01T10:00:00+07:00", "+04:00", "2021-01-01T10:00:00+04:00"),
("2021-01-01T10:00:00-07:00", "-09:00", "2021-01-01T10:00:00-09:00"),
("2021-01-01T10:00:00-07:00", "Z", "2021-01-01T10:00:00Z"),
],
)
def test_replace_timezone_offset(input_date_time, time_zone_offset, expected_date_time):
assert replace_timezone_offset(input_date_time, time_zone_offset) == expected_date_time
@pytest.mark.parametrize(
"time_zone, expected_offset",
[
("Central Asia Standard Time", "+05:00"), # Windows timezone format
("America/New_York", "-04:00"), # IANA timezone format
("Not a valid timezone", "Z"), # Fallback to UTC
],
)
def test_convert_timezone_to_offset(time_zone, expected_offset):
assert convert_timezone_to_offset(time_zone) == expected_offset