Add Outlook Calendar tools (#382)
This commit is contained in:
parent
c128717e53
commit
419a0ac717
18 changed files with 1592 additions and 4 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
225
toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py
Normal file
225
toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
}
|
||||
288
toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py
Normal file
288
toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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)}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
|
||||
|
||||
DEFAULT_MESSAGE_FIELDS = [
|
||||
"bccRecipients",
|
||||
"body",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
]
|
||||
|
|
@ -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
|
||||
53
toolkits/microsoft/evals/outlook_calendar/eval_get_event.py
Normal file
53
toolkits/microsoft/evals/outlook_calendar/eval_get_event.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
0
toolkits/microsoft/tests/outlook_calendar/__init__.py
Normal file
0
toolkits/microsoft/tests/outlook_calendar/__init__.py
Normal file
385
toolkits/microsoft/tests/outlook_calendar/test_models.py
Normal file
385
toolkits/microsoft/tests/outlook_calendar/test_models.py
Normal 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)
|
||||
118
toolkits/microsoft/tests/outlook_calendar/test_utils.py
Normal file
118
toolkits/microsoft/tests/outlook_calendar/test_utils.py
Normal 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
|
||||
Loading…
Reference in a new issue