Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Google Calendar Capabilities
|
||||
|
||||
A mock calendar service supporting multiple calendars, recurring events, attendees with RSVP tracking, and availability checking.
|
||||
|
||||
## What the agent can do
|
||||
|
||||
**Manage events.** Create, read, update, and delete calendar events. Events can be timed (with specific start/end times) or all-day. Events support descriptions, locations, attendees, reminders, and recurrence rules.
|
||||
|
||||
**Recurring events.** Create events that repeat on a schedule — daily, weekly (with specific days like Tuesday/Thursday), or monthly. Recurrence supports end conditions (after N occurrences, or until a specific date). When listing events, recurring events are automatically expanded into individual instances within the query range.
|
||||
|
||||
**Multiple calendars.** Create named calendars (e.g., "Personal", "Work", "Team Meetings") and manage events across them. List all calendars with event counts. When listing events without specifying a calendar, results come from all calendars. Each calendar is isolated — events in one don't appear in another unless explicitly queried.
|
||||
|
||||
**Event search.** Search events across one or all calendars by keyword, quoted phrase, location, description, attendee email/name, creator, organizer, or calendar name. Search can be combined with time ranges, attendee filters, RSVP response-status filters, creator filters, and organizer filters. Recurring events are expanded within the requested range; without a range, search expands recurring events within a one-year default window from the series start.
|
||||
|
||||
**Attendees and RSVPs.** Invite people to events by email. Track RSVP responses (accepted, declined, tentative, needs action). Update individual attendee responses.
|
||||
|
||||
**Availability checking.** Check whether a time range is free or busy. Returns busy periods and available free slots of at least a specified duration. Can filter by specific attendees — for example, "find a free hour that works for both Alice and Bob." Declined events are excluded from busy calculations. Works across recurring events too.
|
||||
|
||||
**Reminders.** Set reminders on events (e.g., popup 15 minutes before, email 1 hour before). Reminders are stored but not actually triggered in the mock.
|
||||
|
||||
## Coverage gaps
|
||||
|
||||
- No "edit this and all future events" for recurring series (can only edit the template)
|
||||
- No event colors or categories
|
||||
- No shared calendar permissions (all calendars are fully accessible)
|
||||
- No time zone conversion tools
|
||||
- No meeting room or resource booking
|
||||
- Search uses `query` rather than the real Google Calendar `q` parameter, and does not implement `eventTypes`
|
||||
- Reminders are stored but don't fire
|
||||
|
||||
## Toolsets
|
||||
|
||||
10 tools total. Toolsets map to `WORLDBENCH_TOOL_SETS` values (prefixed form — e.g., `google_calendar_events`).
|
||||
|
||||
| Toolset | Tools | Description |
|
||||
|---------|-------|-------------|
|
||||
| `all` / `google_calendar_all` | 10 | Everything |
|
||||
| `read` / `google_calendar_read` | 5 | Read-only: get, list/search events, list calendars, check availability |
|
||||
| `write` / `google_calendar_write` | 5 | Write: create/update/delete event, create calendar, respond to event |
|
||||
| `google_calendar_events` | 6 | Event CRUD plus list/search events |
|
||||
| `google_calendar_calendars` | 2 | Calendar management: create, list calendars |
|
||||
| `google_calendar_scheduling` | 2 | Availability + RSVP: check availability, respond to event |
|
||||
| `google_calendar_core` | 6 | Baseline event management (legacy Toolathlon subset plus search) |
|
||||
| `google_calendar_toolathlon_legacy` | 5 | Legacy Toolathlon tool subset (pre-integration) |
|
||||
| `google_calendar_state` | 2 | `export_state`, `import_state` for fixture seeding and grading |
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Google Calendar MCP server package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .server import mcp
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Google Calendar MCP Server")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
parser.add_argument("--agent-workspace", help="Agent workspace path used to resolve persistent calendar state")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
|
||||
from .async_tool_guard import assert_tools_async
|
||||
|
||||
assert_tools_async(mcp)
|
||||
|
||||
from .state import init_state
|
||||
|
||||
init_state(args.agent_workspace)
|
||||
|
||||
port = os.environ.get("PORT")
|
||||
if port:
|
||||
from .viewer import run_http_server
|
||||
|
||||
run_http_server(mcp, int(port))
|
||||
else:
|
||||
mcp.run()
|
||||
|
||||
|
||||
__all__ = ["main", "mcp"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point for ``python -m google_calendar``."""
|
||||
|
||||
from google_calendar import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Boot-time guard: every registered MCP tool must be async.
|
||||
|
||||
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
||||
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
||||
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
||||
dictionary changed size during iteration``, which tears down the
|
||||
StreamableHTTP task group and turns every later request into a 500. Async
|
||||
(`async def`) tools validate on the single-threaded event loop and are safe.
|
||||
|
||||
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
||||
boots every server): a non-conformant package fails fast instead of shipping a
|
||||
server that can crash under load. The same check is intentionally duplicated in
|
||||
each package because the bundled prod images install only that package's own
|
||||
dependencies, so there is no shared runtime module to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _unwrap(fn: Any) -> Any:
|
||||
while isinstance(fn, partial):
|
||||
fn = fn.func
|
||||
return fn
|
||||
|
||||
|
||||
def find_sync_tools(mcp: Any) -> list[str]:
|
||||
"""Return the names of registered tools whose function is not a coroutine."""
|
||||
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
||||
manager = getattr(mcp, "_tool_manager", None)
|
||||
if manager is not None and hasattr(manager, "list_tools"):
|
||||
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
||||
|
||||
# fastmcp.FastMCP exposes an async API.
|
||||
async def _collect() -> list[str]:
|
||||
sync: list[str] = []
|
||||
for descriptor in await mcp.list_tools():
|
||||
tool = await mcp.get_tool(descriptor.name)
|
||||
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
||||
sync.append(descriptor.name)
|
||||
return sorted(sync)
|
||||
|
||||
return asyncio.run(_collect())
|
||||
|
||||
|
||||
def assert_tools_async(mcp: Any) -> None:
|
||||
"""Raise if any registered tool is synchronous."""
|
||||
sync = find_sync_tools(mcp)
|
||||
if sync:
|
||||
raise RuntimeError(
|
||||
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
||||
"validation in a worker threadpool and can trigger a pydantic-core "
|
||||
"'dictionary changed size during iteration' panic under concurrent calls, "
|
||||
"which kills the server. Make these tools async: " + ", ".join(sync)
|
||||
)
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Pydantic models and typed aliases for the Google Calendar mock."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, date, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
_RFC3339_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$")
|
||||
_RFC3339_OFFSET_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$")
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
DEFAULT_RECURRING_SEARCH_DAYS = 365
|
||||
DEFAULT_CALENDAR_TIME_ZONE = "UTC"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic Types for Event Ingestion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NonEmptyStateString = Annotated[str, Field(min_length=1)]
|
||||
Rfc3339DateTimeString = Annotated[str, Field(pattern=_RFC3339_DATETIME_RE.pattern)]
|
||||
Rfc3339OffsetDateTimeString = Annotated[str, Field(pattern=_RFC3339_OFFSET_DATETIME_RE.pattern)]
|
||||
CalendarDateString = Annotated[str, Field(pattern=_DATE_RE.pattern)]
|
||||
EventId = NonEmptyStateString
|
||||
CalendarId = NonEmptyStateString
|
||||
CalendarAccountId = NonEmptyStateString
|
||||
CalendarTimeZone = Annotated[str, Field(description="IANA time zone name, e.g. America/New_York")]
|
||||
EventSummary = NonEmptyStateString
|
||||
CalendarSummary = NonEmptyStateString
|
||||
EventQuery = NonEmptyStateString
|
||||
RecurrenceRuleString = Annotated[str, Field(pattern=r"^(?:RRULE|EXRULE|RDATE|EXDATE):")]
|
||||
PeopleResourceName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
pattern=r"^people/c[0-9]+$",
|
||||
description='Google People API resource name used by Calendar birthdays, e.g. "people/c12345".',
|
||||
),
|
||||
]
|
||||
ListEventsMaxResults = Annotated[int, Field(ge=0)]
|
||||
AvailabilityDurationMinutes = Annotated[int, Field(ge=1)]
|
||||
|
||||
|
||||
def _validate_time_zone(value: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as e:
|
||||
raise ValueError("timeZone must be a valid IANA time zone") from e
|
||||
|
||||
|
||||
def parse_rfc3339_datetime(value: str, time_zone: str | None = None) -> datetime:
|
||||
if not _RFC3339_DATETIME_RE.fullmatch(value):
|
||||
raise ValueError("dateTime must be an RFC 3339 date-time")
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError as e:
|
||||
raise ValueError("dateTime must be a valid RFC 3339 date-time") from e
|
||||
if time_zone is not None:
|
||||
zone = _validate_time_zone(time_zone)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=zone)
|
||||
expected_offset = zone.utcoffset(parsed.replace(tzinfo=None))
|
||||
if expected_offset != parsed.utcoffset():
|
||||
raise ValueError("dateTime offset must match timeZone")
|
||||
return parsed.astimezone(zone)
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_calendar_date(value: str) -> date:
|
||||
if not _DATE_RE.fullmatch(value):
|
||||
raise ValueError("date must use YYYY-MM-DD format")
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError as e:
|
||||
raise ValueError("date must be a valid calendar date") from e
|
||||
|
||||
|
||||
class EventStatus(StrEnum):
|
||||
"""Event status — mirrors Google Calendar API values."""
|
||||
|
||||
CONFIRMED = "confirmed"
|
||||
TENTATIVE = "tentative"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class EventVisibility(StrEnum):
|
||||
"""Event visibility — mirrors Google Calendar API values."""
|
||||
|
||||
DEFAULT = "default"
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
CONFIDENTIAL = "confidential"
|
||||
|
||||
|
||||
class EventTransparency(StrEnum):
|
||||
"""Whether an event blocks availability in free/busy calculations."""
|
||||
|
||||
OPAQUE = "opaque"
|
||||
TRANSPARENT = "transparent"
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
"""Event type values supported by Google Calendar."""
|
||||
|
||||
DEFAULT = "default"
|
||||
OUT_OF_OFFICE = "outOfOffice"
|
||||
FOCUS_TIME = "focusTime"
|
||||
FROM_GMAIL = "fromGmail"
|
||||
WORKING_LOCATION = "workingLocation"
|
||||
BIRTHDAY = "birthday"
|
||||
|
||||
|
||||
class EventClearField(StrEnum):
|
||||
"""Optional event fields that update_event can remove explicitly."""
|
||||
|
||||
DESCRIPTION = "description"
|
||||
LOCATION = "location"
|
||||
STATUS = "status"
|
||||
COLOR_ID = "colorId"
|
||||
VISIBILITY = "visibility"
|
||||
TRANSPARENCY = "transparency"
|
||||
RECURRENCE = "recurrence"
|
||||
REMINDERS = "reminders"
|
||||
ATTENDEES = "attendees"
|
||||
CREATOR = "creator"
|
||||
ORGANIZER = "organizer"
|
||||
EXTENDED_PROPERTIES = "extendedProperties"
|
||||
SOURCE = "source"
|
||||
OUT_OF_OFFICE_PROPERTIES = "outOfOfficeProperties"
|
||||
FOCUS_TIME_PROPERTIES = "focusTimeProperties"
|
||||
WORKING_LOCATION_PROPERTIES = "workingLocationProperties"
|
||||
BIRTHDAY_PROPERTIES = "birthdayProperties"
|
||||
HTML_LINK = "htmlLink"
|
||||
HANGOUT_LINK = "hangoutLink"
|
||||
|
||||
|
||||
EVENT_TYPE_PROPERTY_FIELDS = {
|
||||
EventType.OUT_OF_OFFICE: "outOfOfficeProperties",
|
||||
EventType.FOCUS_TIME: "focusTimeProperties",
|
||||
EventType.WORKING_LOCATION: "workingLocationProperties",
|
||||
EventType.BIRTHDAY: "birthdayProperties",
|
||||
}
|
||||
|
||||
|
||||
class AutoDeclineMode(StrEnum):
|
||||
"""Auto-decline behavior for focus time and out-of-office events."""
|
||||
|
||||
DECLINE_NONE = "declineNone"
|
||||
DECLINE_ALL_CONFLICTING_INVITATIONS = "declineAllConflictingInvitations"
|
||||
DECLINE_ONLY_NEW_CONFLICTING_INVITATIONS = "declineOnlyNewConflictingInvitations"
|
||||
|
||||
|
||||
class FocusTimeChatStatus(StrEnum):
|
||||
"""Chat status values for focus time events."""
|
||||
|
||||
AVAILABLE = "available"
|
||||
DO_NOT_DISTURB = "doNotDisturb"
|
||||
|
||||
|
||||
class WorkingLocationType(StrEnum):
|
||||
"""Working location type values supported by Google Calendar."""
|
||||
|
||||
HOME_OFFICE = "homeOffice"
|
||||
OFFICE_LOCATION = "officeLocation"
|
||||
CUSTOM_LOCATION = "customLocation"
|
||||
|
||||
|
||||
class BirthdayType(StrEnum):
|
||||
"""Birthday or special-date type values supported by Google Calendar."""
|
||||
|
||||
ANNIVERSARY = "anniversary"
|
||||
BIRTHDAY = "birthday"
|
||||
CUSTOM = "custom"
|
||||
OTHER = "other"
|
||||
SELF = "self"
|
||||
|
||||
|
||||
class AttendeeResponseStatus(StrEnum):
|
||||
"""Attendee RSVP state — mirrors Google Calendar API values."""
|
||||
|
||||
NEEDS_ACTION = "needsAction"
|
||||
DECLINED = "declined"
|
||||
TENTATIVE = "tentative"
|
||||
ACCEPTED = "accepted"
|
||||
|
||||
|
||||
class SearchEventsOrderBy(StrEnum):
|
||||
"""Sort options for search_events results."""
|
||||
|
||||
START_TIME = "startTime"
|
||||
UPDATED = "updated"
|
||||
|
||||
|
||||
class ReminderMethod(StrEnum):
|
||||
"""Event reminder delivery methods supported by Google Calendar."""
|
||||
|
||||
EMAIL = "email"
|
||||
POPUP = "popup"
|
||||
|
||||
|
||||
SearchEventsMaxResults = Annotated[int, Field(ge=0)]
|
||||
ReminderMinutes = Annotated[int, Field(ge=0, le=40320)]
|
||||
|
||||
|
||||
class EventDateTime(BaseModel):
|
||||
"""Time specification for an event — mirrors the Google Calendar API shape."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
dateTime: Rfc3339DateTimeString | None = None # RFC 3339 format for timed events
|
||||
date: CalendarDateString | None = None # YYYY-MM-DD for all-day events
|
||||
timeZone: CalendarTimeZone | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_date_or_datetime(self) -> "EventDateTime":
|
||||
has_datetime = bool(self.dateTime)
|
||||
has_date = bool(self.date)
|
||||
if has_datetime == has_date:
|
||||
raise ValueError("Event time must include exactly one of dateTime or date")
|
||||
|
||||
if self.timeZone is not None:
|
||||
_validate_time_zone(self.timeZone)
|
||||
if self.dateTime is not None:
|
||||
parsed = parse_rfc3339_datetime(self.dateTime, self.timeZone)
|
||||
if parsed.tzinfo is None and not self.timeZone:
|
||||
raise ValueError("dateTime without an offset requires timeZone")
|
||||
if self.date is not None:
|
||||
_parse_calendar_date(self.date)
|
||||
|
||||
return self
|
||||
|
||||
def _sort_value(self) -> Any:
|
||||
if self.dateTime is not None:
|
||||
return parse_rfc3339_datetime(self.dateTime, self.timeZone)
|
||||
if self.date is not None:
|
||||
return _parse_calendar_date(self.date)
|
||||
raise ValueError("Event time must include exactly one of dateTime or date")
|
||||
|
||||
|
||||
class EventAttendee(BaseModel):
|
||||
"""Attendee on an event — mirrors the Google Calendar API shape."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
email: EmailStr
|
||||
displayName: str | None = None
|
||||
organizer: bool | None = None
|
||||
self: bool | None = None
|
||||
resource: bool | None = None
|
||||
optional: bool | None = None
|
||||
responseStatus: AttendeeResponseStatus | None = None
|
||||
comment: str | None = None
|
||||
additionalGuests: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class CalendarPerson(BaseModel):
|
||||
"""Creator or organizer person object on an event."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
id: str | None = None
|
||||
email: EmailStr | None = None
|
||||
displayName: str | None = None
|
||||
self: bool | None = None
|
||||
|
||||
|
||||
class ExtendedProperties(BaseModel):
|
||||
"""Arbitrary event metadata supported by Google Calendar."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
private: dict[str, str] | None = None
|
||||
shared: dict[str, str] | None = None
|
||||
|
||||
|
||||
class EventSource(BaseModel):
|
||||
"""Source metadata for an event."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
title: NonEmptyStateString | None = None
|
||||
# Add url once worlds have hosted or Drive-backed source links.
|
||||
|
||||
|
||||
class EventReminder(BaseModel):
|
||||
"""Event reminder override — mirrors Google Calendar API values."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
method: ReminderMethod
|
||||
minutes: ReminderMinutes
|
||||
|
||||
|
||||
class EventReminders(BaseModel):
|
||||
"""Reminder settings for an event."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
useDefault: bool
|
||||
overrides: list[EventReminder] | None = Field(default=None, min_length=1, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_overrides_match_default_mode(self) -> "EventReminders":
|
||||
if self.useDefault and self.overrides is not None:
|
||||
raise ValueError("reminders.overrides cannot be set when useDefault is true")
|
||||
if not self.useDefault and self.overrides is None:
|
||||
raise ValueError("reminders.overrides is required when useDefault is false")
|
||||
return self
|
||||
|
||||
|
||||
class OutOfOfficeProperties(BaseModel):
|
||||
"""Out-of-office event data."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
autoDeclineMode: AutoDeclineMode | None = None
|
||||
declineMessage: str | None = None
|
||||
|
||||
|
||||
class FocusTimeProperties(BaseModel):
|
||||
"""Focus time event data."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
autoDeclineMode: AutoDeclineMode | None = None
|
||||
declineMessage: str | None = None
|
||||
chatStatus: FocusTimeChatStatus | None = None
|
||||
|
||||
|
||||
class WorkingLocationCustomLocation(BaseModel):
|
||||
"""Custom working location details."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class WorkingLocationOfficeLocation(BaseModel):
|
||||
"""Office working location details."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
buildingId: str | None = None
|
||||
floorId: str | None = None
|
||||
floorSectionId: str | None = None
|
||||
deskId: str | None = None
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class WorkingLocationHomeOffice(BaseModel):
|
||||
"""Home office marker for working location events."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class WorkingLocationProperties(BaseModel):
|
||||
"""Working location event data."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
type: WorkingLocationType
|
||||
homeOffice: WorkingLocationHomeOffice | None = None
|
||||
customLocation: WorkingLocationCustomLocation | None = None
|
||||
officeLocation: WorkingLocationOfficeLocation | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_location_matches_type(self) -> "WorkingLocationProperties":
|
||||
if self.type == WorkingLocationType.CUSTOM_LOCATION and self.customLocation is None:
|
||||
raise ValueError("customLocation is required when working location type is customLocation")
|
||||
if self.type == WorkingLocationType.OFFICE_LOCATION and self.officeLocation is None:
|
||||
raise ValueError("officeLocation is required when working location type is officeLocation")
|
||||
if self.type != WorkingLocationType.CUSTOM_LOCATION and self.customLocation is not None:
|
||||
raise ValueError("customLocation can only be set when type is customLocation")
|
||||
if self.type != WorkingLocationType.OFFICE_LOCATION and self.officeLocation is not None:
|
||||
raise ValueError("officeLocation can only be set when type is officeLocation")
|
||||
if self.type != WorkingLocationType.HOME_OFFICE and self.homeOffice is not None:
|
||||
raise ValueError("homeOffice can only be set when type is homeOffice")
|
||||
return self
|
||||
|
||||
|
||||
class BirthdayProperties(BaseModel):
|
||||
"""Birthday or special-date event data."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
type: BirthdayType = BirthdayType.BIRTHDAY
|
||||
contact: PeopleResourceName | None = None
|
||||
customTypeName: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_birthday_shape(self) -> "BirthdayProperties":
|
||||
if self.type == BirthdayType.SELF and self.contact is not None:
|
||||
raise ValueError("self birthday events cannot have contact")
|
||||
if self.type in {BirthdayType.ANNIVERSARY, BirthdayType.CUSTOM, BirthdayType.OTHER} and self.contact is None:
|
||||
raise ValueError(f"{self.type.value} birthday events require contact")
|
||||
if self.type == BirthdayType.CUSTOM and not self.customTypeName:
|
||||
raise ValueError("custom birthday events require customTypeName")
|
||||
if self.type != BirthdayType.CUSTOM and self.customTypeName is not None:
|
||||
raise ValueError("customTypeName can only be set when birthday type is custom")
|
||||
return self
|
||||
|
||||
|
||||
class EventInput(BaseModel):
|
||||
"""Schema for ingesting events from external systems (e.g., CSV, APIs)."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
summary: NonEmptyStateString
|
||||
start: EventDateTime
|
||||
end: EventDateTime
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
status: EventStatus | None = None
|
||||
colorId: str | None = None
|
||||
visibility: EventVisibility | None = None
|
||||
transparency: EventTransparency | None = None
|
||||
recurrence: list[RecurrenceRuleString] | None = None
|
||||
reminders: EventReminders | None = None
|
||||
attendees: list[EventAttendee] | None = None
|
||||
creator: CalendarPerson | None = None
|
||||
organizer: CalendarPerson | None = None
|
||||
extendedProperties: ExtendedProperties | None = None
|
||||
source: EventSource | None = None
|
||||
eventType: EventType = EventType.DEFAULT
|
||||
outOfOfficeProperties: OutOfOfficeProperties | None = None
|
||||
focusTimeProperties: FocusTimeProperties | None = None
|
||||
workingLocationProperties: WorkingLocationProperties | None = None
|
||||
birthdayProperties: BirthdayProperties | None = None
|
||||
htmlLink: str | None = None
|
||||
hangoutLink: str | None = None
|
||||
|
||||
@field_validator("recurrence", mode="before")
|
||||
@classmethod
|
||||
def _coerce_recurrence(cls, value: object) -> object:
|
||||
# Legacy fixtures sometimes store a single RRULE string rather than a list.
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return value
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def default_from_gmail_transparency(cls, value: object) -> object:
|
||||
if isinstance(value, dict):
|
||||
raw_value = cast(dict[str, object], value)
|
||||
if raw_value.get("eventType") != EventType.FROM_GMAIL.value:
|
||||
return value
|
||||
value = dict(raw_value)
|
||||
value.setdefault("transparency", EventTransparency.TRANSPARENT.value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_start_end_shape_and_order(self) -> "EventInput":
|
||||
start_is_datetime = self.start.dateTime is not None
|
||||
end_is_datetime = self.end.dateTime is not None
|
||||
if start_is_datetime != end_is_datetime:
|
||||
raise ValueError("Event start and end must both use dateTime or both use date")
|
||||
|
||||
start_value = self.start._sort_value()
|
||||
end_value = self.end._sort_value()
|
||||
if isinstance(start_value, datetime) and isinstance(end_value, datetime):
|
||||
if start_value.tzinfo is None:
|
||||
start_value = start_value.replace(tzinfo=UTC)
|
||||
if end_value.tzinfo is None:
|
||||
end_value = end_value.replace(tzinfo=UTC)
|
||||
if end_value <= start_value:
|
||||
raise ValueError("Event end must be after start")
|
||||
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_attendees(self) -> "EventInput":
|
||||
if self.attendees is None:
|
||||
return self
|
||||
|
||||
seen_emails = set()
|
||||
for attendee in self.attendees:
|
||||
email = attendee.email.lower()
|
||||
if email in seen_emails:
|
||||
raise ValueError(f"Duplicate attendee email: {attendee.email}")
|
||||
seen_emails.add(email)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_event_type_properties(self) -> "EventInput":
|
||||
property_by_type = {
|
||||
EventType.OUT_OF_OFFICE: ("outOfOfficeProperties", self.outOfOfficeProperties),
|
||||
EventType.FOCUS_TIME: ("focusTimeProperties", self.focusTimeProperties),
|
||||
EventType.WORKING_LOCATION: ("workingLocationProperties", self.workingLocationProperties),
|
||||
EventType.BIRTHDAY: ("birthdayProperties", self.birthdayProperties),
|
||||
}
|
||||
for event_type, (field_name, value) in property_by_type.items():
|
||||
if self.eventType == event_type and value is None:
|
||||
raise ValueError(f"{field_name} is required when eventType is {event_type.value}")
|
||||
if self.eventType != event_type and value is not None:
|
||||
raise ValueError(f"{field_name} can only be set when eventType is {event_type.value}")
|
||||
return self
|
||||
|
||||
|
||||
class Event(EventInput):
|
||||
"""Full stored event with system-generated fields."""
|
||||
|
||||
id: NonEmptyStateString
|
||||
# Optional so synthetic/legacy snapshots without audit timestamps still round-trip.
|
||||
created: Rfc3339DateTimeString | None = None
|
||||
updated: Rfc3339DateTimeString | None = None
|
||||
|
||||
@field_validator("created", "updated")
|
||||
@classmethod
|
||||
def _validate_audit_timestamp(cls, value: str | None) -> str | None:
|
||||
if value is not None:
|
||||
parsed = parse_rfc3339_datetime(value)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("audit timestamps must include an offset")
|
||||
return value
|
||||
|
||||
|
||||
def dump_model(value: Any, model_type: type[BaseModel]) -> dict[str, Any]:
|
||||
if isinstance(value, model_type):
|
||||
return value.model_dump(exclude_none=True)
|
||||
return model_type.model_validate(value).model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def event_attendee_payload(attendee: EventAttendee | dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(attendee, EventAttendee):
|
||||
attendee = EventAttendee.model_validate(attendee)
|
||||
payload = attendee.model_dump(exclude_none=True)
|
||||
payload["displayName"] = payload.get("displayName") or attendee.email
|
||||
payload["responseStatus"] = payload.get("responseStatus") or AttendeeResponseStatus.NEEDS_ACTION.value
|
||||
payload["organizer"] = payload.get("organizer", False)
|
||||
payload["self"] = payload.get("self", False)
|
||||
return payload
|
||||
|
||||
|
||||
class Calendar(BaseModel):
|
||||
"""Stored secondary calendar metadata and events."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
summary: NonEmptyStateString
|
||||
description: str = ""
|
||||
timeZone: CalendarTimeZone = DEFAULT_CALENDAR_TIME_ZONE
|
||||
events: dict[NonEmptyStateString, Event] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("timeZone")
|
||||
@classmethod
|
||||
def _validate_time_zone(cls, value: str) -> str:
|
||||
_validate_time_zone(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_events_keyed_by_id(self) -> "Calendar":
|
||||
for key, event in self.events.items():
|
||||
if key != event.id:
|
||||
raise ValueError(f"events key {key!r} does not match event.id {event.id!r}")
|
||||
return self
|
||||
|
||||
|
||||
class CalendarState(BaseModel):
|
||||
"""Full google_calendar state — round-trips with load_data()/save_data()."""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
timeZone: CalendarTimeZone = DEFAULT_CALENDAR_TIME_ZONE
|
||||
events: dict[NonEmptyStateString, Event] = Field(default_factory=dict)
|
||||
calendars: dict[NonEmptyStateString, Calendar] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("timeZone")
|
||||
@classmethod
|
||||
def _validate_time_zone(cls, value: str) -> str:
|
||||
_validate_time_zone(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_primary_calendar_is_flat(self) -> "CalendarState":
|
||||
if "primary" in self.calendars:
|
||||
raise ValueError("Primary calendar events must be stored in top-level events, not calendars['primary']")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_events_keyed_by_id(self) -> "CalendarState":
|
||||
for key, event in self.events.items():
|
||||
if key != event.id:
|
||||
raise ValueError(f"events key {key!r} does not match event.id {event.id!r}")
|
||||
return self
|
||||
|
||||
|
||||
class CalendarAccountsState(BaseModel):
|
||||
"""Multi-account google_calendar state wrapper.
|
||||
|
||||
Each account is fully isolated: calendar IDs, event IDs, calendars, and
|
||||
availability/search state are scoped to the selected account.
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
accounts: dict[NonEmptyStateString, CalendarState] = Field(default_factory=dict)
|
||||
|
||||
|
||||
GoogleCalendarState = CalendarState | CalendarAccountsState
|
||||
|
||||
|
||||
class SearchEventsResponse(TypedDict, total=False):
|
||||
status: str
|
||||
message: str
|
||||
events: list[dict[str, Any]]
|
||||
count: int
|
||||
warnings: list[str]
|
||||
skipped_unparseable: list[str]
|
||||
@@ -0,0 +1,492 @@
|
||||
"""FastMCP tool surface for the Google Calendar mock."""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import EmailStr
|
||||
|
||||
from .models import (
|
||||
AttendeeResponseStatus,
|
||||
AvailabilityDurationMinutes,
|
||||
BirthdayProperties,
|
||||
CalendarAccountId,
|
||||
CalendarAccountsState,
|
||||
CalendarId,
|
||||
CalendarPerson,
|
||||
CalendarState,
|
||||
CalendarSummary,
|
||||
CalendarTimeZone,
|
||||
EventAttendee,
|
||||
EventClearField,
|
||||
EventDateTime,
|
||||
EventId,
|
||||
EventReminders,
|
||||
EventSource,
|
||||
EventSummary,
|
||||
EventTransparency,
|
||||
EventType,
|
||||
ExtendedProperties,
|
||||
FocusTimeProperties,
|
||||
GoogleCalendarState,
|
||||
ListEventsMaxResults,
|
||||
OutOfOfficeProperties,
|
||||
RecurrenceRuleString,
|
||||
Rfc3339OffsetDateTimeString,
|
||||
SearchEventsMaxResults,
|
||||
SearchEventsOrderBy,
|
||||
SearchEventsResponse,
|
||||
WorkingLocationProperties,
|
||||
)
|
||||
from .state import list_accounts as list_calendar_accounts
|
||||
from .state import set_active_account, state_from_json, state_to_json, write_snapshots
|
||||
from .tools import availability, calendars, events, search
|
||||
|
||||
mcp = FastMCP("google_calendar")
|
||||
|
||||
|
||||
def _snapshot_on_write(fn):
|
||||
"""Write configured state snapshots after successful write tools.
|
||||
|
||||
The wrapper is async so the registered tool is a coroutine: FastMCP then
|
||||
validates and runs it on the event loop instead of a worker threadpool,
|
||||
avoiding the pydantic-core concurrency panic. See ``async_tool_guard``.
|
||||
"""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = fn(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
write_snapshots()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _with_account(fn):
|
||||
"""Decorator: extract account_id from tool calls and select that account."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
account_id = kwargs.pop("account_id", None)
|
||||
if account_id is not None:
|
||||
set_active_account(account_id)
|
||||
result = fn(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def export_state() -> GoogleCalendarState:
|
||||
"""Export the full google_calendar state as JSON.
|
||||
|
||||
Round-trips with import_state.
|
||||
"""
|
||||
state = state_to_json()
|
||||
if "accounts" in state:
|
||||
return CalendarAccountsState.model_validate(state)
|
||||
return CalendarState.model_validate(state)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_snapshot_on_write
|
||||
def import_state(state: GoogleCalendarState) -> dict:
|
||||
"""Replace the google_calendar state with the provided JSON.
|
||||
|
||||
For synthetic-data injection and test setup. Round-trips with export_state.
|
||||
"""
|
||||
state_from_json(state)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_accounts() -> dict:
|
||||
"""List available isolated Google Calendar accounts.
|
||||
|
||||
Multi-account worlds store each account as a fully separate calendar state.
|
||||
Use the returned ``account_id`` with other tools to select the account to
|
||||
read or mutate. Single-account worlds expose their only configured account.
|
||||
"""
|
||||
accounts = list_calendar_accounts()
|
||||
return {"status": "success", "accounts": accounts, "count": len(accounts)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
@_snapshot_on_write
|
||||
def create_event(
|
||||
summary: EventSummary,
|
||||
start: EventDateTime,
|
||||
end: EventDateTime,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
calendar_id: CalendarId = "primary",
|
||||
recurrence: list[RecurrenceRuleString] | None = None,
|
||||
reminders: EventReminders | None = None,
|
||||
attendees: list[EventAttendee] | None = None,
|
||||
creator: CalendarPerson | None = None,
|
||||
organizer: CalendarPerson | None = None,
|
||||
extendedProperties: ExtendedProperties | None = None,
|
||||
source: EventSource | None = None,
|
||||
transparency: EventTransparency | None = None,
|
||||
eventType: EventType = EventType.DEFAULT,
|
||||
outOfOfficeProperties: OutOfOfficeProperties | None = None,
|
||||
focusTimeProperties: FocusTimeProperties | None = None,
|
||||
workingLocationProperties: WorkingLocationProperties | None = None,
|
||||
birthdayProperties: BirthdayProperties | None = None,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Creates a new event in Google Calendar.
|
||||
|
||||
Args:
|
||||
summary: Event title
|
||||
start: Start time object with either 'dateTime' (ISO format, e.g. '2025-12-10T09:00:00-05:00')
|
||||
for timed events, or 'date' (YYYY-MM-DD, e.g. '2025-12-10') for all-day events.
|
||||
Optionally include 'timeZone' (e.g. 'America/New_York').
|
||||
end: End time object with either 'dateTime' or 'date' (same format as start).
|
||||
For all-day events, 'date' is exclusive (e.g. end '2025-12-11' means the event ends on 2025-12-10).
|
||||
description: Event description
|
||||
location: Event location
|
||||
calendar_id: Calendar to create the event in (default 'primary')
|
||||
recurrence: RRULE strings for recurring events (e.g., ['RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=10'])
|
||||
reminders: Reminder overrides, e.g. {"useDefault": false, "overrides": [{"method": "popup", "minutes": 15}]}
|
||||
attendees: List of attendees, e.g. [{"email": "alice@co.com", "displayName": "Alice"}]. responseStatus defaults to 'needsAction'.
|
||||
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
The created event object with its ID
|
||||
"""
|
||||
return events.create_event(
|
||||
summary=summary,
|
||||
start=start,
|
||||
end=end,
|
||||
description=description,
|
||||
location=location,
|
||||
calendar_id=calendar_id,
|
||||
recurrence=recurrence,
|
||||
reminders=reminders,
|
||||
attendees=attendees,
|
||||
creator=creator,
|
||||
organizer=organizer,
|
||||
extendedProperties=extendedProperties,
|
||||
source=source,
|
||||
transparency=transparency,
|
||||
eventType=eventType,
|
||||
outOfOfficeProperties=outOfOfficeProperties,
|
||||
focusTimeProperties=focusTimeProperties,
|
||||
workingLocationProperties=workingLocationProperties,
|
||||
birthdayProperties=birthdayProperties,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
def get_event(
|
||||
eventId: EventId,
|
||||
calendar_id: CalendarId = "primary",
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Retrieves details of a specific event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to retrieve
|
||||
calendar_id: Calendar to look in (default 'primary')
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
The event object if found
|
||||
"""
|
||||
return events.get_event(eventId=eventId, calendar_id=calendar_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
@_snapshot_on_write
|
||||
def update_event(
|
||||
eventId: EventId,
|
||||
summary: EventSummary | None = None,
|
||||
start: EventDateTime | None = None,
|
||||
end: EventDateTime | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
calendar_id: CalendarId = "primary",
|
||||
recurrence: list[RecurrenceRuleString] | None = None,
|
||||
reminders: EventReminders | None = None,
|
||||
attendees: list[EventAttendee] | None = None,
|
||||
creator: CalendarPerson | None = None,
|
||||
organizer: CalendarPerson | None = None,
|
||||
extendedProperties: ExtendedProperties | None = None,
|
||||
source: EventSource | None = None,
|
||||
transparency: EventTransparency | None = None,
|
||||
eventType: EventType | None = None,
|
||||
outOfOfficeProperties: OutOfOfficeProperties | None = None,
|
||||
focusTimeProperties: FocusTimeProperties | None = None,
|
||||
workingLocationProperties: WorkingLocationProperties | None = None,
|
||||
birthdayProperties: BirthdayProperties | None = None,
|
||||
clear_fields: list[EventClearField] | None = None,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Updates an existing event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to update
|
||||
summary: New event title
|
||||
start: New start time object with either 'dateTime' (ISO format) for timed events,
|
||||
or 'date' (YYYY-MM-DD) for all-day events. Optionally include 'timeZone'.
|
||||
end: New end time object with either 'dateTime' or 'date' (same format as start).
|
||||
description: New event description
|
||||
location: New event location
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
recurrence: RRULE strings for recurring events (pass empty list to remove recurrence)
|
||||
reminders: Reminder overrides (pass {"useDefault": true} to reset to defaults)
|
||||
attendees: Replace attendee list (pass empty list to remove all attendees)
|
||||
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
|
||||
eventType: Event type to set. Switching types clears properties that only belong to the old type.
|
||||
clear_fields: Optional event fields to remove, e.g. ['source', 'transparency'].
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
The updated event object
|
||||
"""
|
||||
return events.update_event(
|
||||
eventId=eventId,
|
||||
summary=summary,
|
||||
start=start,
|
||||
end=end,
|
||||
description=description,
|
||||
location=location,
|
||||
calendar_id=calendar_id,
|
||||
recurrence=recurrence,
|
||||
reminders=reminders,
|
||||
attendees=attendees,
|
||||
creator=creator,
|
||||
organizer=organizer,
|
||||
extendedProperties=extendedProperties,
|
||||
source=source,
|
||||
transparency=transparency,
|
||||
eventType=eventType,
|
||||
outOfOfficeProperties=outOfOfficeProperties,
|
||||
focusTimeProperties=focusTimeProperties,
|
||||
workingLocationProperties=workingLocationProperties,
|
||||
birthdayProperties=birthdayProperties,
|
||||
clear_fields=clear_fields,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
@_snapshot_on_write
|
||||
def delete_event(
|
||||
eventId: EventId,
|
||||
calendar_id: CalendarId = "primary",
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Deletes an event from the calendar.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to delete
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
Confirmation of deletion
|
||||
"""
|
||||
return events.delete_event(eventId=eventId, calendar_id=calendar_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
@_snapshot_on_write
|
||||
def respond_to_event(
|
||||
eventId: EventId,
|
||||
email: EmailStr,
|
||||
response: AttendeeResponseStatus,
|
||||
calendar_id: CalendarId = "primary",
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Update an attendee's RSVP response on an event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event
|
||||
email: Email address of the attendee responding
|
||||
response: Response status — 'accepted', 'declined', 'tentative', or 'needsAction'
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
The updated event with attendee responses
|
||||
"""
|
||||
return events.respond_to_event(eventId=eventId, email=email, response=response, calendar_id=calendar_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
@_snapshot_on_write
|
||||
def create_calendar(
|
||||
summary: CalendarSummary,
|
||||
description: str | None = None,
|
||||
timeZone: CalendarTimeZone | None = None,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Creates a new calendar (e.g., 'Personal', 'Team Meetings', 'On-Call').
|
||||
|
||||
Args:
|
||||
summary: Calendar name
|
||||
description: Calendar description
|
||||
timeZone: IANA time zone for the calendar. Defaults to the primary calendar time zone.
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
The created calendar object
|
||||
"""
|
||||
return calendars.create_calendar(summary=summary, description=description, timeZone=timeZone)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
def list_calendars(account_id: CalendarAccountId | None = None) -> dict:
|
||||
"""
|
||||
Lists all calendars. Always includes the 'primary' calendar plus any
|
||||
additional calendars that have been created.
|
||||
|
||||
Returns:
|
||||
List of calendars with their IDs, names, and event counts
|
||||
"""
|
||||
return calendars.list_calendars()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
def list_events(
|
||||
timeMin: Rfc3339OffsetDateTimeString,
|
||||
timeMax: Rfc3339OffsetDateTimeString,
|
||||
maxResults: ListEventsMaxResults | None = None,
|
||||
orderBy: SearchEventsOrderBy | None = None,
|
||||
calendar_id: CalendarId | None = None,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Lists events within a specified time range.
|
||||
|
||||
Args:
|
||||
timeMin: Lower bound (exclusive) for an event's end time (ISO format).
|
||||
Events that end after this time are included.
|
||||
timeMax: Upper bound (exclusive) for an event's start time (ISO format).
|
||||
Events that start before this time are included.
|
||||
maxResults: Maximum number of events to return
|
||||
orderBy: Sort order ('startTime' or 'updated')
|
||||
calendar_id: Calendar to list events from. If omitted, lists events from all calendars.
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
List of events that overlap the given time range
|
||||
"""
|
||||
return search.list_events(
|
||||
timeMin=timeMin,
|
||||
timeMax=timeMax,
|
||||
maxResults=maxResults,
|
||||
orderBy=orderBy,
|
||||
calendar_id=calendar_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
def search_events(
|
||||
query: str = "",
|
||||
timeMin: Rfc3339OffsetDateTimeString | None = None,
|
||||
timeMax: Rfc3339OffsetDateTimeString | None = None,
|
||||
calendar_id: CalendarId | None = None,
|
||||
attendee_email: EmailStr | None = None,
|
||||
response_status: AttendeeResponseStatus | None = None,
|
||||
organizer_email: EmailStr | None = None,
|
||||
creator_email: EmailStr | None = None,
|
||||
maxResults: SearchEventsMaxResults | None = None,
|
||||
orderBy: SearchEventsOrderBy | None = SearchEventsOrderBy.START_TIME,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> SearchEventsResponse:
|
||||
"""
|
||||
Search calendar events by keyword, phrase, attendee, location, or calendar.
|
||||
|
||||
Args:
|
||||
query: Search query. Bare words are ANDed across summary, description,
|
||||
location, attendees, and calendar names. Quoted phrases require
|
||||
exact adjacency. May be empty when attendee_email is provided.
|
||||
timeMin: Optional lower bound (exclusive) for an event's end time.
|
||||
timeMax: Optional upper bound (exclusive) for an event's start time.
|
||||
calendar_id: Calendar to search. If omitted, searches all calendars.
|
||||
attendee_email: Optional attendee email or display-name substring.
|
||||
response_status: Optional attendee RSVP filter ('accepted', 'declined',
|
||||
'tentative', or 'needsAction'). When attendee_email is
|
||||
provided, applies to that attendee; otherwise matches
|
||||
any attendee with that status.
|
||||
organizer_email: Optional organizer email substring.
|
||||
creator_email: Optional creator email substring.
|
||||
maxResults: Maximum number of matching events to return. 0 returns no results.
|
||||
orderBy: Sort order ('startTime', 'updated', or None).
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
Matching events with calendar_id annotated on each result.
|
||||
"""
|
||||
return search.search_events(
|
||||
query=query,
|
||||
timeMin=timeMin,
|
||||
timeMax=timeMax,
|
||||
calendar_id=calendar_id,
|
||||
attendee_email=attendee_email,
|
||||
response_status=response_status,
|
||||
organizer_email=organizer_email,
|
||||
creator_email=creator_email,
|
||||
maxResults=maxResults,
|
||||
orderBy=orderBy,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@_with_account
|
||||
def check_availability(
|
||||
timeMin: Rfc3339OffsetDateTimeString,
|
||||
timeMax: Rfc3339OffsetDateTimeString,
|
||||
duration_minutes: AvailabilityDurationMinutes = 30,
|
||||
calendar_id: CalendarId | None = None,
|
||||
attendee_emails: list[EmailStr] | None = None,
|
||||
account_id: CalendarAccountId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Check availability within a time range. Returns busy periods and free slots
|
||||
of at least the requested duration.
|
||||
|
||||
When attendee_emails is provided, only events where at least one of those
|
||||
people is an attendee (and hasn't declined) are considered busy. This lets
|
||||
you find times that work for specific people.
|
||||
|
||||
Args:
|
||||
timeMin: Start of the range to check (ISO format)
|
||||
timeMax: End of the range to check (ISO format)
|
||||
duration_minutes: Minimum free slot duration in minutes (default 30)
|
||||
calendar_id: Calendar to check. If omitted, checks all calendars.
|
||||
attendee_emails: Only consider events involving these attendees (optional)
|
||||
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
|
||||
|
||||
Returns:
|
||||
Busy periods and available free slots within the range
|
||||
"""
|
||||
return availability.check_availability(
|
||||
timeMin=timeMin,
|
||||
timeMax=timeMax,
|
||||
duration_minutes=duration_minutes,
|
||||
calendar_id=calendar_id,
|
||||
attendee_emails=attendee_emails,
|
||||
)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""State loading, saving, and storage helpers for the Google Calendar mock."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .models import DEFAULT_CALENDAR_TIME_ZONE, CalendarAccountsState, CalendarState
|
||||
from .utils import get_calendar_data_path
|
||||
|
||||
SERVICE_NAME = "google_calendar"
|
||||
_UNSET = object()
|
||||
|
||||
# Will be set by argparse before server starts.
|
||||
_AGENT_WORKSPACE_ARG: str | None = None
|
||||
|
||||
# Cached data file path (lazy initialization). ``None`` means in-memory only.
|
||||
_DATA_FILE: Path | None = None
|
||||
_final_path: Path | None = None
|
||||
_bundle_state_path: Path | None = None
|
||||
_active_account_id: str = "default"
|
||||
_accounts: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def resolve_bundle_state_paths() -> list[Path]:
|
||||
"""Resolve the seed-state files inside this service's bundle subdir.
|
||||
|
||||
The folder ``<BUNDLEDIR>/services/<name>/`` is the unit: everything in it
|
||||
is this service's seed. Prefer the canonical single-file ``state.json``
|
||||
(the output round-trip shape); otherwise hand back ALL ``*.json`` in the
|
||||
folder (the raw entities layout), coalesced by the loader.
|
||||
"""
|
||||
bundle_dir = os.environ.get("BUNDLEDIR")
|
||||
if not bundle_dir:
|
||||
return []
|
||||
service_dir = Path(bundle_dir) / "services" / SERVICE_NAME
|
||||
state_file = service_dir / "state.json"
|
||||
if state_file.is_file():
|
||||
return [state_file]
|
||||
if service_dir.is_dir():
|
||||
return sorted(service_dir.glob("*.json"))
|
||||
return []
|
||||
|
||||
|
||||
def resolve_bundle_state_path() -> Path | None:
|
||||
"""Back-compat single-file view of :func:`resolve_bundle_state_paths`."""
|
||||
paths = resolve_bundle_state_paths()
|
||||
return paths[0] if paths else None
|
||||
|
||||
|
||||
def _merge_flat_into(target: dict[str, Any], source: dict[str, Any]) -> None:
|
||||
"""Merge a flat account seed into ``target``: dicts update, lists extend, scalars overwrite."""
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
target[key].update(value)
|
||||
elif isinstance(value, list) and isinstance(target.get(key), list):
|
||||
target[key].extend(value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
|
||||
def _coalesce_account_files(paths: list[Path]) -> dict[str, Any] | None:
|
||||
"""Coalesce a folder of seed files into one seed dict.
|
||||
|
||||
A single file passes through unchanged (flat single-account or ``{accounts:
|
||||
{...}}`` wrapper). With multiple files, flat (non-wrapper) files are merged
|
||||
into ONE ``default`` account — the raw entities layout splits a single
|
||||
account across per-entity files (``events.json`` + ``calendars.json``), and
|
||||
those halves belong together, not in separate accounts. Files carrying an
|
||||
explicit ``{accounts: {...}}`` wrapper contribute their named accounts.
|
||||
"""
|
||||
if not paths:
|
||||
return None
|
||||
if len(paths) == 1:
|
||||
return cast(dict[str, Any], json.loads(paths[0].read_text()))
|
||||
accounts: dict[str, Any] = {}
|
||||
default_account: dict[str, Any] = {}
|
||||
for path in paths:
|
||||
data = json.loads(path.read_text())
|
||||
if isinstance(data, dict) and "accounts" in data:
|
||||
accounts.update(data["accounts"])
|
||||
elif isinstance(data, dict):
|
||||
_merge_flat_into(default_account, data)
|
||||
if default_account:
|
||||
# Flat files form the default account unless a wrapper already named one.
|
||||
accounts.setdefault("default", default_account)
|
||||
return {"accounts": accounts}
|
||||
|
||||
|
||||
def resolve_bundle_output_path() -> Path | None:
|
||||
output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
|
||||
if not output_dir:
|
||||
return None
|
||||
return Path(output_dir) / "state.json"
|
||||
|
||||
|
||||
def set_agent_workspace(workspace: str | None) -> None:
|
||||
"""Set the agent workspace used to resolve the calendar data file."""
|
||||
global _AGENT_WORKSPACE_ARG, _DATA_FILE, _active_account_id
|
||||
_AGENT_WORKSPACE_ARG = workspace
|
||||
_DATA_FILE = None
|
||||
_active_account_id = "default"
|
||||
_accounts.clear()
|
||||
|
||||
|
||||
def get_data_file_path() -> Path | None:
|
||||
"""
|
||||
Get the data file path. Checks in order:
|
||||
1. --agent-workspace CLI argument (computes path via shared function)
|
||||
2. AGENT_WORKSPACE environment variable
|
||||
3. None for in-memory local testing
|
||||
"""
|
||||
global _AGENT_WORKSPACE_ARG
|
||||
|
||||
# Check CLI arg first (set by argparse before mcp.run())
|
||||
if _AGENT_WORKSPACE_ARG:
|
||||
return get_calendar_data_path(_AGENT_WORKSPACE_ARG)
|
||||
|
||||
# Check env var
|
||||
env_workspace = os.environ.get("AGENT_WORKSPACE")
|
||||
if env_workspace:
|
||||
return get_calendar_data_path(env_workspace)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_data_file() -> Path | None:
|
||||
"""Get cached data file path, initializing on first call."""
|
||||
global _DATA_FILE
|
||||
if _DATA_FILE is None:
|
||||
_DATA_FILE = get_data_file_path()
|
||||
return _DATA_FILE
|
||||
|
||||
|
||||
def set_snapshot_paths(
|
||||
*,
|
||||
final_path: Path | str | None | object = _UNSET,
|
||||
bundle_state_path: Path | str | None | object = _UNSET,
|
||||
) -> None:
|
||||
"""Set snapshot output paths.
|
||||
|
||||
Omitted keyword arguments leave the corresponding path unchanged. Passing
|
||||
``None`` explicitly clears that path.
|
||||
"""
|
||||
global _final_path, _bundle_state_path
|
||||
if final_path is not _UNSET:
|
||||
_final_path = Path(cast(str | Path, final_path)) if final_path is not None else None
|
||||
if bundle_state_path is not _UNSET:
|
||||
_bundle_state_path = Path(cast(str | Path, bundle_state_path)) if bundle_state_path is not None else None
|
||||
|
||||
|
||||
def get_final_path() -> Path | None:
|
||||
return _final_path
|
||||
|
||||
|
||||
def get_bundle_state_path() -> Path | None:
|
||||
return _bundle_state_path
|
||||
|
||||
|
||||
def dump_state(dest: Path, label: str) -> None:
|
||||
"""Write a JSON snapshot of the current calendar state to *dest*."""
|
||||
try:
|
||||
data = state_to_json()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f"Wrote {label} state to {dest}")
|
||||
except Exception as e:
|
||||
print(f"Error writing {label} state to {dest}: {e}")
|
||||
|
||||
|
||||
def write_snapshots() -> None:
|
||||
"""Write configured post-tool snapshots."""
|
||||
if _bundle_state_path is not None:
|
||||
dump_state(_bundle_state_path, "bundle")
|
||||
if _final_path is not None:
|
||||
dump_state(_final_path, "final")
|
||||
|
||||
|
||||
def configure_snapshots_from_env(*, write_initial: bool = False) -> None:
|
||||
"""Configure snapshot paths from OUTPUTDIR/BUNDLE_OUTPUT_DIR."""
|
||||
outputdir = os.environ.get("OUTPUTDIR")
|
||||
|
||||
set_snapshot_paths(
|
||||
bundle_state_path=resolve_bundle_output_path(),
|
||||
final_path=(Path(outputdir) / "final.json") if outputdir else None,
|
||||
)
|
||||
|
||||
if write_initial:
|
||||
if _bundle_state_path is not None:
|
||||
dump_state(_bundle_state_path, "bundle")
|
||||
if outputdir:
|
||||
dump_state(Path(outputdir) / "initial.json", "initial")
|
||||
|
||||
|
||||
def load_seed_state_from_env() -> None:
|
||||
"""Load startup seed state from BUNDLEDIR or INPUTDIR, if present."""
|
||||
# The bundle folder is read in full and coalesced (a lone state.json is a
|
||||
# one-element list); otherwise fall back to the first INPUTDIR/*.json.
|
||||
bundle_paths = resolve_bundle_state_paths()
|
||||
inputdir = os.environ.get("INPUTDIR")
|
||||
if bundle_paths:
|
||||
try:
|
||||
seed = _coalesce_account_files(bundle_paths)
|
||||
assert seed is not None
|
||||
state_from_json(seed)
|
||||
print(f"Loaded state from {[str(p) for p in bundle_paths]}")
|
||||
except Exception as e:
|
||||
print(f"Warning: failed to load {[str(p) for p in bundle_paths]}: {e}")
|
||||
return
|
||||
|
||||
if inputdir and Path(inputdir).is_dir():
|
||||
json_files = sorted(Path(inputdir).glob("*.json"))
|
||||
if json_files:
|
||||
try:
|
||||
with open(json_files[0]) as f:
|
||||
state_from_json(json.load(f))
|
||||
print(f"Loaded state from {json_files[0]}")
|
||||
except Exception as e:
|
||||
print(f"Warning: failed to load {json_files[0]}: {e}")
|
||||
|
||||
|
||||
def init_state(agent_workspace: str | None = None, *, write_initial_snapshot: bool = True) -> None:
|
||||
"""Initialize calendar state paths, seed data, and snapshot outputs."""
|
||||
if agent_workspace:
|
||||
set_agent_workspace(agent_workspace)
|
||||
|
||||
print(f"BUNDLEDIR={os.environ.get('BUNDLEDIR', '<unset>')}")
|
||||
print(f"INPUTDIR={os.environ.get('INPUTDIR', '<unset>')}")
|
||||
print(f"OUTPUTDIR={os.environ.get('OUTPUTDIR', '<unset>')}")
|
||||
|
||||
load_seed_state_from_env()
|
||||
configure_snapshots_from_env(write_initial=write_initial_snapshot)
|
||||
|
||||
|
||||
def _read_storage_data() -> dict[str, Any]:
|
||||
data_file = get_data_file()
|
||||
if data_file is not None and data_file.exists():
|
||||
with open(data_file) as f:
|
||||
return json.load(f)
|
||||
return {"events": {}}
|
||||
|
||||
|
||||
def _accounts_from_storage(
|
||||
data: dict[str, Any] | CalendarState | CalendarAccountsState,
|
||||
*,
|
||||
validate: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
if isinstance(data, (CalendarAccountsState, CalendarState)):
|
||||
data = data.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
if "accounts" in data:
|
||||
if validate:
|
||||
accounts = CalendarAccountsState.model_validate(data).accounts
|
||||
return {
|
||||
account_id: account.model_dump(mode="json", exclude_none=True)
|
||||
for account_id, account in accounts.items()
|
||||
}
|
||||
return {account_id: deepcopy(account) for account_id, account in data["accounts"].items()}
|
||||
|
||||
if validate:
|
||||
account = CalendarState.model_validate(data)
|
||||
return {"default": account.model_dump(mode="json", exclude_none=True)}
|
||||
return {"default": deepcopy(data)}
|
||||
|
||||
|
||||
def _storage_from_accounts(accounts: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
if len(accounts) == 1 and "default" in accounts:
|
||||
return accounts["default"]
|
||||
return {"accounts": accounts}
|
||||
|
||||
|
||||
def _write_storage(accounts: dict[str, dict[str, Any]]) -> None:
|
||||
data_file = get_data_file()
|
||||
if data_file is None:
|
||||
return
|
||||
with open(data_file, "w") as f:
|
||||
json.dump(_storage_from_accounts(accounts), f, indent=2)
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
global _active_account_id
|
||||
if _accounts:
|
||||
return
|
||||
_accounts.update(_accounts_from_storage(_read_storage_data()))
|
||||
_active_account_id = "default" if "default" in _accounts else next(iter(_accounts), "default")
|
||||
|
||||
|
||||
def set_active_account(account_id: str) -> None:
|
||||
"""Select the account used by subsequent tool handlers."""
|
||||
global _active_account_id
|
||||
_ensure_loaded()
|
||||
if account_id not in _accounts:
|
||||
available = ", ".join(sorted(_accounts.keys()))
|
||||
raise ValueError(f"Calendar account '{account_id}' not found. Available: {available}")
|
||||
_active_account_id = account_id
|
||||
|
||||
|
||||
def get_active_account_id() -> str:
|
||||
return _active_account_id
|
||||
|
||||
|
||||
def get_default_account_id() -> str:
|
||||
"""Return the deterministic default account for viewer reads."""
|
||||
_ensure_loaded()
|
||||
if "default" in _accounts:
|
||||
return "default"
|
||||
return next(iter(_accounts), _active_account_id)
|
||||
|
||||
|
||||
def list_accounts() -> list[dict[str, Any]]:
|
||||
"""List available isolated calendar accounts."""
|
||||
_ensure_loaded()
|
||||
result = []
|
||||
for account_id, account in _accounts.items():
|
||||
calendars = get_all_calendars(account)
|
||||
result.append(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"calendar_count": len(calendars),
|
||||
"event_count": sum(calendar["eventCount"] for calendar in calendars),
|
||||
"timeZone": account.get("timeZone", DEFAULT_CALENDAR_TIME_ZONE),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def load_data(account_id: str | None = None) -> dict:
|
||||
"""Return the in-memory calendar data for the selected account.
|
||||
|
||||
INPUTDIR seeding happens once at startup via state_from_json() in
|
||||
__main__; after that the in-memory account registry is the canonical
|
||||
state and the data file is its persisted serialization.
|
||||
"""
|
||||
_ensure_loaded()
|
||||
selected_account = account_id or _active_account_id
|
||||
if selected_account not in _accounts:
|
||||
available = ", ".join(sorted(_accounts.keys()))
|
||||
raise ValueError(f"Calendar account '{selected_account}' not found. Available: {available}")
|
||||
return _accounts[selected_account]
|
||||
|
||||
|
||||
def save_data(data: dict, account_id: str | None = None) -> None:
|
||||
"""Validate and save calendar data for the selected account to JSON file."""
|
||||
_ensure_loaded()
|
||||
selected_account = account_id or _active_account_id
|
||||
validated = CalendarState.model_validate(data).model_dump(mode="json", exclude_none=True)
|
||||
if selected_account not in _accounts:
|
||||
available = ", ".join(sorted(_accounts.keys()))
|
||||
raise ValueError(f"Calendar account '{selected_account}' not found. Available: {available}")
|
||||
_accounts[selected_account] = validated
|
||||
_write_storage(_accounts)
|
||||
|
||||
|
||||
def save_data_or_error(data: dict) -> dict | None:
|
||||
try:
|
||||
save_data(data)
|
||||
except ValidationError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
return None
|
||||
|
||||
|
||||
def generate_event_id() -> str:
|
||||
"""Generate a unique event ID."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-Calendar Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_calendar_events(data: dict, calendar_id: str) -> dict:
|
||||
"""Get the events dict for a calendar. Handles backward compatibility:
|
||||
- If data has 'calendars' dict, looks up by calendar_id
|
||||
- If data only has flat 'events' dict, treats it as 'primary'
|
||||
Returns the events dict (mutable reference into data).
|
||||
"""
|
||||
if "calendars" in data and calendar_id in data["calendars"]:
|
||||
return data["calendars"][calendar_id].setdefault("events", {})
|
||||
|
||||
# Backward compat: flat 'events' dict is the primary calendar
|
||||
if calendar_id == "primary":
|
||||
return data.setdefault("events", {})
|
||||
|
||||
# Calendar doesn't exist
|
||||
return {}
|
||||
|
||||
|
||||
def set_calendar_event(data: dict, calendar_id: str, event_id: str, event: dict) -> bool:
|
||||
"""Set an event in a calendar. Returns False if calendar doesn't exist."""
|
||||
if "calendars" in data and calendar_id in data["calendars"]:
|
||||
if "events" not in data["calendars"][calendar_id]:
|
||||
data["calendars"][calendar_id]["events"] = {}
|
||||
data["calendars"][calendar_id]["events"][event_id] = event
|
||||
return True
|
||||
|
||||
if calendar_id == "primary":
|
||||
if "events" not in data:
|
||||
data["events"] = {}
|
||||
data["events"][event_id] = event
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def delete_calendar_event(data: dict, calendar_id: str, event_id: str) -> dict | None:
|
||||
"""Delete an event from a calendar. Returns the deleted event or None."""
|
||||
events = get_calendar_events(data, calendar_id)
|
||||
if event_id in events:
|
||||
return events.pop(event_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_all_calendars(data: dict) -> list[dict]:
|
||||
"""Get list of all calendars with metadata."""
|
||||
calendars = []
|
||||
|
||||
# Always include primary
|
||||
primary_events = data.get("events", {})
|
||||
primary_time_zone = data.get("timeZone", DEFAULT_CALENDAR_TIME_ZONE)
|
||||
calendars.append(
|
||||
{
|
||||
"id": "primary",
|
||||
"summary": "Primary",
|
||||
"description": "Default calendar",
|
||||
"timeZone": primary_time_zone,
|
||||
"primary": True,
|
||||
"eventCount": len(primary_events),
|
||||
}
|
||||
)
|
||||
|
||||
# Add any explicit calendars (except primary which we already handled)
|
||||
for cal_id, cal_data in data.get("calendars", {}).items():
|
||||
if cal_id == "primary":
|
||||
continue
|
||||
calendars.append(
|
||||
{
|
||||
"id": cal_id,
|
||||
"summary": cal_data.get("summary", cal_id),
|
||||
"description": cal_data.get("description", ""),
|
||||
"timeZone": cal_data.get("timeZone", primary_time_zone),
|
||||
"primary": False,
|
||||
"eventCount": len(cal_data.get("events", {})),
|
||||
}
|
||||
)
|
||||
|
||||
return calendars
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State Management Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def state_to_json() -> dict:
|
||||
"""Return the full calendar state as a JSON-native dict.
|
||||
|
||||
Round-trips with state_from_json. Emits both legacy flat ``events`` shape
|
||||
and the multi-calendar ``calendars`` dict when present, so multi-calendar
|
||||
worlds survive an export/import cycle.
|
||||
"""
|
||||
_ensure_loaded()
|
||||
return deepcopy(_storage_from_accounts(_accounts))
|
||||
|
||||
|
||||
def state_from_json(data: dict[str, Any] | CalendarState | CalendarAccountsState) -> None:
|
||||
"""Full-replace the calendar state from a JSON-native dict."""
|
||||
global _active_account_id
|
||||
accounts = _accounts_from_storage(data, validate=True)
|
||||
_accounts.clear()
|
||||
_accounts.update(accounts)
|
||||
_write_storage(_accounts)
|
||||
_active_account_id = "default" if "default" in _accounts else next(iter(_accounts), "default")
|
||||
@@ -0,0 +1 @@
|
||||
"""Tool implementation modules for the Google Calendar mock."""
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Availability tool implementation for the Google Calendar mock."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pydantic import EmailStr
|
||||
|
||||
from google_calendar.models import (
|
||||
AvailabilityDurationMinutes,
|
||||
CalendarId,
|
||||
EventTransparency,
|
||||
Rfc3339OffsetDateTimeString,
|
||||
)
|
||||
from google_calendar.state import get_calendar_events, load_data
|
||||
from google_calendar.tools.search import _expand_recurring_event, _parse_event_end, _parse_event_start
|
||||
|
||||
|
||||
def check_availability(
|
||||
timeMin: Rfc3339OffsetDateTimeString,
|
||||
timeMax: Rfc3339OffsetDateTimeString,
|
||||
duration_minutes: AvailabilityDurationMinutes = 30,
|
||||
calendar_id: CalendarId | None = None,
|
||||
attendee_emails: list[EmailStr] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Check availability within a time range. Returns busy periods and free slots
|
||||
of at least the requested duration.
|
||||
|
||||
When attendee_emails is provided, only events where at least one of those
|
||||
people is an attendee (and hasn't declined) are considered busy. This lets
|
||||
you find times that work for specific people.
|
||||
|
||||
Args:
|
||||
timeMin: Start of the range to check (ISO format)
|
||||
timeMax: End of the range to check (ISO format)
|
||||
duration_minutes: Minimum free slot duration in minutes (default 30)
|
||||
calendar_id: Calendar to check. If omitted, checks all calendars.
|
||||
attendee_emails: Only consider events involving these attendees (optional)
|
||||
|
||||
Returns:
|
||||
Busy periods and available free slots within the range
|
||||
"""
|
||||
data = load_data()
|
||||
|
||||
# Parse boundaries
|
||||
try:
|
||||
range_start = datetime.fromisoformat(timeMin)
|
||||
range_end = datetime.fromisoformat(timeMax)
|
||||
except ValueError as e:
|
||||
return {"status": "error", "message": f"Invalid time format: {e}"}
|
||||
|
||||
# Normalize timezone awareness
|
||||
if range_start.tzinfo is None and range_end.tzinfo is not None:
|
||||
range_start = range_start.replace(tzinfo=range_end.tzinfo)
|
||||
elif range_start.tzinfo is not None and range_end.tzinfo is None:
|
||||
range_end = range_end.replace(tzinfo=range_start.tzinfo)
|
||||
|
||||
# Collect events from requested calendar(s). Event IDs are scoped to a
|
||||
# calendar, so all-calendar availability must not merge by event ID.
|
||||
all_events: list[dict] = []
|
||||
if calendar_id:
|
||||
all_events = list(get_calendar_events(data, calendar_id).values())
|
||||
else:
|
||||
all_events.extend(data.get("events", {}).values())
|
||||
for cal_data in data.get("calendars", {}).values():
|
||||
all_events.extend(cal_data.get("events", {}).values())
|
||||
|
||||
# Expand recurring events and collect all event instances
|
||||
expanded_events: list[dict] = []
|
||||
for event in all_events:
|
||||
if event.get("recurrence"):
|
||||
expanded_events.extend(_expand_recurring_event(event, range_start, range_end))
|
||||
else:
|
||||
expanded_events.append(event)
|
||||
|
||||
# Filter by attendee emails if specified
|
||||
if attendee_emails:
|
||||
emails_lower = {e.lower() for e in attendee_emails}
|
||||
filtered = []
|
||||
for event in expanded_events:
|
||||
event_attendees = event.get("attendees", [])
|
||||
if not event_attendees:
|
||||
# Events without attendees are considered relevant (owner's events)
|
||||
filtered.append(event)
|
||||
continue
|
||||
for a in event_attendees:
|
||||
if a.get("email", "").lower() in emails_lower and a.get("responseStatus") != "declined":
|
||||
filtered.append(event)
|
||||
break
|
||||
expanded_events = filtered
|
||||
|
||||
# Find busy periods within the range
|
||||
busy_periods: list[tuple[datetime, datetime, str]] = []
|
||||
for event in expanded_events:
|
||||
if event.get("transparency") == EventTransparency.TRANSPARENT.value:
|
||||
continue
|
||||
|
||||
event_start = _parse_event_start(event)
|
||||
event_end = _parse_event_end(event)
|
||||
if event_start is None or event_end is None:
|
||||
continue
|
||||
|
||||
# Normalize timezone
|
||||
if event_start.tzinfo is None and range_start.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=range_start.tzinfo)
|
||||
elif event_start.tzinfo is not None and range_start.tzinfo is None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
if event_end.tzinfo is None and range_start.tzinfo is not None:
|
||||
event_end = event_end.replace(tzinfo=range_start.tzinfo)
|
||||
elif event_end.tzinfo is not None and range_start.tzinfo is None:
|
||||
event_end = event_end.replace(tzinfo=None)
|
||||
|
||||
# Check overlap with requested range
|
||||
if event_end > range_start and event_start < range_end:
|
||||
# Clamp to range
|
||||
clamped_start = max(event_start, range_start)
|
||||
clamped_end = min(event_end, range_end)
|
||||
busy_periods.append((clamped_start, clamped_end, event.get("summary", "")))
|
||||
|
||||
# Sort busy periods by start time
|
||||
busy_periods.sort(key=lambda x: x[0])
|
||||
|
||||
# Merge overlapping busy periods
|
||||
merged: list[tuple[datetime, datetime, list[str]]] = []
|
||||
for start, end, summary in busy_periods:
|
||||
if merged and start <= merged[-1][1]:
|
||||
# Overlapping — extend
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end), merged[-1][2] + [summary])
|
||||
else:
|
||||
merged.append((start, end, [summary]))
|
||||
|
||||
# Find free slots
|
||||
min_duration = timedelta(minutes=duration_minutes)
|
||||
free_slots: list[dict] = []
|
||||
cursor = range_start
|
||||
|
||||
for busy_start, busy_end, _summaries in merged:
|
||||
gap = busy_start - cursor
|
||||
if gap >= min_duration:
|
||||
free_slots.append(
|
||||
{
|
||||
"start": cursor.isoformat(),
|
||||
"end": busy_start.isoformat(),
|
||||
"duration_minutes": int(gap.total_seconds() / 60),
|
||||
}
|
||||
)
|
||||
cursor = max(cursor, busy_end)
|
||||
|
||||
# Check final gap
|
||||
final_gap = range_end - cursor
|
||||
if final_gap >= min_duration:
|
||||
free_slots.append(
|
||||
{
|
||||
"start": cursor.isoformat(),
|
||||
"end": range_end.isoformat(),
|
||||
"duration_minutes": int(final_gap.total_seconds() / 60),
|
||||
}
|
||||
)
|
||||
|
||||
busy_formatted = [
|
||||
{
|
||||
"start": s.isoformat(),
|
||||
"end": e.isoformat(),
|
||||
"events": summaries,
|
||||
}
|
||||
for s, e, summaries in merged
|
||||
]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"busy": busy_formatted,
|
||||
"free_slots": free_slots,
|
||||
"total_busy_minutes": sum(int((e - s).total_seconds() / 60) for s, e, _ in merged),
|
||||
"total_free_minutes": sum(slot["duration_minutes"] for slot in free_slots),
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Calendar metadata tool implementations for the Google Calendar mock."""
|
||||
|
||||
from google_calendar.models import DEFAULT_CALENDAR_TIME_ZONE, Calendar, CalendarSummary, CalendarTimeZone
|
||||
from google_calendar.state import get_all_calendars, load_data, save_data_or_error
|
||||
|
||||
|
||||
def create_calendar(
|
||||
summary: CalendarSummary,
|
||||
description: str | None = None,
|
||||
timeZone: CalendarTimeZone | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Creates a new calendar (e.g., 'Personal', 'Team Meetings', 'On-Call').
|
||||
|
||||
Args:
|
||||
summary: Calendar name
|
||||
description: Calendar description
|
||||
timeZone: IANA time zone for the calendar. Defaults to the primary calendar time zone.
|
||||
|
||||
Returns:
|
||||
The created calendar object
|
||||
"""
|
||||
data = load_data()
|
||||
|
||||
# Generate calendar ID from summary
|
||||
cal_id = summary.lower().replace(" ", "-")
|
||||
cal_id = "".join(c for c in cal_id if c.isalnum() or c == "-")
|
||||
cal_id = cal_id.strip("-")
|
||||
|
||||
if "calendars" not in data:
|
||||
data["calendars"] = {}
|
||||
|
||||
if not cal_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Calendar summary must contain at least one letter or number",
|
||||
}
|
||||
|
||||
if cal_id in data["calendars"] or cal_id == "primary":
|
||||
return {"status": "error", "message": f"Calendar '{cal_id}' already exists"}
|
||||
|
||||
calendar = Calendar.model_validate(
|
||||
{
|
||||
"summary": summary,
|
||||
"description": description or "",
|
||||
"timeZone": timeZone or data.get("timeZone", DEFAULT_CALENDAR_TIME_ZONE),
|
||||
"events": {},
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
|
||||
data["calendars"][cal_id] = calendar
|
||||
if error := save_data_or_error(data):
|
||||
return error
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"calendar": {
|
||||
"id": cal_id,
|
||||
"summary": calendar["summary"],
|
||||
"description": calendar["description"],
|
||||
"timeZone": calendar["timeZone"],
|
||||
"primary": False,
|
||||
"eventCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_calendars() -> dict:
|
||||
"""
|
||||
Lists all calendars. Always includes the 'primary' calendar plus any
|
||||
additional calendars that have been created.
|
||||
|
||||
Returns:
|
||||
List of calendars with their IDs, names, and event counts
|
||||
"""
|
||||
data = load_data()
|
||||
calendars = get_all_calendars(data)
|
||||
return {"status": "success", "calendars": calendars, "count": len(calendars)}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Event tool implementations for the Google Calendar mock."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import EmailStr, ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
EVENT_TYPE_PROPERTY_FIELDS,
|
||||
AttendeeResponseStatus,
|
||||
BirthdayProperties,
|
||||
CalendarId,
|
||||
CalendarPerson,
|
||||
Event,
|
||||
EventAttendee,
|
||||
EventClearField,
|
||||
EventDateTime,
|
||||
EventId,
|
||||
EventReminders,
|
||||
EventSource,
|
||||
EventSummary,
|
||||
EventTransparency,
|
||||
EventType,
|
||||
ExtendedProperties,
|
||||
FocusTimeProperties,
|
||||
OutOfOfficeProperties,
|
||||
RecurrenceRuleString,
|
||||
WorkingLocationProperties,
|
||||
dump_model,
|
||||
event_attendee_payload,
|
||||
)
|
||||
from google_calendar.state import (
|
||||
delete_calendar_event,
|
||||
generate_event_id,
|
||||
get_calendar_events,
|
||||
load_data,
|
||||
save_data_or_error,
|
||||
set_calendar_event,
|
||||
)
|
||||
|
||||
|
||||
def create_event(
|
||||
summary: EventSummary,
|
||||
start: EventDateTime,
|
||||
end: EventDateTime,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
calendar_id: CalendarId = "primary",
|
||||
recurrence: list[RecurrenceRuleString] | None = None,
|
||||
reminders: EventReminders | None = None,
|
||||
attendees: list[EventAttendee] | None = None,
|
||||
creator: CalendarPerson | None = None,
|
||||
organizer: CalendarPerson | None = None,
|
||||
extendedProperties: ExtendedProperties | None = None,
|
||||
source: EventSource | None = None,
|
||||
transparency: EventTransparency | None = None,
|
||||
eventType: EventType = EventType.DEFAULT,
|
||||
outOfOfficeProperties: OutOfOfficeProperties | None = None,
|
||||
focusTimeProperties: FocusTimeProperties | None = None,
|
||||
workingLocationProperties: WorkingLocationProperties | None = None,
|
||||
birthdayProperties: BirthdayProperties | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Creates a new event in Google Calendar.
|
||||
|
||||
Args:
|
||||
summary: Event title
|
||||
start: Start time object with either 'dateTime' (ISO format, e.g. '2025-12-10T09:00:00-05:00')
|
||||
for timed events, or 'date' (YYYY-MM-DD, e.g. '2025-12-10') for all-day events.
|
||||
Optionally include 'timeZone' (e.g. 'America/New_York').
|
||||
end: End time object with either 'dateTime' or 'date' (same format as start).
|
||||
For all-day events, 'date' is exclusive (e.g. end '2025-12-11' means the event ends on 2025-12-10).
|
||||
description: Event description
|
||||
location: Event location
|
||||
calendar_id: Calendar to create the event in (default 'primary')
|
||||
recurrence: RRULE strings for recurring events (e.g., ['RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=10'])
|
||||
reminders: Reminder overrides, e.g. {"useDefault": false, "overrides": [{"method": "popup", "minutes": 15}]}
|
||||
attendees: List of attendees, e.g. [{"email": "alice@co.com", "displayName": "Alice"}]. responseStatus defaults to 'needsAction'.
|
||||
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
|
||||
|
||||
Returns:
|
||||
The created event object with its ID
|
||||
"""
|
||||
data = load_data()
|
||||
|
||||
event_id = generate_event_id()
|
||||
now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
event: dict = {
|
||||
"id": event_id,
|
||||
"summary": summary,
|
||||
"start": dump_model(start, EventDateTime),
|
||||
"end": dump_model(end, EventDateTime),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
"eventType": EventType(eventType).value,
|
||||
}
|
||||
|
||||
if description:
|
||||
event["description"] = description
|
||||
if location:
|
||||
event["location"] = location
|
||||
if recurrence:
|
||||
event["recurrence"] = recurrence
|
||||
if reminders:
|
||||
event["reminders"] = dump_model(reminders, EventReminders)
|
||||
if attendees:
|
||||
event["attendees"] = [event_attendee_payload(attendee) for attendee in attendees]
|
||||
if creator is not None:
|
||||
event["creator"] = dump_model(creator, CalendarPerson)
|
||||
if organizer is not None:
|
||||
event["organizer"] = dump_model(organizer, CalendarPerson)
|
||||
if extendedProperties is not None:
|
||||
event["extendedProperties"] = dump_model(extendedProperties, ExtendedProperties)
|
||||
if source is not None:
|
||||
event["source"] = dump_model(source, EventSource)
|
||||
if transparency is not None:
|
||||
event["transparency"] = EventTransparency(transparency).value
|
||||
if outOfOfficeProperties is not None:
|
||||
event["outOfOfficeProperties"] = dump_model(outOfOfficeProperties, OutOfOfficeProperties)
|
||||
if focusTimeProperties is not None:
|
||||
event["focusTimeProperties"] = dump_model(focusTimeProperties, FocusTimeProperties)
|
||||
if workingLocationProperties is not None:
|
||||
event["workingLocationProperties"] = dump_model(workingLocationProperties, WorkingLocationProperties)
|
||||
if birthdayProperties is not None:
|
||||
event["birthdayProperties"] = dump_model(birthdayProperties, BirthdayProperties)
|
||||
|
||||
try:
|
||||
event = Event.model_validate(event).model_dump(exclude_none=True)
|
||||
except ValidationError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
if not set_calendar_event(data, calendar_id, event_id, event):
|
||||
return {"status": "error", "message": f"Calendar '{calendar_id}' not found"}
|
||||
|
||||
if error := save_data_or_error(data):
|
||||
return error
|
||||
|
||||
return {"status": "success", "event": event}
|
||||
|
||||
|
||||
def get_event(eventId: EventId, calendar_id: CalendarId = "primary") -> dict:
|
||||
"""
|
||||
Retrieves details of a specific event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to retrieve
|
||||
calendar_id: Calendar to look in (default 'primary')
|
||||
|
||||
Returns:
|
||||
The event object if found
|
||||
"""
|
||||
data = load_data()
|
||||
events = get_calendar_events(data, calendar_id)
|
||||
|
||||
if eventId not in events:
|
||||
return {"status": "error", "message": f"Event with ID '{eventId}' not found in calendar '{calendar_id}'"}
|
||||
|
||||
return {"status": "success", "event": events[eventId]}
|
||||
|
||||
|
||||
def update_event(
|
||||
eventId: EventId,
|
||||
summary: EventSummary | None = None,
|
||||
start: EventDateTime | None = None,
|
||||
end: EventDateTime | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
calendar_id: CalendarId = "primary",
|
||||
recurrence: list[RecurrenceRuleString] | None = None,
|
||||
reminders: EventReminders | None = None,
|
||||
attendees: list[EventAttendee] | None = None,
|
||||
creator: CalendarPerson | None = None,
|
||||
organizer: CalendarPerson | None = None,
|
||||
extendedProperties: ExtendedProperties | None = None,
|
||||
source: EventSource | None = None,
|
||||
transparency: EventTransparency | None = None,
|
||||
eventType: EventType | None = None,
|
||||
outOfOfficeProperties: OutOfOfficeProperties | None = None,
|
||||
focusTimeProperties: FocusTimeProperties | None = None,
|
||||
workingLocationProperties: WorkingLocationProperties | None = None,
|
||||
birthdayProperties: BirthdayProperties | None = None,
|
||||
clear_fields: list[EventClearField] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Updates an existing event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to update
|
||||
summary: New event title
|
||||
start: New start time object with either 'dateTime' (ISO format) for timed events,
|
||||
or 'date' (YYYY-MM-DD) for all-day events. Optionally include 'timeZone'.
|
||||
end: New end time object with either 'dateTime' or 'date' (same format as start).
|
||||
description: New event description
|
||||
location: New event location
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
recurrence: RRULE strings for recurring events (pass empty list to remove recurrence)
|
||||
reminders: Reminder overrides (pass {"useDefault": true} to reset to defaults)
|
||||
attendees: Replace attendee list (pass empty list to remove all attendees)
|
||||
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
|
||||
eventType: Event type to set. Switching types clears properties that only belong to the old type.
|
||||
clear_fields: Optional event fields to remove, e.g. ['source', 'transparency'].
|
||||
|
||||
Returns:
|
||||
The updated event object
|
||||
"""
|
||||
data = load_data()
|
||||
events = get_calendar_events(data, calendar_id)
|
||||
|
||||
if eventId not in events:
|
||||
return {"status": "error", "message": f"Event with ID '{eventId}' not found in calendar '{calendar_id}'"}
|
||||
|
||||
event = dict(events[eventId])
|
||||
|
||||
if clear_fields:
|
||||
fields_to_clear = {field.value for field in clear_fields}
|
||||
for field in fields_to_clear:
|
||||
event.pop(field, None)
|
||||
|
||||
if summary is not None:
|
||||
event["summary"] = summary
|
||||
if start is not None:
|
||||
event["start"] = dump_model(start, EventDateTime)
|
||||
if end is not None:
|
||||
event["end"] = dump_model(end, EventDateTime)
|
||||
if description is not None:
|
||||
event["description"] = description
|
||||
if location is not None:
|
||||
event["location"] = location
|
||||
if recurrence is not None:
|
||||
if recurrence:
|
||||
event["recurrence"] = recurrence
|
||||
else:
|
||||
event.pop("recurrence", None)
|
||||
if reminders is not None:
|
||||
event["reminders"] = dump_model(reminders, EventReminders)
|
||||
if attendees is not None:
|
||||
event["attendees"] = [event_attendee_payload(attendee) for attendee in attendees]
|
||||
if creator is not None:
|
||||
event["creator"] = dump_model(creator, CalendarPerson)
|
||||
if organizer is not None:
|
||||
event["organizer"] = dump_model(organizer, CalendarPerson)
|
||||
if extendedProperties is not None:
|
||||
event["extendedProperties"] = dump_model(extendedProperties, ExtendedProperties)
|
||||
if source is not None:
|
||||
event["source"] = dump_model(source, EventSource)
|
||||
if transparency is not None:
|
||||
event["transparency"] = EventTransparency(transparency).value
|
||||
if eventType is not None:
|
||||
event_type = EventType(eventType)
|
||||
event["eventType"] = event_type.value
|
||||
for property_event_type, property_field in EVENT_TYPE_PROPERTY_FIELDS.items():
|
||||
if property_event_type != event_type:
|
||||
event.pop(property_field, None)
|
||||
if outOfOfficeProperties is not None:
|
||||
event["outOfOfficeProperties"] = dump_model(outOfOfficeProperties, OutOfOfficeProperties)
|
||||
if focusTimeProperties is not None:
|
||||
event["focusTimeProperties"] = dump_model(focusTimeProperties, FocusTimeProperties)
|
||||
if workingLocationProperties is not None:
|
||||
event["workingLocationProperties"] = dump_model(workingLocationProperties, WorkingLocationProperties)
|
||||
if birthdayProperties is not None:
|
||||
event["birthdayProperties"] = dump_model(birthdayProperties, BirthdayProperties)
|
||||
|
||||
event["updated"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
try:
|
||||
event = Event.model_validate(event).model_dump(exclude_none=True)
|
||||
except ValidationError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
events[eventId] = event
|
||||
if error := save_data_or_error(data):
|
||||
return error
|
||||
|
||||
return {"status": "success", "event": event}
|
||||
|
||||
|
||||
def delete_event(eventId: EventId, calendar_id: CalendarId = "primary") -> dict:
|
||||
"""
|
||||
Deletes an event from the calendar.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event to delete
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
|
||||
Returns:
|
||||
Confirmation of deletion
|
||||
"""
|
||||
data = load_data()
|
||||
|
||||
deleted_event = delete_calendar_event(data, calendar_id, eventId)
|
||||
if deleted_event is None:
|
||||
return {"status": "error", "message": f"Event with ID '{eventId}' not found in calendar '{calendar_id}'"}
|
||||
|
||||
if error := save_data_or_error(data):
|
||||
return error
|
||||
|
||||
return {"status": "success", "message": f"Event '{deleted_event['summary']}' deleted"}
|
||||
|
||||
|
||||
def respond_to_event(
|
||||
eventId: EventId,
|
||||
email: EmailStr,
|
||||
response: AttendeeResponseStatus,
|
||||
calendar_id: CalendarId = "primary",
|
||||
) -> dict:
|
||||
"""
|
||||
Update an attendee's RSVP response on an event.
|
||||
|
||||
Args:
|
||||
eventId: ID of the event
|
||||
email: Email address of the attendee responding
|
||||
response: Response status — 'accepted', 'declined', 'tentative', or 'needsAction'
|
||||
calendar_id: Calendar containing the event (default 'primary')
|
||||
|
||||
Returns:
|
||||
The updated event with attendee responses
|
||||
"""
|
||||
data = load_data()
|
||||
events = get_calendar_events(data, calendar_id)
|
||||
|
||||
if eventId not in events:
|
||||
return {"status": "error", "message": f"Event with ID '{eventId}' not found in calendar '{calendar_id}'"}
|
||||
|
||||
event = events[eventId]
|
||||
attendees = event.get("attendees", [])
|
||||
|
||||
found = False
|
||||
for attendee in attendees:
|
||||
if attendee.get("email", "").lower() == email.lower():
|
||||
attendee["responseStatus"] = response.value
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
return {"status": "error", "message": f"Attendee '{email}' not found on this event"}
|
||||
|
||||
event["updated"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
if error := save_data_or_error(data):
|
||||
return error
|
||||
|
||||
return {"status": "success", "event": event}
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Search, listing, and recurrence helpers for the Google Calendar mock."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import cast
|
||||
|
||||
from pydantic import EmailStr
|
||||
|
||||
from google_calendar.models import (
|
||||
DEFAULT_RECURRING_SEARCH_DAYS,
|
||||
AttendeeResponseStatus,
|
||||
CalendarId,
|
||||
ListEventsMaxResults,
|
||||
Rfc3339OffsetDateTimeString,
|
||||
SearchEventsMaxResults,
|
||||
SearchEventsOrderBy,
|
||||
SearchEventsResponse,
|
||||
parse_rfc3339_datetime,
|
||||
)
|
||||
from google_calendar.state import get_calendar_events, load_data
|
||||
|
||||
_QUERY_TOKEN_RE = re.compile(r'"([^"]+)"|\S+')
|
||||
|
||||
_DAY_MAP = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
||||
|
||||
|
||||
def _parse_rrule(rrule_str: str) -> dict:
|
||||
"""Parse an RRULE string into a dict of parameters."""
|
||||
params: dict = {}
|
||||
rule = rrule_str.replace("RRULE:", "")
|
||||
for part in rule.split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, value = part.split("=", 1)
|
||||
params[key] = value
|
||||
return params
|
||||
|
||||
|
||||
def _expand_recurring_event(
|
||||
event: dict,
|
||||
range_start: datetime,
|
||||
range_end: datetime,
|
||||
) -> list[dict]:
|
||||
"""Expand a recurring event into instances within a time range.
|
||||
|
||||
Supports: FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY.
|
||||
Returns a list of synthetic event dicts (copies with adjusted start/end).
|
||||
"""
|
||||
recurrence = event.get("recurrence", [])
|
||||
if not recurrence:
|
||||
return []
|
||||
|
||||
# Find the RRULE
|
||||
rrule_str = None
|
||||
for r in recurrence:
|
||||
if r.startswith("RRULE:"):
|
||||
rrule_str = r
|
||||
break
|
||||
if not rrule_str:
|
||||
return []
|
||||
|
||||
params = _parse_rrule(rrule_str)
|
||||
freq = params.get("FREQ", "").upper()
|
||||
interval = int(params.get("INTERVAL", "1"))
|
||||
count = int(params.get("COUNT", "0")) or None
|
||||
until_str = params.get("UNTIL")
|
||||
byday = params.get("BYDAY", "").split(",") if params.get("BYDAY") else None
|
||||
|
||||
# Parse event start/end to get duration
|
||||
event_start = _parse_event_start(event)
|
||||
event_end = _parse_event_end(event)
|
||||
if event_start is None or event_end is None:
|
||||
return []
|
||||
duration = event_end - event_start
|
||||
|
||||
# Parse UNTIL. Normalize to match event_start's tz-awareness so comparisons
|
||||
# don't raise when RRULE UNTIL is specified without an offset.
|
||||
until_dt = None
|
||||
if until_str:
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
until_dt = datetime.fromisoformat(until_str)
|
||||
except ValueError:
|
||||
with contextlib.suppress(ValueError):
|
||||
until_dt = datetime.strptime(until_str, "%Y%m%dT%H%M%SZ").replace(tzinfo=event_start.tzinfo)
|
||||
if until_dt is not None and until_dt.tzinfo is None and event_start.tzinfo is not None:
|
||||
until_dt = until_dt.replace(tzinfo=event_start.tzinfo)
|
||||
|
||||
# Normalize timezone for comparison
|
||||
if event_start.tzinfo is not None and range_start.tzinfo is None:
|
||||
range_start = range_start.replace(tzinfo=event_start.tzinfo)
|
||||
range_end = range_end.replace(tzinfo=event_start.tzinfo)
|
||||
elif event_start.tzinfo is None and range_start.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=range_start.tzinfo)
|
||||
|
||||
# Generate instances
|
||||
instances = []
|
||||
current = event_start
|
||||
instance_count = 0
|
||||
max_iterations = 1000 # safety limit
|
||||
|
||||
for _ in range(max_iterations):
|
||||
if until_dt and current > until_dt:
|
||||
break
|
||||
if count and instance_count >= count:
|
||||
break
|
||||
if current >= range_end:
|
||||
break
|
||||
|
||||
current_end = current + duration
|
||||
|
||||
# Check if this instance matches BYDAY (for WEEKLY)
|
||||
day_match = True
|
||||
if byday and freq == "WEEKLY":
|
||||
day_abbr = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"][current.weekday()]
|
||||
day_match = day_abbr in byday
|
||||
|
||||
if day_match:
|
||||
instance_count += 1
|
||||
# Only include if it overlaps the query range
|
||||
if current_end > range_start and current < range_end:
|
||||
instance = dict(event)
|
||||
instance["id"] = f"{event['id']}_{instance_count}"
|
||||
instance["recurringEventId"] = event["id"]
|
||||
if "dateTime" in event.get("start", {}):
|
||||
instance["start"] = {"dateTime": current.isoformat()}
|
||||
instance["end"] = {"dateTime": current_end.isoformat()}
|
||||
if time_zone := event.get("start", {}).get("timeZone"):
|
||||
instance["start"]["timeZone"] = time_zone
|
||||
if time_zone := event.get("end", {}).get("timeZone"):
|
||||
instance["end"]["timeZone"] = time_zone
|
||||
else:
|
||||
instance["start"] = {"date": current.strftime("%Y-%m-%d")}
|
||||
instance["end"] = {"date": current_end.strftime("%Y-%m-%d")}
|
||||
# Don't include recurrence on instances
|
||||
instance.pop("recurrence", None)
|
||||
instances.append(instance)
|
||||
|
||||
# Advance to next occurrence
|
||||
if freq == "DAILY":
|
||||
current += timedelta(days=interval)
|
||||
elif freq == "WEEKLY":
|
||||
if byday:
|
||||
# Advance one day at a time for BYDAY matching
|
||||
current += timedelta(days=1)
|
||||
else:
|
||||
current += timedelta(weeks=interval)
|
||||
elif freq == "MONTHLY":
|
||||
month = current.month + interval
|
||||
year = current.year + (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
try:
|
||||
current = current.replace(year=year, month=month)
|
||||
except ValueError:
|
||||
break # e.g., Jan 31 → Feb 31 doesn't exist
|
||||
else:
|
||||
break # Unsupported frequency
|
||||
|
||||
return instances
|
||||
|
||||
|
||||
def _parse_dt(dt_obj: dict) -> datetime | None:
|
||||
"""Parse a dateTime or date field from an event start/end object.
|
||||
|
||||
Always returns a timezone-aware datetime so comparisons against offset-aware
|
||||
query bounds don't raise. Offset-less dateTime strings use the event's
|
||||
timeZone when present. All-day events (date-only) are treated as UTC.
|
||||
Tolerant of data where dateTime is explicitly null (common in all-day
|
||||
events that also carry a date field).
|
||||
"""
|
||||
|
||||
dt = dt_obj.get("dateTime")
|
||||
if isinstance(dt, str):
|
||||
time_zone = dt_obj.get("timeZone")
|
||||
if not isinstance(time_zone, str):
|
||||
time_zone = None
|
||||
try:
|
||||
parsed = parse_rfc3339_datetime(dt, time_zone)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
d = dt_obj.get("date")
|
||||
if isinstance(d, str):
|
||||
try:
|
||||
return datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_event_start(event: dict) -> datetime | None:
|
||||
"""Parse event start time from either dateTime or date field."""
|
||||
return _parse_dt(event.get("start", {}))
|
||||
|
||||
|
||||
def _parse_event_end(event: dict) -> datetime | None:
|
||||
"""Parse event end time from either dateTime or date field."""
|
||||
end = _parse_dt(event.get("end", {}))
|
||||
start_obj = event.get("start", {})
|
||||
end_obj = event.get("end", {})
|
||||
if (
|
||||
end is not None
|
||||
and isinstance(start_obj, dict)
|
||||
and isinstance(end_obj, dict)
|
||||
and "dateTime" not in start_obj
|
||||
and "dateTime" not in end_obj
|
||||
and isinstance(start_obj.get("date"), str)
|
||||
and isinstance(end_obj.get("date"), str)
|
||||
):
|
||||
start = _parse_dt(start_obj)
|
||||
if start is not None and end <= start:
|
||||
return start + timedelta(days=1)
|
||||
return end
|
||||
|
||||
|
||||
def _get_event_sort_key(event: dict) -> str:
|
||||
"""Get a sortable string key for event start time.
|
||||
|
||||
dateTime takes precedence, then date. Tolerates events where dateTime is
|
||||
explicitly null by falling through to date.
|
||||
"""
|
||||
start = event.get("start", {})
|
||||
dt = start.get("dateTime")
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
d = start.get("date")
|
||||
if isinstance(d, str):
|
||||
return d
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_search_tokens(query: str) -> list[str]:
|
||||
"""Parse a query into lower-case word tokens and quoted phrase tokens."""
|
||||
tokens = []
|
||||
for match in _QUERY_TOKEN_RE.finditer(query):
|
||||
token = (match.group(1) or match.group(0)).strip().lower()
|
||||
if token:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _search_query_warnings(query: str) -> list[str]:
|
||||
"""Warn when users put unsupported operator syntax in the free-text query."""
|
||||
warnings: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in re.finditer(r"\b([A-Za-z_]+):\S+", query):
|
||||
operator = match.group(1)
|
||||
if operator.lower() in {"http", "https", "mailto"}:
|
||||
continue
|
||||
message = (
|
||||
f"Unsupported Calendar query operator '{operator}:'; use search_events parameters "
|
||||
"such as timeMin, timeMax, attendee_email, response_status, organizer_email, or creator_email."
|
||||
)
|
||||
if message not in seen:
|
||||
warnings.append(message)
|
||||
seen.add(message)
|
||||
return warnings
|
||||
|
||||
|
||||
def _event_search_haystack(event: dict, calendar_id: str, calendar_summary: str | None = None) -> str:
|
||||
"""Build searchable event text, using newlines so phrases don't span fields."""
|
||||
parts = [
|
||||
event.get("summary", ""),
|
||||
event.get("description", ""),
|
||||
event.get("location", ""),
|
||||
calendar_id,
|
||||
calendar_summary or "",
|
||||
]
|
||||
for person_field in ("organizer", "creator"):
|
||||
person = event.get(person_field)
|
||||
if isinstance(person, dict):
|
||||
parts.extend([person.get("email", ""), person.get("displayName", "")])
|
||||
elif person:
|
||||
parts.append(person)
|
||||
for attendee in event.get("attendees", []) or []:
|
||||
parts.extend(
|
||||
[
|
||||
attendee.get("email", ""),
|
||||
attendee.get("displayName", ""),
|
||||
attendee.get("comment", ""),
|
||||
]
|
||||
)
|
||||
return "\n".join(str(part) for part in parts if part).lower()
|
||||
|
||||
|
||||
def _extract_person_emails(value: object) -> list[str]:
|
||||
if isinstance(value, dict):
|
||||
person = cast("dict[str, object]", value)
|
||||
email = person.get("email")
|
||||
return [str(email).lower()] if email else []
|
||||
if isinstance(value, str):
|
||||
return [value.lower()]
|
||||
return []
|
||||
|
||||
|
||||
def _event_matches_attendee(event: dict, attendee_email: str | None) -> bool:
|
||||
if not attendee_email:
|
||||
return True
|
||||
needle = attendee_email.lower()
|
||||
for attendee in event.get("attendees", []) or []:
|
||||
email = str(attendee.get("email", "")).lower()
|
||||
display_name = str(attendee.get("displayName", "")).lower()
|
||||
if needle in email or needle in display_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _event_matches_response_status(
|
||||
event: dict, response_status: AttendeeResponseStatus | None, attendee_email: str | None
|
||||
) -> bool:
|
||||
if response_status is None:
|
||||
return True
|
||||
needle = response_status.value.lower()
|
||||
attendee_needle = attendee_email.lower() if attendee_email else None
|
||||
for attendee in event.get("attendees", []) or []:
|
||||
if attendee_needle:
|
||||
email = str(attendee.get("email", "")).lower()
|
||||
display_name = str(attendee.get("displayName", "")).lower()
|
||||
if attendee_needle not in email and attendee_needle not in display_name:
|
||||
continue
|
||||
if str(attendee.get("responseStatus", "")).lower() == needle:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _event_matches_creator(event: dict, creator_email: str | None) -> bool:
|
||||
if not creator_email:
|
||||
return True
|
||||
needle = creator_email.lower()
|
||||
return any(needle in email for email in _extract_person_emails(event.get("creator")))
|
||||
|
||||
|
||||
def _event_matches_organizer(event: dict, organizer_email: str | None) -> bool:
|
||||
if not organizer_email:
|
||||
return True
|
||||
needle = organizer_email.lower()
|
||||
if any(needle in email for email in _extract_person_emails(event.get("organizer"))):
|
||||
return True
|
||||
for attendee in event.get("attendees", []) or []:
|
||||
if attendee.get("organizer") and needle in str(attendee.get("email", "")).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_range_bound(value: str | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _event_overlaps_range(event: dict, time_min: datetime | None, time_max: datetime | None) -> bool:
|
||||
event_start = _parse_event_start(event)
|
||||
event_end = _parse_event_end(event)
|
||||
if event_start is None or event_end is None:
|
||||
return False
|
||||
|
||||
if time_min is not None:
|
||||
if event_end.tzinfo is None and time_min.tzinfo is not None:
|
||||
event_end = event_end.replace(tzinfo=time_min.tzinfo)
|
||||
elif event_end.tzinfo is not None and time_min.tzinfo is None:
|
||||
event_end = event_end.replace(tzinfo=None)
|
||||
if event_end <= time_min:
|
||||
return False
|
||||
|
||||
if time_max is not None:
|
||||
if event_start.tzinfo is None and time_max.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=time_max.tzinfo)
|
||||
elif event_start.tzinfo is not None and time_max.tzinfo is None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
if event_start >= time_max:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _recurring_search_range(
|
||||
event: dict, time_min: datetime | None, time_max: datetime | None
|
||||
) -> tuple[datetime, datetime] | None:
|
||||
"""Return bounded expansion range for recurring search results."""
|
||||
event_start = _parse_event_start(event)
|
||||
if event_start is None:
|
||||
return None
|
||||
|
||||
range_start = time_min or event_start
|
||||
range_end = time_max or event_start + timedelta(days=DEFAULT_RECURRING_SEARCH_DAYS)
|
||||
if range_end <= range_start:
|
||||
return None
|
||||
return range_start, range_end
|
||||
|
||||
|
||||
def _iter_calendar_events(data: dict, calendar_id: str | None = None) -> list[tuple[str, str, dict]]:
|
||||
"""Return (calendar_id, calendar_summary, event) tuples for requested calendars."""
|
||||
if calendar_id:
|
||||
calendar_summary = (
|
||||
"Primary"
|
||||
if calendar_id == "primary"
|
||||
else data.get("calendars", {}).get(calendar_id, {}).get("summary", calendar_id)
|
||||
)
|
||||
return [(calendar_id, calendar_summary, event) for event in get_calendar_events(data, calendar_id).values()]
|
||||
|
||||
events = [("primary", "Primary", event) for event in data.get("events", {}).values()]
|
||||
for cid, cal_data in data.get("calendars", {}).items():
|
||||
calendar_summary = cal_data.get("summary", cid)
|
||||
events.extend((cid, calendar_summary, event) for event in cal_data.get("events", {}).values())
|
||||
return events
|
||||
|
||||
|
||||
def list_events(
|
||||
timeMin: Rfc3339OffsetDateTimeString,
|
||||
timeMax: Rfc3339OffsetDateTimeString,
|
||||
maxResults: ListEventsMaxResults | None = None,
|
||||
orderBy: SearchEventsOrderBy | None = None,
|
||||
calendar_id: CalendarId | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Lists events within a specified time range.
|
||||
|
||||
Args:
|
||||
timeMin: Lower bound (exclusive) for an event's end time (ISO format).
|
||||
Events that end after this time are included.
|
||||
timeMax: Upper bound (exclusive) for an event's start time (ISO format).
|
||||
Events that start before this time are included.
|
||||
maxResults: Maximum number of events to return
|
||||
orderBy: Sort order ('startTime' or 'updated')
|
||||
calendar_id: Calendar to list events from. If omitted, lists events from all calendars.
|
||||
|
||||
Returns:
|
||||
List of events that overlap the given time range
|
||||
"""
|
||||
data = load_data()
|
||||
|
||||
# Parse time boundaries
|
||||
try:
|
||||
time_min = datetime.fromisoformat(timeMin)
|
||||
time_max = datetime.fromisoformat(timeMax)
|
||||
except ValueError as e:
|
||||
return {"status": "error", "message": f"Invalid time format: {e}"}
|
||||
|
||||
# Ensure time_min and time_max have consistent timezone awareness
|
||||
if time_min.tzinfo is None and time_max.tzinfo is not None:
|
||||
time_min = time_min.replace(tzinfo=time_max.tzinfo)
|
||||
elif time_min.tzinfo is not None and time_max.tzinfo is None:
|
||||
time_max = time_max.replace(tzinfo=time_min.tzinfo)
|
||||
|
||||
# Collect events from requested calendar(s). Event IDs are scoped to a
|
||||
# calendar, so all-calendar queries must not merge by event ID.
|
||||
all_events: list[dict] = []
|
||||
if calendar_id:
|
||||
all_events = list(get_calendar_events(data, calendar_id).values())
|
||||
else:
|
||||
all_events.extend(data.get("events", {}).values())
|
||||
for cal_data in data.get("calendars", {}).values():
|
||||
all_events.extend(cal_data.get("events", {}).values())
|
||||
|
||||
# Expand recurring events and filter by time range
|
||||
filtered_events = []
|
||||
skipped_ids = []
|
||||
for event in all_events:
|
||||
# Expand recurring events into instances
|
||||
if event.get("recurrence"):
|
||||
instances = _expand_recurring_event(event, time_min, time_max)
|
||||
filtered_events.extend(instances)
|
||||
continue
|
||||
|
||||
event_start = _parse_event_start(event)
|
||||
event_end = _parse_event_end(event)
|
||||
if event_start is None or event_end is None:
|
||||
skipped_ids.append(event.get("id", "unknown"))
|
||||
continue
|
||||
# Normalize timezone awareness for comparison
|
||||
if event_start.tzinfo is None and time_min.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=time_min.tzinfo)
|
||||
elif event_start.tzinfo is not None and time_min.tzinfo is None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
if event_end.tzinfo is None and time_min.tzinfo is not None:
|
||||
event_end = event_end.replace(tzinfo=time_min.tzinfo)
|
||||
elif event_end.tzinfo is not None and time_min.tzinfo is None:
|
||||
event_end = event_end.replace(tzinfo=None)
|
||||
# Overlap: event ends after timeMin AND event starts before timeMax
|
||||
if event_end > time_min and event_start < time_max:
|
||||
filtered_events.append(event)
|
||||
|
||||
# Sort events
|
||||
if orderBy == SearchEventsOrderBy.START_TIME:
|
||||
filtered_events.sort(key=_get_event_sort_key)
|
||||
elif orderBy == SearchEventsOrderBy.UPDATED:
|
||||
filtered_events.sort(key=lambda e: e.get("updated", ""), reverse=True)
|
||||
|
||||
# Limit results
|
||||
if maxResults is not None:
|
||||
filtered_events = filtered_events[:maxResults]
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"events": filtered_events,
|
||||
"count": len(filtered_events),
|
||||
}
|
||||
if skipped_ids:
|
||||
result["skipped_unparseable"] = skipped_ids
|
||||
return result
|
||||
|
||||
|
||||
def search_events(
|
||||
query: str = "",
|
||||
timeMin: Rfc3339OffsetDateTimeString | None = None,
|
||||
timeMax: Rfc3339OffsetDateTimeString | None = None,
|
||||
calendar_id: CalendarId | None = None,
|
||||
attendee_email: EmailStr | None = None,
|
||||
response_status: AttendeeResponseStatus | None = None,
|
||||
organizer_email: EmailStr | None = None,
|
||||
creator_email: EmailStr | None = None,
|
||||
maxResults: SearchEventsMaxResults | None = None,
|
||||
orderBy: SearchEventsOrderBy | None = SearchEventsOrderBy.START_TIME,
|
||||
) -> SearchEventsResponse:
|
||||
"""
|
||||
Search calendar events by keyword, phrase, attendee, location, or calendar.
|
||||
|
||||
Args:
|
||||
query: Search query. Bare words are ANDed across summary, description,
|
||||
location, attendees, and calendar names. Quoted phrases require
|
||||
exact adjacency. May be empty when attendee_email is provided.
|
||||
timeMin: Optional lower bound (exclusive) for an event's end time.
|
||||
timeMax: Optional upper bound (exclusive) for an event's start time.
|
||||
calendar_id: Calendar to search. If omitted, searches all calendars.
|
||||
attendee_email: Optional attendee email or display-name substring.
|
||||
response_status: Optional attendee RSVP filter ('accepted', 'declined',
|
||||
'tentative', or 'needsAction'). When attendee_email is
|
||||
provided, applies to that attendee; otherwise matches
|
||||
any attendee with that status.
|
||||
organizer_email: Optional organizer email substring.
|
||||
creator_email: Optional creator email substring.
|
||||
maxResults: Maximum number of matching events to return. 0 returns no results.
|
||||
orderBy: Sort order ('startTime', 'updated', or None).
|
||||
|
||||
Returns:
|
||||
Matching events with calendar_id annotated on each result.
|
||||
"""
|
||||
tokens = _parse_search_tokens(query)
|
||||
warnings = _search_query_warnings(query)
|
||||
if not any([tokens, attendee_email, response_status, organizer_email, creator_email]):
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "query or at least one filter is required",
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
try:
|
||||
time_min = _normalize_range_bound(timeMin)
|
||||
time_max = _normalize_range_bound(timeMax)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Invalid time format: {e}",
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
data = load_data()
|
||||
matching_events = []
|
||||
skipped_ids = []
|
||||
|
||||
for cid, calendar_summary, event in _iter_calendar_events(data, calendar_id):
|
||||
candidates = [event]
|
||||
if event.get("recurrence"):
|
||||
recurring_range = _recurring_search_range(event, time_min, time_max)
|
||||
if recurring_range is None:
|
||||
skipped_ids.append(event.get("id", "unknown"))
|
||||
continue
|
||||
candidates = _expand_recurring_event(event, recurring_range[0], recurring_range[1])
|
||||
|
||||
for candidate in candidates:
|
||||
if (time_min is not None or time_max is not None) and not _event_overlaps_range(
|
||||
candidate, time_min, time_max
|
||||
):
|
||||
if _parse_event_start(candidate) is None or _parse_event_end(candidate) is None:
|
||||
skipped_ids.append(candidate.get("id", "unknown"))
|
||||
continue
|
||||
if not _event_matches_attendee(candidate, attendee_email):
|
||||
continue
|
||||
if not _event_matches_response_status(candidate, response_status, attendee_email):
|
||||
continue
|
||||
if not _event_matches_organizer(candidate, organizer_email):
|
||||
continue
|
||||
if not _event_matches_creator(candidate, creator_email):
|
||||
continue
|
||||
haystack = _event_search_haystack(candidate, cid, calendar_summary)
|
||||
if tokens and not all(token in haystack for token in tokens):
|
||||
continue
|
||||
|
||||
result_event = dict(candidate)
|
||||
result_event["calendar_id"] = cid
|
||||
result_event["calendar_summary"] = calendar_summary
|
||||
matching_events.append(result_event)
|
||||
|
||||
if orderBy == SearchEventsOrderBy.START_TIME:
|
||||
matching_events.sort(key=_get_event_sort_key)
|
||||
elif orderBy == SearchEventsOrderBy.UPDATED:
|
||||
matching_events.sort(key=lambda e: e.get("updated", ""), reverse=True)
|
||||
|
||||
if maxResults is not None:
|
||||
matching_events = matching_events[:maxResults]
|
||||
|
||||
result: SearchEventsResponse = {"status": "success", "events": matching_events, "count": len(matching_events)}
|
||||
result["warnings"] = warnings
|
||||
if skipped_ids:
|
||||
result["skipped_unparseable"] = skipped_ids
|
||||
result["warnings"].append(
|
||||
"Some recurring or malformed events were skipped because they could not be expanded or parsed."
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Utilities for setting up calendar data from JSON files.
|
||||
Used by task preprocess scripts to initialize calendar state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_calendar_data_path(agent_workspace: str | Path) -> Path:
|
||||
"""
|
||||
Get the calendar data file path for a given workspace.
|
||||
|
||||
Stores in external_services/ directory NEXT TO the workspace:
|
||||
- If workspace is at /workspace/dumps/workspace, stores at /workspace/dumps/external_services/
|
||||
- Outside the agent workspace so it can't be read directly via filesystem MCP
|
||||
- Inside the dumps mount so it persists to the host
|
||||
- Path is deterministic and can be computed by preprocess, MCP, and evaluation
|
||||
"""
|
||||
workspace_path = Path(agent_workspace)
|
||||
# Go up one level from workspace and create external_services directory
|
||||
external_services_dir = workspace_path.parent / "external_services"
|
||||
return external_services_dir / "calendar_data.json"
|
||||
|
||||
|
||||
def load_events_from_json(json_path: Path) -> dict:
|
||||
"""
|
||||
Load calendar data from a JSON file.
|
||||
|
||||
Expected format:
|
||||
{
|
||||
"events": {
|
||||
"event-id": {
|
||||
"id": "event-id",
|
||||
"summary": "Event Title",
|
||||
"start": { "dateTime": "2025-12-10T11:30:00-05:00", "timeZone": "America/New_York" },
|
||||
"end": { "dateTime": "2025-12-10T12:30:00-05:00", "timeZone": "America/New_York" },
|
||||
"description": "Optional description",
|
||||
"location": "Optional location"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
json_path: Path to the JSON file
|
||||
|
||||
Returns:
|
||||
Calendar data dictionary with events
|
||||
"""
|
||||
with open(json_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure we have the events key
|
||||
if "events" not in data:
|
||||
data = {"events": data if isinstance(data, dict) else {}}
|
||||
|
||||
# Add created/updated timestamps if missing
|
||||
now_utc = datetime.now(UTC).isoformat()
|
||||
for event_id, event in data["events"].items():
|
||||
if "id" not in event:
|
||||
event["id"] = event_id
|
||||
if "created" not in event:
|
||||
event["created"] = now_utc
|
||||
if "updated" not in event:
|
||||
event["updated"] = now_utc
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def create_calendar_data(
|
||||
agent_workspace: str,
|
||||
json_path: Path,
|
||||
verbose: bool = True,
|
||||
) -> Path:
|
||||
"""
|
||||
Create calendar data from a JSON file and store in external_services.
|
||||
|
||||
Uses a deterministic file path based on the workspace, so the MCP
|
||||
server can find it without needing a marker file in the agent workspace.
|
||||
|
||||
Args:
|
||||
agent_workspace: Path to the agent's workspace directory
|
||||
json_path: Path to the JSON file with events (google_calendar.json)
|
||||
verbose: Print progress messages
|
||||
|
||||
Returns:
|
||||
Path to the created data file
|
||||
"""
|
||||
if not json_path.exists():
|
||||
if verbose:
|
||||
print(f"⚠️ No {json_path.name} found at {json_path}, creating empty calendar")
|
||||
calendar_data = {"events": {}}
|
||||
else:
|
||||
calendar_data = load_events_from_json(json_path)
|
||||
if verbose:
|
||||
print(f"✅ Loaded {len(calendar_data['events'])} events from {json_path}")
|
||||
|
||||
# Deterministic path based on workspace (allows parallel runs)
|
||||
data_path = get_calendar_data_path(agent_workspace)
|
||||
|
||||
# Create parent directory if needed
|
||||
data_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write calendar data to external_services directory (outside agent workspace)
|
||||
with open(data_path, "w") as f:
|
||||
json.dump(calendar_data, f, indent=2)
|
||||
if verbose:
|
||||
print(f"✅ Created {data_path} with {len(calendar_data['events'])} events")
|
||||
|
||||
# Print summary
|
||||
if verbose:
|
||||
for event in calendar_data["events"].values():
|
||||
start_time = event.get("start", {}).get("dateTime", "unknown")
|
||||
print(f" - {event.get('summary', 'Untitled')}: {start_time}")
|
||||
|
||||
return data_path
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Calendar viewer — read-only calendar UI and API endpoints.
|
||||
|
||||
Serves:
|
||||
GET /api/events — event list (optional ?date=YYYY-MM-DD for week filter)
|
||||
GET /api/events/:id — single event detail
|
||||
GET /api/stats — calendar stats
|
||||
GET / — viewer HTML (single-page app)
|
||||
|
||||
All non-MCP routes require the X-Proxy-Token header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProxyTokenMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip auth for MCP endpoint
|
||||
if request.url.path.startswith("/mcp"):
|
||||
return await call_next(request)
|
||||
token = os.environ.get("MCP_PROXY_TOKEN", "")
|
||||
if token and request.headers.get("x-proxy-token") != token:
|
||||
return Response("Forbidden: invalid proxy token", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_events() -> dict[str, Any]:
|
||||
"""Load and return all calendar events keyed uniquely for viewer lookups."""
|
||||
from .state import get_default_account_id, load_data
|
||||
|
||||
data = load_data(account_id=get_default_account_id())
|
||||
events: dict[str, Any] = {}
|
||||
for event_id, event in data.get("events", {}).items():
|
||||
events[event_id] = {**event, "calendar_id": "primary", "lookup_id": event_id}
|
||||
|
||||
for calendar_id, calendar in data.get("calendars", {}).items():
|
||||
for event_id, event in calendar.get("events", {}).items():
|
||||
lookup_id = f"{calendar_id}:{event_id}"
|
||||
events[lookup_id] = {**event, "calendar_id": calendar_id, "lookup_id": lookup_id}
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _parse_event_dt(dt_obj: dict) -> datetime | None:
|
||||
"""Parse a dateTime or date field from an event start/end object. Always
|
||||
returns a timezone-aware datetime; all-day dates are treated as UTC."""
|
||||
if not dt_obj:
|
||||
return None
|
||||
|
||||
dt = dt_obj.get("dateTime")
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(dt)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
d = dt_obj.get("date")
|
||||
if isinstance(d, str):
|
||||
try:
|
||||
return datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _event_sort_key(event: dict) -> str:
|
||||
start = event.get("start", {})
|
||||
dt = start.get("dateTime")
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
d = start.get("date")
|
||||
if isinstance(d, str):
|
||||
return d
|
||||
return ""
|
||||
|
||||
|
||||
def _format_event(event: dict) -> dict[str, Any]:
|
||||
"""Normalize an event for the API response."""
|
||||
start = event.get("start", {})
|
||||
end = event.get("end", {})
|
||||
return {
|
||||
"id": event.get("id", ""),
|
||||
"lookup_id": event.get("lookup_id", event.get("id", "")),
|
||||
"summary": event.get("summary", "(No title)"),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"description": event.get("description"),
|
||||
"location": event.get("location"),
|
||||
"created": event.get("created"),
|
||||
"updated": event.get("updated"),
|
||||
"calendar_id": event.get("calendar_id", "primary"),
|
||||
"all_day": "date" in start and "dateTime" not in start,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def api_events(request: Request) -> JSONResponse:
|
||||
events_dict = _get_events()
|
||||
events = list(events_dict.values())
|
||||
|
||||
date_param = request.query_params.get("date")
|
||||
if date_param:
|
||||
# Filter to the week containing the given date
|
||||
try:
|
||||
anchor = datetime.strptime(date_param, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid date format, use YYYY-MM-DD"}, status_code=400)
|
||||
week_start = anchor - timedelta(days=anchor.weekday()) # Monday
|
||||
week_end = week_start + timedelta(days=7)
|
||||
|
||||
filtered = []
|
||||
for ev in events:
|
||||
start_dt = _parse_event_dt(ev.get("start", {}))
|
||||
if start_dt is None:
|
||||
continue
|
||||
# Strip timezone for naive comparison
|
||||
start_naive = start_dt.replace(tzinfo=None)
|
||||
if week_start <= start_naive < week_end:
|
||||
filtered.append(ev)
|
||||
events = filtered
|
||||
|
||||
events.sort(key=_event_sort_key)
|
||||
return JSONResponse({"events": [_format_event(e) for e in events]})
|
||||
|
||||
|
||||
async def api_event_detail(request: Request) -> JSONResponse:
|
||||
event_id = request.path_params["event_id"]
|
||||
events_dict = _get_events()
|
||||
event = events_dict.get(event_id)
|
||||
if event is None:
|
||||
return JSONResponse({"error": "Event not found"}, status_code=404)
|
||||
return JSONResponse({"event": _format_event(event)})
|
||||
|
||||
|
||||
async def api_stats(request: Request) -> JSONResponse:
|
||||
events_dict = _get_events()
|
||||
total = len(events_dict)
|
||||
now = datetime.now(UTC)
|
||||
upcoming = sum(
|
||||
1
|
||||
for ev in events_dict.values()
|
||||
if (_parse_event_dt(ev.get("start", {})) or datetime.min.replace(tzinfo=UTC)) >= now
|
||||
)
|
||||
return JSONResponse({"total_events": total, "upcoming_events": upcoming})
|
||||
|
||||
|
||||
async def viewer_html(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(VIEWER_HTML)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_calendar_viewer_app():
|
||||
routes = [
|
||||
Route("/", viewer_html),
|
||||
Route("/api/events", api_events),
|
||||
Route("/api/events/{event_id}", api_event_detail),
|
||||
Route("/api/stats", api_stats),
|
||||
]
|
||||
return Starlette(
|
||||
routes=routes,
|
||||
middleware=[Middleware(ProxyTokenMiddleware)],
|
||||
)
|
||||
|
||||
|
||||
def run_http_server(mcp_app, port: int) -> None:
|
||||
"""Run combined MCP + viewer HTTP server."""
|
||||
# Get the ASGI app from FastMCP
|
||||
fastmcp_asgi = mcp_app.http_app(
|
||||
transport="streamable-http",
|
||||
path="/mcp",
|
||||
)
|
||||
|
||||
viewer = create_calendar_viewer_app()
|
||||
|
||||
# Combined app: route /mcp to FastMCP, everything else to viewer
|
||||
async def combined_app(scope, receive, send):
|
||||
if scope["type"] == "lifespan":
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/mcp"):
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
else:
|
||||
await viewer(scope, receive, send)
|
||||
|
||||
uvicorn.run(
|
||||
combined_app,
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Viewer HTML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VIEWER_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Calendar</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Header / toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 16px; padding: 10px 20px; background: #fff; border-bottom: 1px solid #e2e8f0; flex-shrink: 0; }
|
||||
.toolbar h1 { font-size: 20px; font-weight: 600; color: #1e293b; margin-right: auto; }
|
||||
.nav-btn { background: none; border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; color: #374151; }
|
||||
.nav-btn:hover { background: #f1f5f9; }
|
||||
.today-btn { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.today-btn:hover { background: #1d4ed8; }
|
||||
.week-label { font-size: 14px; font-weight: 600; color: #374151; min-width: 200px; text-align: center; }
|
||||
.view-toggle { display: flex; border: 1px solid #d1d5db; border-radius: 6px; overflow: hidden; }
|
||||
.view-btn { background: none; border: none; padding: 6px 14px; font-size: 13px; cursor: pointer; color: #374151; }
|
||||
.view-btn.active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* Week grid */
|
||||
.week-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.week-header { display: grid; grid-template-columns: 56px repeat(7, 1fr); border-bottom: 1px solid #e2e8f0; background: #fff; flex-shrink: 0; padding-right: var(--calendar-scrollbar-width, 0px); }
|
||||
.week-header .time-col { }
|
||||
.day-header { padding: 8px 4px; text-align: center; border-left: 1px solid #e2e8f0; }
|
||||
.day-header .day-name { font-size: 11px; color: #64748b; text-transform: uppercase; font-weight: 600; }
|
||||
.day-header .day-num { font-size: 22px; font-weight: 400; color: #374151; line-height: 1.2; margin-top: 2px; }
|
||||
.day-header.today .day-num { background: #2563eb; color: #fff; border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; margin: 2px auto 0; }
|
||||
|
||||
.week-body { flex: 1; overflow-y: auto; position: relative; }
|
||||
.week-scroll { display: grid; grid-template-columns: 56px repeat(7, 1fr); min-height: 1440px; /* 60px per hour * 24h */ }
|
||||
.time-gutter { position: relative; }
|
||||
.time-label { position: absolute; right: 8px; font-size: 10px; color: #94a3b8; transform: translateY(-50%); }
|
||||
.day-col { border-left: 1px solid #e2e8f0; position: relative; }
|
||||
.hour-line { position: absolute; left: 0; right: 0; border-top: 1px solid #f1f5f9; }
|
||||
.hour-line.half { border-top-style: dotted; }
|
||||
|
||||
/* Events */
|
||||
.cal-event { position: absolute; left: 2px; right: 2px; border-radius: 4px; padding: 2px 6px; font-size: 11px; font-weight: 500; cursor: pointer; overflow: hidden; z-index: 1; border-left: 3px solid transparent; }
|
||||
.cal-event:hover { opacity: 0.85; }
|
||||
.cal-event .evt-time { font-size: 10px; opacity: 0.8; }
|
||||
.cal-event .evt-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.all-day-row { display: grid; grid-template-columns: 56px repeat(7, 1fr); background: #fff; border-bottom: 1px solid #e2e8f0; min-height: 28px; padding-right: var(--calendar-scrollbar-width, 0px); }
|
||||
.all-day-col { border-left: 1px solid #e2e8f0; padding: 2px; }
|
||||
.all-day-event { border-radius: 3px; padding: 1px 6px; font-size: 11px; font-weight: 500; cursor: pointer; margin-bottom: 1px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* Current time line */
|
||||
.now-line { position: absolute; left: 0; right: 0; z-index: 2; pointer-events: none; }
|
||||
.now-line::before { content: ''; display: block; height: 2px; background: #ef4444; }
|
||||
.now-dot { position: absolute; left: -5px; top: -4px; width: 10px; height: 10px; border-radius: 50%; background: #ef4444; }
|
||||
|
||||
/* List view */
|
||||
.list-view { flex: 1; overflow-y: auto; padding: 16px 24px; display: none; }
|
||||
.list-day-group { margin-bottom: 24px; }
|
||||
.list-day-label { font-size: 13px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; }
|
||||
.list-event { display: flex; gap: 16px; padding: 10px 12px; background: #fff; border-radius: 8px; margin-bottom: 6px; cursor: pointer; border: 1px solid #e2e8f0; }
|
||||
.list-event:hover { border-color: #93c5fd; background: #eff6ff; }
|
||||
.list-event .evt-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 3px; }
|
||||
.list-event .evt-info { flex: 1; min-width: 0; }
|
||||
.list-event .evt-title { font-size: 14px; font-weight: 500; color: #1e293b; }
|
||||
.list-event .evt-meta { font-size: 12px; color: #64748b; margin-top: 2px; }
|
||||
.list-empty { text-align: center; padding: 60px; color: #94a3b8; font-size: 14px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100; display: none; align-items: center; justify-content: center; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: #fff; border-radius: 12px; width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,0.15); overflow: hidden; }
|
||||
.modal-header { padding: 20px 20px 16px; display: flex; align-items: flex-start; gap: 12px; }
|
||||
.modal-color { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; margin-top: 3px; }
|
||||
.modal-title { font-size: 18px; font-weight: 600; flex: 1; }
|
||||
.modal-close { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; padding: 0 4px; }
|
||||
.modal-body { padding: 0 20px 20px; }
|
||||
.modal-row { display: flex; gap: 10px; font-size: 13px; margin-bottom: 10px; align-items: flex-start; }
|
||||
.modal-icon { font-size: 15px; flex-shrink: 0; width: 20px; }
|
||||
.modal-text { color: #374151; flex: 1; }
|
||||
.modal-desc { color: #475569; line-height: 1.6; white-space: pre-wrap; margin-top: 4px; }
|
||||
|
||||
.no-events-msg { display: flex; align-items: center; justify-content: center; height: 200px; color: #94a3b8; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<h1>Calendar</h1>
|
||||
<button class="nav-btn" onclick="prevWeek()">‹</button>
|
||||
<div class="week-label" id="week-label"></div>
|
||||
<button class="nav-btn" onclick="nextWeek()">›</button>
|
||||
<button class="nav-btn today-btn" onclick="goToday()">Today</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btn-week" onclick="setView('week')">Week</button>
|
||||
<button class="view-btn" id="btn-list" onclick="setView('list')">List</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="week-view" id="week-view">
|
||||
<div class="all-day-row" id="all-day-row">
|
||||
<div style="font-size:10px;color:#94a3b8;padding:8px 4px;text-align:right;align-self:center">all-day</div>
|
||||
</div>
|
||||
<div class="week-header" id="week-header"></div>
|
||||
<div class="week-body">
|
||||
<div class="week-scroll" id="week-scroll"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-view" id="list-view">
|
||||
<div id="list-content"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
|
||||
<div class="modal" id="modal"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const HOURS = 24;
|
||||
const PX_PER_HOUR = 60;
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const COLORS = [
|
||||
{ bg: '#dbeafe', fg: '#1d4ed8', border: '#3b82f6' },
|
||||
{ bg: '#dcfce7', fg: '#166534', border: '#22c55e' },
|
||||
{ bg: '#fef3c7', fg: '#92400e', border: '#f59e0b' },
|
||||
{ bg: '#fce7f3', fg: '#9d174d', border: '#ec4899' },
|
||||
{ bg: '#ede9fe', fg: '#5b21b6', border: '#8b5cf6' },
|
||||
{ bg: '#ffedd5', fg: '#9a3412', border: '#f97316' },
|
||||
{ bg: '#cffafe', fg: '#164e63', border: '#06b6d4' },
|
||||
{ bg: '#f0fdf4', fg: '#14532d', border: '#16a34a' },
|
||||
];
|
||||
|
||||
let currentWeekStart = getWeekStart(new Date());
|
||||
let allEvents = [];
|
||||
let colorMap = {};
|
||||
let colorIdx = 0;
|
||||
let currentView = 'week';
|
||||
|
||||
function syncScrollbarGutter() {
|
||||
const body = document.querySelector('.week-body');
|
||||
if (!body) return;
|
||||
const width = Math.max(body.offsetWidth - body.clientWidth, 0);
|
||||
document.documentElement.style.setProperty('--calendar-scrollbar-width', width + 'px');
|
||||
}
|
||||
|
||||
function getWeekStart(date) {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay(); // 0=Sun
|
||||
// Use Monday as week start
|
||||
const diff = (day === 0) ? -6 : 1 - day;
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getColor(eventId) {
|
||||
if (!colorMap[eventId]) {
|
||||
colorMap[eventId] = COLORS[colorIdx % COLORS.length];
|
||||
colorIdx++;
|
||||
}
|
||||
return colorMap[eventId];
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function parseEventDt(dtObj) {
|
||||
if (!dtObj) return null;
|
||||
if (dtObj.dateTime) {
|
||||
try { return new Date(dtObj.dateTime); } catch { return null; }
|
||||
}
|
||||
if (dtObj.date) {
|
||||
// Parse as local date to avoid timezone shift
|
||||
const [y, m, d] = dtObj.date.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
if (!date) return '';
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function formatDateLong(date) {
|
||||
return DAYS[date.getDay()] + ', ' + MONTHS[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
const base = window.location.pathname.replace(/\\/$/, '');
|
||||
|
||||
async function fetchEvents() {
|
||||
const dateStr = currentWeekStart.toISOString().slice(0, 10);
|
||||
const r = await fetch(base + '/api/events?date=' + dateStr);
|
||||
const data = await r.json();
|
||||
allEvents = data.events || [];
|
||||
}
|
||||
|
||||
function getWeekDays() {
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(currentWeekStart);
|
||||
d.setDate(d.getDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function updateWeekLabel() {
|
||||
const days = getWeekDays();
|
||||
const start = days[0];
|
||||
const end = days[6];
|
||||
let label;
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
label = MONTHS[start.getMonth()] + ' ' + start.getDate() + ' – ' + end.getDate() + ', ' + start.getFullYear();
|
||||
} else {
|
||||
label = MONTHS[start.getMonth()] + ' ' + start.getDate() + ' – ' + MONTHS[end.getMonth()] + ' ' + end.getDate() + ', ' + end.getFullYear();
|
||||
}
|
||||
document.getElementById('week-label').textContent = label;
|
||||
}
|
||||
|
||||
function renderWeekHeader() {
|
||||
const days = getWeekDays();
|
||||
const today = new Date();
|
||||
const header = document.getElementById('week-header');
|
||||
let html = '<div class="time-col"></div>';
|
||||
days.forEach(d => {
|
||||
const isToday = isSameDay(d, today);
|
||||
html += '<div class="day-header' + (isToday ? ' today' : '') + '">' +
|
||||
'<div class="day-name">' + DAYS[d.getDay()] + '</div>' +
|
||||
'<div class="day-num">' + d.getDate() + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
header.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderWeekGrid() {
|
||||
const days = getWeekDays();
|
||||
const scroll = document.getElementById('week-scroll');
|
||||
|
||||
// Build time gutter
|
||||
let gutterHtml = '<div class="time-gutter">';
|
||||
for (let h = 0; h < HOURS; h++) {
|
||||
const top = h * PX_PER_HOUR;
|
||||
const label = h === 0 ? '' : (h < 12 ? h + ' AM' : h === 12 ? '12 PM' : (h - 12) + ' PM');
|
||||
gutterHtml += '<div class="time-label" style="top:' + top + 'px">' + label + '</div>';
|
||||
// hour line marker
|
||||
gutterHtml += '<div class="hour-line" style="top:' + top + 'px;left:0;width:56px"></div>';
|
||||
}
|
||||
gutterHtml += '</div>';
|
||||
|
||||
// Build day columns
|
||||
let daysHtml = '';
|
||||
days.forEach((day, idx) => {
|
||||
daysHtml += '<div class="day-col" id="day-col-' + idx + '">';
|
||||
// Hour lines
|
||||
for (let h = 0; h < HOURS; h++) {
|
||||
daysHtml += '<div class="hour-line" style="top:' + (h * PX_PER_HOUR) + 'px"></div>';
|
||||
daysHtml += '<div class="hour-line half" style="top:' + (h * PX_PER_HOUR + 30) + 'px"></div>';
|
||||
}
|
||||
// Events for this day
|
||||
const dayEvents = allEvents.filter(ev => {
|
||||
if (ev.all_day) return false;
|
||||
const start = parseEventDt(ev.start);
|
||||
return start && isSameDay(start, day);
|
||||
});
|
||||
dayEvents.forEach(ev => {
|
||||
const start = parseEventDt(ev.start);
|
||||
const end = parseEventDt(ev.end);
|
||||
if (!start) return;
|
||||
const startMins = start.getHours() * 60 + start.getMinutes();
|
||||
const endMins = end ? (end.getHours() * 60 + end.getMinutes()) : startMins + 30;
|
||||
const top = (startMins / 60) * PX_PER_HOUR;
|
||||
const height = Math.max(((endMins - startMins) / 60) * PX_PER_HOUR, 20);
|
||||
const lookupId = ev.lookup_id || ev.id;
|
||||
const color = getColor(lookupId);
|
||||
daysHtml += '<div class="cal-event" style="top:' + top + 'px;height:' + height + 'px;background:' + color.bg + ';color:' + color.fg + ';border-left-color:' + color.border + '" onclick="showEvent(\\'' + esc(lookupId) + '\\')">' +
|
||||
'<div class="evt-time">' + formatTime(start) + '</div>' +
|
||||
'<div class="evt-title">' + esc(ev.summary) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
daysHtml += '</div>';
|
||||
});
|
||||
|
||||
scroll.innerHTML = gutterHtml + daysHtml;
|
||||
|
||||
// All-day events
|
||||
renderAllDay(days);
|
||||
|
||||
// Current time indicator
|
||||
renderNowLine(days);
|
||||
}
|
||||
|
||||
function renderAllDay(days) {
|
||||
const allDayRow = document.getElementById('all-day-row');
|
||||
// reset to first placeholder
|
||||
allDayRow.innerHTML = '<div style="font-size:10px;color:#94a3b8;padding:8px 4px;text-align:right;align-self:center">all-day</div>';
|
||||
days.forEach((day, idx) => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'all-day-col';
|
||||
const dayEvents = allEvents.filter(ev => {
|
||||
if (!ev.all_day) return false;
|
||||
const start = parseEventDt(ev.start);
|
||||
return start && isSameDay(start, day);
|
||||
});
|
||||
dayEvents.forEach(ev => {
|
||||
const lookupId = ev.lookup_id || ev.id;
|
||||
const color = getColor(lookupId);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'all-day-event';
|
||||
el.style.cssText = 'background:' + color.bg + ';color:' + color.fg;
|
||||
el.textContent = ev.summary;
|
||||
el.onclick = () => showEvent(lookupId);
|
||||
col.appendChild(el);
|
||||
});
|
||||
allDayRow.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNowLine(days) {
|
||||
const now = new Date();
|
||||
days.forEach((day, idx) => {
|
||||
if (!isSameDay(day, now)) return;
|
||||
const mins = now.getHours() * 60 + now.getMinutes();
|
||||
const top = (mins / 60) * PX_PER_HOUR;
|
||||
const col = document.getElementById('day-col-' + idx);
|
||||
if (!col) return;
|
||||
const line = document.createElement('div');
|
||||
line.className = 'now-line';
|
||||
line.style.top = top + 'px';
|
||||
line.innerHTML = '<span class="now-dot"></span>';
|
||||
col.appendChild(line);
|
||||
});
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
const content = document.getElementById('list-content');
|
||||
if (!allEvents.length) {
|
||||
content.innerHTML = '<div class="list-empty">No events this week</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by day
|
||||
const groups = {};
|
||||
allEvents.forEach(ev => {
|
||||
const start = parseEventDt(ev.start);
|
||||
if (!start) return;
|
||||
const key = start.toDateString();
|
||||
if (!groups[key]) groups[key] = { date: start, events: [] };
|
||||
groups[key].events.push(ev);
|
||||
});
|
||||
|
||||
const sortedKeys = Object.keys(groups).sort((a, b) => new Date(a) - new Date(b));
|
||||
let html = '';
|
||||
sortedKeys.forEach(key => {
|
||||
const g = groups[key];
|
||||
html += '<div class="list-day-group">';
|
||||
html += '<div class="list-day-label">' + formatDateLong(g.date) + '</div>';
|
||||
g.events.forEach(ev => {
|
||||
const start = parseEventDt(ev.start);
|
||||
const end = parseEventDt(ev.end);
|
||||
const lookupId = ev.lookup_id || ev.id;
|
||||
const color = getColor(lookupId);
|
||||
const timeStr = ev.all_day ? 'All day' : formatTime(start) + (end ? ' – ' + formatTime(end) : '');
|
||||
html += '<div class="list-event" onclick="showEvent(\\'' + esc(lookupId) + '\\')">' +
|
||||
'<div class="evt-color-dot" style="background:' + color.border + '"></div>' +
|
||||
'<div class="evt-info">' +
|
||||
'<div class="evt-title">' + esc(ev.summary) + '</div>' +
|
||||
'<div class="evt-meta">' + esc(timeStr) + (ev.location ? ' · ' + esc(ev.location) : '') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
content.innerHTML = html || '<div class="list-empty">No events this week</div>';
|
||||
}
|
||||
|
||||
async function showEvent(id) {
|
||||
const r = await fetch(base + '/api/events/' + encodeURIComponent(id));
|
||||
const data = await r.json();
|
||||
const ev = data.event;
|
||||
if (!ev) return;
|
||||
|
||||
const start = parseEventDt(ev.start);
|
||||
const end = parseEventDt(ev.end);
|
||||
const color = getColor(ev.lookup_id || ev.id);
|
||||
|
||||
let timeStr;
|
||||
if (ev.all_day) {
|
||||
timeStr = formatDateLong(start);
|
||||
} else {
|
||||
timeStr = formatDateLong(start) + '<br>' + formatTime(start) + (end ? ' – ' + formatTime(end) : '');
|
||||
}
|
||||
|
||||
let html = '<div class="modal-header">' +
|
||||
'<div class="modal-color" style="background:' + color.border + '"></div>' +
|
||||
'<div class="modal-title">' + esc(ev.summary) + '</div>' +
|
||||
'<button class="modal-close" onclick="closeModal()">✕</button>' +
|
||||
'</div>';
|
||||
html += '<div class="modal-body">';
|
||||
html += '<div class="modal-row"><span class="modal-icon">📅</span><span class="modal-text">' + timeStr + '</span></div>';
|
||||
if (ev.location) {
|
||||
html += '<div class="modal-row"><span class="modal-icon">📍</span><span class="modal-text">' + esc(ev.location) + '</span></div>';
|
||||
}
|
||||
if (ev.description) {
|
||||
html += '<div class="modal-row"><span class="modal-icon">📝</span><span class="modal-text modal-desc">' + esc(ev.description) + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('modal').innerHTML = html;
|
||||
document.getElementById('modal-overlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== document.getElementById('modal-overlay')) return;
|
||||
document.getElementById('modal-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
document.getElementById('btn-week').classList.toggle('active', view === 'week');
|
||||
document.getElementById('btn-list').classList.toggle('active', view === 'list');
|
||||
document.getElementById('week-view').style.display = view === 'week' ? 'flex' : 'none';
|
||||
document.getElementById('list-view').style.display = view === 'list' ? 'block' : 'none';
|
||||
if (view === 'list') renderListView();
|
||||
}
|
||||
|
||||
async function prevWeek() {
|
||||
currentWeekStart.setDate(currentWeekStart.getDate() - 7);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function nextWeek() {
|
||||
currentWeekStart.setDate(currentWeekStart.getDate() + 7);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function goToday() {
|
||||
currentWeekStart = getWeekStart(new Date());
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
syncScrollbarGutter();
|
||||
updateWeekLabel();
|
||||
await fetchEvents();
|
||||
renderWeekHeader();
|
||||
renderWeekGrid();
|
||||
if (currentView === 'list') renderListView();
|
||||
// Scroll to 8am on week view
|
||||
scrollToHour(8);
|
||||
}
|
||||
|
||||
function scrollToHour(hour) {
|
||||
const body = document.querySelector('.week-body');
|
||||
if (body) body.scrollTop = hour * PX_PER_HOUR - 20;
|
||||
}
|
||||
|
||||
// Keyboard nav
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') document.getElementById('modal-overlay').classList.remove('open');
|
||||
if (e.key === 'ArrowLeft' && !e.target.closest('.modal')) prevWeek();
|
||||
if (e.key === 'ArrowRight' && !e.target.closest('.modal')) nextWeek();
|
||||
});
|
||||
|
||||
// Init
|
||||
window.addEventListener('resize', syncScrollbarGutter);
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"run": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"-m",
|
||||
"google_calendar"
|
||||
]
|
||||
},
|
||||
"toolsets": {
|
||||
"read": [
|
||||
"check_availability",
|
||||
"get_event",
|
||||
"list_accounts",
|
||||
"list_calendars",
|
||||
"list_events",
|
||||
"search_events"
|
||||
],
|
||||
"write": [
|
||||
"create_calendar",
|
||||
"create_event",
|
||||
"delete_event",
|
||||
"respond_to_event",
|
||||
"update_event"
|
||||
],
|
||||
"events": [
|
||||
"create_event",
|
||||
"delete_event",
|
||||
"get_event",
|
||||
"list_events",
|
||||
"search_events",
|
||||
"respond_to_event",
|
||||
"update_event"
|
||||
],
|
||||
"calendars": [
|
||||
"create_calendar",
|
||||
"list_calendars"
|
||||
],
|
||||
"accounts": [
|
||||
"list_accounts"
|
||||
],
|
||||
"search": [
|
||||
"list_events",
|
||||
"search_events"
|
||||
],
|
||||
"availability": [
|
||||
"check_availability"
|
||||
],
|
||||
"scheduling": [
|
||||
"check_availability",
|
||||
"respond_to_event"
|
||||
],
|
||||
"core": [
|
||||
"create_event",
|
||||
"delete_event",
|
||||
"get_event",
|
||||
"list_events",
|
||||
"search_events",
|
||||
"update_event"
|
||||
],
|
||||
"toolathlon_legacy": [
|
||||
"create_event",
|
||||
"delete_event",
|
||||
"get_event",
|
||||
"list_events",
|
||||
"update_event"
|
||||
],
|
||||
"state": [
|
||||
"export_state",
|
||||
"import_state"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
build-backend = "uv_build"
|
||||
requires = [ "uv-build>=0.11,<0.12" ]
|
||||
|
||||
[project]
|
||||
name = "google-calendar"
|
||||
version = "0.1.0"
|
||||
description = "Google Calendar mock MCP server"
|
||||
requires-python = ">=3.13"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3,<4",
|
||||
"pydantic[email]>=2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
build-backend.module-root = ""
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.testpaths = [ "tests" ]
|
||||
ini_options.pythonpath = [ "." ]
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Tests for attendees and RSVP functionality."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
AttendeeResponseStatus,
|
||||
CalendarPerson,
|
||||
EventAttendee,
|
||||
EventClearField,
|
||||
EventSource,
|
||||
EventTransparency,
|
||||
EventType,
|
||||
ExtendedProperties,
|
||||
)
|
||||
|
||||
|
||||
def _get_gc():
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(json.dumps({"events": {}}))
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_calendar"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_globals(calendar_data, outputdir):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
workspace.mkdir()
|
||||
state.set_agent_workspace(str(workspace))
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
class TestCreateEventWithAttendees:
|
||||
def test_create_and_update_attendees_are_schema_models(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.create_event.__annotations__["attendees"] == list[EventAttendee] | None
|
||||
assert gc.update_event.__annotations__["attendees"] == list[EventAttendee] | None
|
||||
|
||||
def test_create_and_update_people_are_schema_models(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.create_event.__annotations__["creator"] == CalendarPerson | None
|
||||
assert gc.create_event.__annotations__["organizer"] == CalendarPerson | None
|
||||
assert gc.update_event.__annotations__["creator"] == CalendarPerson | None
|
||||
assert gc.update_event.__annotations__["organizer"] == CalendarPerson | None
|
||||
|
||||
def test_create_and_update_metadata_are_schema_models(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.create_event.__annotations__["extendedProperties"] == ExtendedProperties | None
|
||||
assert gc.create_event.__annotations__["source"] == EventSource | None
|
||||
assert gc.create_event.__annotations__["transparency"] == EventTransparency | None
|
||||
assert gc.update_event.__annotations__["extendedProperties"] == ExtendedProperties | None
|
||||
assert gc.update_event.__annotations__["source"] == EventSource | None
|
||||
assert gc.update_event.__annotations__["transparency"] == EventTransparency | None
|
||||
assert gc.update_event.__annotations__["eventType"] == EventType | None
|
||||
assert gc.update_event.__annotations__["clear_fields"] == list[EventClearField] | None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_attendees(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Team Sync",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[
|
||||
{"email": "alice@co.com", "displayName": "Alice"},
|
||||
{"email": "bob@co.com"},
|
||||
],
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
attendees = result["event"]["attendees"]
|
||||
assert len(attendees) == 2
|
||||
assert attendees[0]["email"] == "alice@co.com"
|
||||
assert attendees[0]["responseStatus"] == "needsAction"
|
||||
assert attendees[1]["displayName"] == "bob@co.com" # falls back to email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_preserves_full_attendee_payload(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Team Sync",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[
|
||||
{
|
||||
"email": "room@example.com",
|
||||
"resource": True,
|
||||
"optional": True,
|
||||
"comment": "Projector needed",
|
||||
"additionalGuests": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
attendee = result["event"]["attendees"][0]
|
||||
assert attendee["resource"] is True
|
||||
assert attendee["optional"] is True
|
||||
assert attendee["comment"] == "Projector needed"
|
||||
assert attendee["additionalGuests"] == 2
|
||||
assert attendee["displayName"] == "room@example.com"
|
||||
assert attendee["responseStatus"] == "needsAction"
|
||||
assert attendee["organizer"] is False
|
||||
assert attendee["self"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_duplicate_attendees(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Team Sync",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[
|
||||
{"email": "alice@co.com"},
|
||||
{"email": "ALICE@co.com"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "Duplicate attendee email" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_without_attendees(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Solo Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert "attendees" not in result["event"]
|
||||
|
||||
|
||||
class TestUpdateEventAttendees:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_attendees_via_update(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
attendees=[{"email": "carol@co.com", "displayName": "Carol"}],
|
||||
)
|
||||
assert len(updated["event"]["attendees"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_preserves_full_attendee_payload(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
attendees=[
|
||||
{
|
||||
"email": "room@example.com",
|
||||
"resource": True,
|
||||
"optional": True,
|
||||
"comment": "Projector needed",
|
||||
"additionalGuests": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
attendee = updated["event"]["attendees"][0]
|
||||
assert attendee["resource"] is True
|
||||
assert attendee["optional"] is True
|
||||
assert attendee["comment"] == "Projector needed"
|
||||
assert attendee["additionalGuests"] == 2
|
||||
assert attendee["displayName"] == "room@example.com"
|
||||
assert attendee["responseStatus"] == "needsAction"
|
||||
assert attendee["organizer"] is False
|
||||
assert attendee["self"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_duplicate_attendees(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
attendees=[
|
||||
{"email": "alice@co.com"},
|
||||
{"email": "ALICE@co.com"},
|
||||
],
|
||||
)
|
||||
|
||||
assert updated["status"] == "error"
|
||||
assert "Duplicate attendee email" in updated["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_attendees(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(eventId=event_id, attendees=[])
|
||||
assert updated["event"]["attendees"] == []
|
||||
|
||||
|
||||
class TestUpdateEventMetadata:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_can_clear_optional_metadata_fields(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Annotated Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
creator={"email": "creator@example.com"},
|
||||
organizer={"email": "organizer@example.com"},
|
||||
extendedProperties={"private": {"task_id": "task-123"}},
|
||||
source={"title": "Planning notes"},
|
||||
transparency="transparent",
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
clear_fields=[
|
||||
EventClearField.CREATOR,
|
||||
EventClearField.ORGANIZER,
|
||||
EventClearField.EXTENDED_PROPERTIES,
|
||||
EventClearField.SOURCE,
|
||||
EventClearField.TRANSPARENCY,
|
||||
],
|
||||
)
|
||||
|
||||
assert updated["status"] == "success"
|
||||
for field in ["creator", "organizer", "extendedProperties", "source", "transparency"]:
|
||||
assert field not in updated["event"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_can_switch_special_event_type_back_to_default(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Focus Block",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
eventType="focusTime",
|
||||
focusTimeProperties={"chatStatus": "doNotDisturb"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(eventId=event_id, eventType="default")
|
||||
|
||||
assert updated["status"] == "success"
|
||||
assert updated["event"]["eventType"] == "default"
|
||||
assert "focusTimeProperties" not in updated["event"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_error_for_invalid_event_type_shape(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Focus Block",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
eventType="focusTime",
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "focusTimeProperties is required" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_returns_error_for_invalid_merged_event(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="All Day",
|
||||
start={"date": "2025-06-01"},
|
||||
end={"date": "2025-06-02"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(eventId=event_id, start={"dateTime": "2025-06-01T10:00:00Z"})
|
||||
|
||||
assert updated["status"] == "error"
|
||||
assert "both use dateTime or both use date" in updated["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_can_switch_default_event_to_special_type_with_properties(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Working location",
|
||||
start={"date": "2025-06-01"},
|
||||
end={"date": "2025-06-02"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
eventType=EventType.WORKING_LOCATION,
|
||||
workingLocationProperties={"type": "customLocation", "customLocation": {"label": "Client office"}},
|
||||
)
|
||||
|
||||
assert updated["status"] == "success"
|
||||
assert updated["event"]["eventType"] == "workingLocation"
|
||||
assert updated["event"]["workingLocationProperties"]["customLocation"]["label"] == "Client office"
|
||||
|
||||
|
||||
class TestRespondToEvent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invitation(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}, {"email": "bob@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.ACCEPTED
|
||||
)
|
||||
assert rsvp["status"] == "success"
|
||||
alice = next(a for a in rsvp["event"]["attendees"] if a["email"] == "alice@co.com")
|
||||
assert alice["responseStatus"] == "accepted"
|
||||
# Bob should still be needsAction
|
||||
bob = next(a for a in rsvp["event"]["attendees"] if a["email"] == "bob@co.com")
|
||||
assert bob["responseStatus"] == "needsAction"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decline_invitation(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.DECLINED
|
||||
)
|
||||
alice = rsvp["event"]["attendees"][0]
|
||||
assert alice["responseStatus"] == "declined"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tentative_response(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.TENTATIVE
|
||||
)
|
||||
assert rsvp["event"]["attendees"][0]["responseStatus"] == "tentative"
|
||||
|
||||
def test_respond_response_is_schema_enum(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.respond_to_event.__annotations__["response"] == AttendeeResponseStatus
|
||||
assert gc.respond_to_event.__annotations__["email"] == EmailStr
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(AttendeeResponseStatus).validate_python("maybe")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_attendee_not_found(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId=event_id,
|
||||
email="unknown@co.com",
|
||||
response=AttendeeResponseStatus.ACCEPTED,
|
||||
)
|
||||
assert rsvp["status"] == "error"
|
||||
assert "not found" in rsvp["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_event_not_found(self):
|
||||
gc = _get_gc()
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId="nonexistent",
|
||||
email="alice@co.com",
|
||||
response=AttendeeResponseStatus.ACCEPTED,
|
||||
)
|
||||
assert rsvp["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_case_insensitive_email(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "Alice@CO.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
rsvp = await gc.respond_to_event(
|
||||
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.ACCEPTED
|
||||
)
|
||||
assert rsvp["status"] == "success"
|
||||
|
||||
|
||||
class TestAvailabilityWithAttendees:
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_attendee(self):
|
||||
gc = _get_gc()
|
||||
# Create event with alice as attendee
|
||||
await gc.create_event(
|
||||
summary="Alice's Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com", "responseStatus": "accepted"}],
|
||||
)
|
||||
# Create event with only bob
|
||||
await gc.create_event(
|
||||
summary="Bob's Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
attendees=[{"email": "bob@co.com", "responseStatus": "accepted"}],
|
||||
)
|
||||
|
||||
# Check alice's availability — should only see alice's meeting
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
attendee_emails=["alice@co.com"],
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Alice's Meeting" in result["busy"][0]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_declined_events_not_busy(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Optional Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com"}],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
# Alice declines
|
||||
await gc.respond_to_event(eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.DECLINED)
|
||||
|
||||
# Check alice's availability — declined event should not block
|
||||
avail = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
attendee_emails=["alice@co.com"],
|
||||
)
|
||||
assert len(avail["busy"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_mutual_availability(self):
|
||||
gc = _get_gc()
|
||||
# Alice busy 10-11am
|
||||
await gc.create_event(
|
||||
summary="Alice Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
attendees=[{"email": "alice@co.com", "responseStatus": "accepted"}],
|
||||
)
|
||||
# Bob busy 2-3pm (non-adjacent so they don't merge)
|
||||
await gc.create_event(
|
||||
summary="Bob Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
attendees=[{"email": "bob@co.com", "responseStatus": "accepted"}],
|
||||
)
|
||||
|
||||
# Check availability for both — should see both busy periods
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
attendee_emails=["alice@co.com", "bob@co.com"],
|
||||
)
|
||||
assert len(result["busy"]) == 2
|
||||
assert result["total_busy_minutes"] == 120
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Tests for the nested bundle-input layout: <BUNDLEDIR>/services/<name>/state.json."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
# A minimal, valid CalendarState seed. CalendarState has all-default fields, so
|
||||
# an empty events dict is valid; one event keeps the round-trip meaningful.
|
||||
SEED = {
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Seeded Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
"""Keep state in-memory and reset the account registry around each test."""
|
||||
state = _get_state()
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_prefers_state_json(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
state_json = service_dir / "state.json"
|
||||
state_json.write_text(json.dumps(SEED))
|
||||
(service_dir / "events.json").write_text(json.dumps(SEED))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_path() == state_json
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
a_json.write_text(json.dumps(SEED))
|
||||
(service_dir / "b.json").write_text(json.dumps(SEED))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_path() == a_json
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_missing_subdir(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
(tmp_path / "services").mkdir()
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert state.resolve_bundle_state_path() is None
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
assert state.resolve_bundle_state_path() is None
|
||||
|
||||
|
||||
def test_resolve_bundle_output_path(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
output_dir = tmp_path / "services" / "google_calendar"
|
||||
|
||||
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(output_dir))
|
||||
assert state.resolve_bundle_output_path() == output_dir / "state.json"
|
||||
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
assert state.resolve_bundle_output_path() is None
|
||||
|
||||
|
||||
def test_bundle_state_json_matches_inputdir(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
service_dir = bundle_dir / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(json.dumps(SEED))
|
||||
|
||||
inputdir = tmp_path / "input"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "calendar.json").write_text(json.dumps(SEED))
|
||||
|
||||
# Load from the nested bundle layout.
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
state.load_seed_state_from_env()
|
||||
bundle_state = state.state_to_json()
|
||||
|
||||
# Reset and load the same seed from the INPUTDIR fallback.
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
state.set_agent_workspace(None)
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
state.load_seed_state_from_env()
|
||||
inputdir_state = state.state_to_json()
|
||||
|
||||
assert bundle_state == inputdir_state
|
||||
|
||||
|
||||
# Two distinguishable accounts: different seeded event so a swap would be caught.
|
||||
ACCT_A = {
|
||||
"events": {
|
||||
"evt-a": {
|
||||
"id": "evt-a",
|
||||
"summary": "Account A Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
ACCT_B = {
|
||||
"events": {
|
||||
"evt-b": {
|
||||
"id": "evt-b",
|
||||
"summary": "Account B Event",
|
||||
"start": {"dateTime": "2025-07-02T14:00:00Z"},
|
||||
"end": {"dateTime": "2025-07-02T15:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _load_from_bundle(state, monkeypatch, bundle_dir):
|
||||
"""Reset the registry and load seed state from *bundle_dir* in isolation."""
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
state.load_seed_state_from_env()
|
||||
return state.state_to_json()
|
||||
|
||||
|
||||
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path, monkeypatch):
|
||||
"""A multi-file bundle folder coalesces to the same state as a single
|
||||
consolidated state.json with the same accounts."""
|
||||
state = _get_state()
|
||||
|
||||
# (a) Consolidated: one state.json with both accounts.
|
||||
consolidated = tmp_path / "consolidated"
|
||||
consolidated_dir = consolidated / "services" / "google_calendar"
|
||||
consolidated_dir.mkdir(parents=True)
|
||||
(consolidated_dir / "state.json").write_text(json.dumps({"accounts": {"default": ACCT_A, "work": ACCT_B}}))
|
||||
|
||||
# (b) Split: two wrapper files, no state.json.
|
||||
split = tmp_path / "split"
|
||||
split_dir = split / "services" / "google_calendar"
|
||||
split_dir.mkdir(parents=True)
|
||||
(split_dir / "a.json").write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
(split_dir / "b.json").write_text(json.dumps({"accounts": {"work": ACCT_B}}))
|
||||
|
||||
consolidated_state = _load_from_bundle(state, monkeypatch, consolidated)
|
||||
split_state = _load_from_bundle(state, monkeypatch, split)
|
||||
|
||||
assert consolidated_state == split_state
|
||||
assert set(consolidated_state["accounts"]) == {"default", "work"}
|
||||
assert set(split_state["accounts"]) == {"default", "work"}
|
||||
|
||||
|
||||
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path, monkeypatch):
|
||||
"""The plural resolver returns ALL sorted *.json when there's no state.json,
|
||||
and exactly [state.json] when one is present."""
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
b_json = service_dir / "b.json"
|
||||
a_json.write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
b_json.write_text(json.dumps({"accounts": {"work": ACCT_B}}))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_paths() == [a_json, b_json]
|
||||
|
||||
state_json = service_dir / "state.json"
|
||||
state_json.write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
assert state.resolve_bundle_state_paths() == [state_json]
|
||||
|
||||
|
||||
def test_bundle_flat_files_merge_into_one_account(tmp_path, monkeypatch):
|
||||
"""the raw entities layout splits ONE account across per-entity files
|
||||
(e.g. events.json + calendars.json, no {accounts} wrapper). Flat files must
|
||||
merge into a single default account, not fragment into a phantom account
|
||||
per file that the server never activates."""
|
||||
state = _get_state()
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
# One account split into its two collection halves.
|
||||
(service_dir / "events.json").write_text(json.dumps({"events": ACCT_A["events"]}))
|
||||
(service_dir / "calendars.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"calendars": {
|
||||
"team": {
|
||||
"summary": "Team",
|
||||
"events": {
|
||||
"evt-team": {
|
||||
"id": "evt-team",
|
||||
"summary": "Team Event",
|
||||
"start": {"dateTime": "2025-08-03T09:00:00Z"},
|
||||
"end": {"dateTime": "2025-08-03T10:00:00Z"},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
merged = _load_from_bundle(state, monkeypatch, tmp_path / "bundle")
|
||||
|
||||
# Single (default) account holding BOTH halves — events and the calendar.
|
||||
assert "accounts" not in merged, "flat per-entity files must merge into ONE account"
|
||||
assert "evt-a" in merged["events"]
|
||||
assert "team" in merged["calendars"]
|
||||
@@ -0,0 +1,980 @@
|
||||
"""Tests for multiple calendar support."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from google_calendar.models import EventTransparency, EventType
|
||||
|
||||
|
||||
def _get_gc():
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
"""Seed with primary events (backward-compatible flat format)."""
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"timeZone": "America/New_York",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Primary Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_calendar"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_globals(calendar_data, outputdir):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
workspace.mkdir()
|
||||
state.set_agent_workspace(str(workspace))
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Existing flat events dict works as 'primary' calendar."""
|
||||
|
||||
def test_get_calendar_events_attaches_missing_primary_events_dict(self):
|
||||
state = _get_state()
|
||||
data = {}
|
||||
|
||||
events = state.get_calendar_events(data, "primary")
|
||||
events["evt-1"] = {"id": "evt-1"}
|
||||
|
||||
assert data["events"] is events
|
||||
assert data["events"]["evt-1"]["id"] == "evt-1"
|
||||
|
||||
def test_get_calendar_events_attaches_missing_secondary_events_dict(self):
|
||||
state = _get_state()
|
||||
data = {"calendars": {"work": {"summary": "Work"}}}
|
||||
|
||||
events = state.get_calendar_events(data, "work")
|
||||
events["evt-1"] = {"id": "evt-1"}
|
||||
|
||||
assert data["calendars"]["work"]["events"] is events
|
||||
assert data["calendars"]["work"]["events"]["evt-1"]["id"] == "evt-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_from_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.get_event(eventId="evt-1")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Primary Event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_with_explicit_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.get_event(eventId="evt-1", calendar_id="primary")
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_defaults_to_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="New Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
# Should be in the flat events dict
|
||||
data = _get_state().load_data()
|
||||
assert result["event"]["id"] in data["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_default_includes_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
assert result["count"] >= 1
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
|
||||
def test_save_data_rejects_invalid_calendar_state(self):
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {"primary": {"summary": "Duplicate Primary", "events": {}}}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
state.save_data(data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_returns_error_when_existing_state_is_invalid(self, calendar_data):
|
||||
gc = _get_gc()
|
||||
invalid_state = _get_state().load_data()
|
||||
invalid_state["calendars"] = {"primary": {"summary": "Duplicate Primary", "events": {}}}
|
||||
calendar_data.write_text(json.dumps(invalid_state))
|
||||
|
||||
result = await gc.create_calendar(summary="Work")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "Primary calendar events must be stored in top-level events" in result["message"]
|
||||
persisted = json.loads(calendar_data.read_text())
|
||||
assert "work" not in persisted["calendars"]
|
||||
|
||||
|
||||
class TestCreateCalendar:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Personal")
|
||||
assert result["status"] == "success"
|
||||
assert result["calendar"]["id"] == "personal"
|
||||
assert result["calendar"]["summary"] == "Personal"
|
||||
assert result["calendar"]["primary"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar_with_description(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Team Meetings", description="Shared team calendar")
|
||||
assert result["calendar"]["description"] == "Shared team calendar"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar_with_timezone(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="London Office", timeZone="Europe/London")
|
||||
assert result["status"] == "success"
|
||||
assert result["calendar"]["timeZone"] == "Europe/London"
|
||||
|
||||
data = _get_state().load_data()
|
||||
assert data["calendars"]["london-office"]["timeZone"] == "Europe/London"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Personal")
|
||||
result = await gc.create_calendar(summary="Personal")
|
||||
assert result["status"] == "error"
|
||||
assert "already exists" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_create_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Primary")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_create_empty_sanitized_calendar_id(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="!!!")
|
||||
assert result["status"] == "error"
|
||||
assert "letter or number" in result["message"]
|
||||
assert "" not in _get_state().load_data().get("calendars", {})
|
||||
|
||||
|
||||
class TestListCalendars:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_includes_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_calendars()
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] >= 1
|
||||
ids = [c["id"] for c in result["calendars"]]
|
||||
assert "primary" in ids
|
||||
primary = next(c for c in result["calendars"] if c["id"] == "primary")
|
||||
assert primary["timeZone"] == "America/New_York"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_includes_created_calendars(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_calendar(summary="Personal")
|
||||
result = await gc.list_calendars()
|
||||
ids = [c["id"] for c in result["calendars"]]
|
||||
assert "work" in ids
|
||||
assert "personal" in ids
|
||||
assert result["count"] == 3 # primary + work + personal
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_shows_event_count(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_calendars()
|
||||
primary = next(c for c in result["calendars"] if c["id"] == "primary")
|
||||
assert primary["eventCount"] == 1 # from fixture
|
||||
|
||||
|
||||
class TestMultiCalendarEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_in_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_from_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.get_event(eventId=event_id, calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_not_found_in_wrong_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.get_event(eventId=event_id, calendar_id="primary")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_in_nonexistent_calendar(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Orphan",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="nonexistent",
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_all_calendars(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# List without calendar_id = all calendars
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
assert "Work Meeting" in summaries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_preserves_duplicate_ids_across_calendars(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
assert "Work Event With Same ID" in summaries
|
||||
assert result["count"] == 2
|
||||
|
||||
def test_viewer_events_include_secondary_calendars(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert events["evt-1"]["summary"] == "Primary Event"
|
||||
assert events["evt-1"]["calendar_id"] == "primary"
|
||||
assert events["evt-1"]["lookup_id"] == "evt-1"
|
||||
assert events["work:evt-1"]["summary"] == "Work Event With Same ID"
|
||||
assert events["work:evt-1"]["calendar_id"] == "work"
|
||||
assert events["work:evt-1"]["lookup_id"] == "work:evt-1"
|
||||
|
||||
def test_viewer_events_are_pinned_to_default_account(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Default Account Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
state.set_active_account("work")
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert "default-event" in events
|
||||
assert "work-event" not in events
|
||||
|
||||
def test_viewer_events_use_first_account_when_default_is_absent(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert list(events) == ["work-event"]
|
||||
assert events["work-event"]["summary"] == "Work Account Event"
|
||||
|
||||
def test_viewer_event_detail_uses_unique_lookup_id(self):
|
||||
from google_calendar.viewer import create_calendar_viewer_app
|
||||
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
client = TestClient(create_calendar_viewer_app())
|
||||
|
||||
primary = client.get("/api/events/evt-1")
|
||||
work = client.get("/api/events/work%3Aevt-1")
|
||||
|
||||
assert primary.status_code == 200
|
||||
assert primary.json()["event"]["summary"] == "Primary Event"
|
||||
assert primary.json()["event"]["lookup_id"] == "evt-1"
|
||||
assert work.status_code == 200
|
||||
assert work.json()["event"]["summary"] == "Work Event With Same ID"
|
||||
assert work.json()["event"]["lookup_id"] == "work:evt-1"
|
||||
|
||||
def test_viewer_stats_handles_timed_events(self):
|
||||
from google_calendar.viewer import create_calendar_viewer_app
|
||||
|
||||
client = TestClient(create_calendar_viewer_app(), raise_server_exceptions=False)
|
||||
response = client.get("/api/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total_events"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_single_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# List only work calendar
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
calendar_id="work",
|
||||
)
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_max_results_zero_returns_no_events(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
maxResults=0,
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_from_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="To Delete",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.delete_event(eventId=event_id, calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_in_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Original",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.update_event(eventId=event_id, summary="Updated", calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Updated"
|
||||
|
||||
|
||||
class TestCheckAvailability:
|
||||
"""Tests for free/busy availability checking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fully_free_range(self):
|
||||
gc = _get_gc()
|
||||
# Primary has one event at 10-11am on June 1. Check June 2 = all free.
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-02T08:00:00Z",
|
||||
timeMax="2025-06-02T18:00:00Z",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert len(result["busy"]) == 0
|
||||
assert len(result["free_slots"]) == 1
|
||||
assert result["free_slots"][0]["duration_minutes"] == 600 # 10 hours
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_busy_period_detected(self):
|
||||
gc = _get_gc()
|
||||
# Check range that includes the fixture event (10-11am June 1)
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Primary Event" in result["busy"][0]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transparent_events_do_not_block_availability(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="FYI hold",
|
||||
start={"dateTime": "2025-06-05T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
transparency="transparent",
|
||||
)
|
||||
assert result["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
assert availability["total_free_minutes"] == 240
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_from_gmail_events_default_transparent_and_do_not_block_availability(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Flight to Denver",
|
||||
start={"dateTime": "2025-06-05T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
eventType=EventType.FROM_GMAIL,
|
||||
)
|
||||
assert result["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_can_mark_event_transparent(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Optional office hours",
|
||||
start={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T11:00:00Z"},
|
||||
)
|
||||
|
||||
updated = await gc.update_event(eventId=result["event"]["id"], transparency=EventTransparency.TRANSPARENT)
|
||||
assert updated["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_slots_around_event(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
duration_minutes=30,
|
||||
)
|
||||
# Should have free slot before (8-10am) and after (11am-6pm) the event
|
||||
assert len(result["free_slots"]) == 2
|
||||
assert result["free_slots"][0]["duration_minutes"] == 120 # 8am-10am
|
||||
assert result["free_slots"][1]["duration_minutes"] == 420 # 11am-6pm
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_filter(self):
|
||||
gc = _get_gc()
|
||||
# Create two events close together, leaving only a 30min gap
|
||||
await gc.create_event(
|
||||
summary="Meeting 1",
|
||||
start={"dateTime": "2025-06-03T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-03T10:00:00Z"},
|
||||
)
|
||||
await gc.create_event(
|
||||
summary="Meeting 2",
|
||||
start={"dateTime": "2025-06-03T10:30:00Z"},
|
||||
end={"dateTime": "2025-06-03T11:30:00Z"},
|
||||
)
|
||||
# Ask for 60min slots — the 30min gap should be filtered out
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-03T08:00:00Z",
|
||||
timeMax="2025-06-03T12:00:00Z",
|
||||
duration_minutes=60,
|
||||
)
|
||||
free_durations = [s["duration_minutes"] for s in result["free_slots"]]
|
||||
assert 30 not in free_durations # 30min gap filtered
|
||||
assert 60 in free_durations # 8-9am slot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_specific_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# Check only work calendar — should not see primary event
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
calendar_id="work",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Work Meeting" in result["busy"][0]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_availability_preserves_duplicate_ids_across_calendars(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
|
||||
busy_event_names = [name for period in result["busy"] for name in period["events"]]
|
||||
assert "Primary Event" in busy_event_names
|
||||
assert "Work Event With Same ID" in busy_event_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlapping_events_merged(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="A",
|
||||
start={"dateTime": "2025-06-04T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-04T10:30:00Z"},
|
||||
)
|
||||
await gc.create_event(
|
||||
summary="B",
|
||||
start={"dateTime": "2025-06-04T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-04T11:00:00Z"},
|
||||
)
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-04T08:00:00Z",
|
||||
timeMax="2025-06-04T12:00:00Z",
|
||||
)
|
||||
# A and B overlap, so should merge into one busy period
|
||||
assert len(result["busy"]) == 1
|
||||
assert result["total_busy_minutes"] == 120 # 9am-11am
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_totals_correct(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
assert result["total_busy_minutes"] + result["total_free_minutes"] == 600 # 10 hours
|
||||
|
||||
|
||||
class TestMultiAccountSupport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_reports_default_for_flat_state(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.list_accounts()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 1
|
||||
assert result["accounts"][0]["account_id"] == "default"
|
||||
assert result["accounts"][0]["event_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_account_state_routes_reads_and_writes_by_account(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Default Account Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
accounts = await gc.list_accounts()
|
||||
assert {account["account_id"] for account in accounts["accounts"]} == {"default", "work"}
|
||||
|
||||
default_result = await gc.get_event(eventId="default-event")
|
||||
assert default_result["status"] == "success"
|
||||
assert default_result["event"]["summary"] == "Default Account Event"
|
||||
|
||||
work_result = await gc.get_event(eventId="work-event", account_id="work")
|
||||
assert work_result["status"] == "success"
|
||||
assert work_result["event"]["summary"] == "Work Account Event"
|
||||
|
||||
missing_cross_account = await gc.get_event(eventId="work-event", account_id="default")
|
||||
assert missing_cross_account["status"] == "error"
|
||||
|
||||
created = await gc.create_event(
|
||||
summary="Work Follow-up",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
account_id="work",
|
||||
)
|
||||
assert created["status"] == "success"
|
||||
|
||||
exported = state.state_to_json()
|
||||
created_id = created["event"]["id"]
|
||||
assert created_id in exported["accounts"]["work"]["events"]
|
||||
assert created_id not in exported["accounts"]["default"]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_and_availability_respect_account_id(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Shared Planning",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Shared Planning",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
default_search = await gc.search_events(query="shared planning")
|
||||
work_search = await gc.search_events(query="shared planning", account_id="work")
|
||||
assert [event["id"] for event in default_search["events"]] == ["default-event"]
|
||||
assert [event["id"] for event in work_search["events"]] == ["work-event"]
|
||||
|
||||
default_availability = await gc.check_availability(
|
||||
timeMin="2025-06-01T09:00:00Z",
|
||||
timeMax="2025-06-01T14:00:00Z",
|
||||
account_id="default",
|
||||
)
|
||||
work_availability = await gc.check_availability(
|
||||
timeMin="2025-06-01T09:00:00Z",
|
||||
timeMax="2025-06-01T14:00:00Z",
|
||||
account_id="work",
|
||||
)
|
||||
assert default_availability["busy"][0]["events"] == ["Shared Planning"]
|
||||
assert default_availability["busy"][0]["start"] == "2025-06-01T10:00:00+00:00"
|
||||
assert work_availability["busy"][0]["events"] == ["Shared Planning"]
|
||||
assert work_availability["busy"][0]["start"] == "2025-06-01T12:00:00+00:00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_write_in_one_account_leaves_other_accounts_untouched(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {"events": {}},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Broken Event",
|
||||
start={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
)
|
||||
|
||||
exported = state.state_to_json()
|
||||
assert result["status"] == "error"
|
||||
assert exported["accounts"]["default"]["events"] == {}
|
||||
assert exported["accounts"]["work"]["events"]["work-event"]["summary"] == "Work Event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_from_json_resets_active_account_to_default(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "work": {"events": {}}}})
|
||||
await gc.create_event(
|
||||
summary="Work Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
account_id="work",
|
||||
)
|
||||
assert state.get_active_account_id() == "work"
|
||||
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "personal": {"events": {}}}})
|
||||
|
||||
assert state.get_active_account_id() == "default"
|
||||
result = await gc.create_event(
|
||||
summary="Default Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
)
|
||||
exported = state.state_to_json()
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["id"] in exported["accounts"]["default"]["events"]
|
||||
assert exported["accounts"]["personal"]["events"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_account_uses_first_loaded_account_when_default_is_absent(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = await gc.get_event(eventId="work-event")
|
||||
|
||||
assert state.get_active_account_id() == "work"
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Account Event"
|
||||
|
||||
def test_ensure_loaded_uses_first_persisted_account_when_default_is_absent(self, calendar_data):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
calendar_data.write_text(json.dumps({"accounts": {"work": {"events": {}}}}))
|
||||
state.set_agent_workspace(str(workspace))
|
||||
|
||||
assert state.load_data() == {"events": {}}
|
||||
assert state.get_active_account_id() == "work"
|
||||
|
||||
def test_no_workspace_mode_stays_in_memory(self, tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("AGENT_WORKSPACE", raising=False)
|
||||
state.set_agent_workspace(None)
|
||||
|
||||
state.state_from_json({"events": {}})
|
||||
state.save_data({"events": {}})
|
||||
|
||||
assert not (tmp_path / "calendar_data.json").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_account_snapshot_preserves_wrapper(self, outputdir):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "personal": {"events": {}}}})
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Personal Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
account_id="personal",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
|
||||
snapshot = json.loads((outputdir / "final.json").read_text())
|
||||
assert set(snapshot["accounts"]) == {"default", "personal"}
|
||||
assert result["event"]["id"] in snapshot["accounts"]["personal"]["events"]
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Tests for recurring events and reminders."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from google_calendar.models import EventReminders
|
||||
|
||||
|
||||
def _get_gc():
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(json.dumps({"events": {}}))
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_calendar"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_globals(calendar_data, outputdir):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
workspace.mkdir()
|
||||
state.set_agent_workspace(str(workspace))
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReminders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_with_reminders(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
reminders={"useDefault": False, "overrides": [{"method": "popup", "minutes": 15}]},
|
||||
)
|
||||
assert result["event"]["reminders"]["overrides"][0]["minutes"] == 15
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_reminders(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(
|
||||
eventId=event_id,
|
||||
reminders={"useDefault": False, "overrides": [{"method": "email", "minutes": 30}]},
|
||||
)
|
||||
assert updated["event"]["reminders"]["overrides"][0]["method"] == "email"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_reverts_to_default_reminders(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Meeting",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
reminders={"useDefault": False, "overrides": [{"method": "email", "minutes": 30}]},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(eventId=event_id, reminders={"useDefault": True})
|
||||
|
||||
assert updated["event"]["reminders"] == {"useDefault": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_without_reminders(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="No Reminders",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert "reminders" not in result["event"]
|
||||
|
||||
def test_reminders_are_schema_model(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.create_event.__annotations__["reminders"] == EventReminders | None
|
||||
assert gc.update_event.__annotations__["reminders"] == EventReminders | None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"useDefault": False, "overrides": [{"method": "sms", "minutes": 10}]},
|
||||
{"useDefault": False, "overrides": [{"method": "popup", "minutes": -1}]},
|
||||
{"useDefault": False, "overrides": [{"method": "popup", "minutes": 40321}]},
|
||||
{"useDefault": True, "overrides": [{"method": "popup", "minutes": 10}]},
|
||||
{"useDefault": False},
|
||||
{"useDefault": False, "overrides": []},
|
||||
{
|
||||
"useDefault": False,
|
||||
"overrides": [
|
||||
{"method": "popup", "minutes": 1},
|
||||
{"method": "popup", "minutes": 2},
|
||||
{"method": "popup", "minutes": 3},
|
||||
{"method": "popup", "minutes": 4},
|
||||
{"method": "popup", "minutes": 5},
|
||||
{"method": "popup", "minutes": 6},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_invalid_reminders_are_rejected(self, payload):
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(EventReminders).validate_python(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recurring Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecurringEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_daily_recurring_event(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
assert result["event"]["recurrence"] == ["RRULE:FREQ=DAILY;COUNT=5"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_bad_recurrence_prefix(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Bad recurrence",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "String should match pattern" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_accepts_google_calendar_recurrence_exception_prefixes(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Recurring With Exceptions",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5", "EXDATE:20250603T090000Z", "RDATE:20250610T090000Z"],
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["recurrence"] == [
|
||||
"RRULE:FREQ=DAILY;COUNT=5",
|
||||
"EXDATE:20250603T090000Z",
|
||||
"RDATE:20250610T090000Z",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_daily_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
# Should have 5 instances (June 1-5)
|
||||
standups = [e for e in result["events"] if "Standup" in e["summary"]]
|
||||
assert len(standups) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_weekly_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Weekly Sync",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"}, # Monday
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=WEEKLY;COUNT=4"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-07-01T00:00:00Z",
|
||||
)
|
||||
syncs = [e for e in result["events"] if "Weekly Sync" in e["summary"]]
|
||||
assert len(syncs) == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_weekly_with_byday(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Tue/Thu Meeting",
|
||||
start={"dateTime": "2025-06-03T14:00:00Z"}, # Tuesday
|
||||
end={"dateTime": "2025-06-03T15:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=WEEKLY;BYDAY=TU,TH;COUNT=6"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-30T00:00:00Z",
|
||||
)
|
||||
meetings = [e for e in result["events"] if "Tue/Thu" in e["summary"]]
|
||||
assert len(meetings) == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_have_unique_ids(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Daily",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=3"],
|
||||
)
|
||||
parent_id = result["event"]["id"]
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
ids = [e["id"] for e in listed["events"] if "Daily" in e["summary"]]
|
||||
# Each instance has a unique ID derived from parent
|
||||
assert len(ids) == 3
|
||||
assert len(set(ids)) == 3 # all unique
|
||||
assert all(parent_id in eid for eid in ids)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_have_recurringEventId(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Recurring",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=2"],
|
||||
)
|
||||
parent_id = result["event"]["id"]
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "Recurring" in e["summary"]]
|
||||
for inst in instances:
|
||||
assert inst["recurringEventId"] == parent_id
|
||||
assert "recurrence" not in inst # instances don't carry the rule
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_preserve_parent_time_zone(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Recurring with time zone",
|
||||
start={"dateTime": "2025-06-01T09:00:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-06-01T09:30:00", "timeZone": "America/New_York"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=2"],
|
||||
)
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "Recurring with time zone" in e["summary"]]
|
||||
assert len(instances) == 2
|
||||
for inst in instances:
|
||||
assert inst["start"]["timeZone"] == "America/New_York"
|
||||
assert inst["end"]["timeZone"] == "America/New_York"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_use_named_time_zone_across_dst(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="DST recurring",
|
||||
start={"dateTime": "2025-03-08T09:00:00-05:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-03-08T09:30:00-05:00", "timeZone": "America/New_York"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=3"],
|
||||
)
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-03-08T00:00:00Z",
|
||||
timeMax="2025-03-12T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "DST recurring" in e["summary"]]
|
||||
|
||||
assert [inst["start"]["dateTime"] for inst in instances] == [
|
||||
"2025-03-08T09:00:00-05:00",
|
||||
"2025-03-09T09:00:00-04:00",
|
||||
"2025-03-10T09:00:00-04:00",
|
||||
]
|
||||
assert {inst["start"]["timeZone"] for inst in instances} == {"America/New_York"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_only_within_query_range(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=30"],
|
||||
)
|
||||
|
||||
# Query only June 5-7
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-05T00:00:00Z",
|
||||
timeMax="2025-06-08T00:00:00Z",
|
||||
)
|
||||
daily = [e for e in result["events"] if "Daily" in e["summary"]]
|
||||
assert len(daily) == 3 # June 5, 6, 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monthly_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Monthly Review",
|
||||
start={"dateTime": "2025-01-15T10:00:00Z"},
|
||||
end={"dateTime": "2025-01-15T11:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=MONTHLY;COUNT=6"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-07-01T00:00:00Z",
|
||||
)
|
||||
reviews = [e for e in result["events"] if "Monthly Review" in e["summary"]]
|
||||
assert len(reviews) == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_with_until(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Until Event",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;UNTIL=2025-06-04T00:00:00Z"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
events = [e for e in result["events"] if "Until Event" in e["summary"]]
|
||||
assert len(events) == 3 # June 1, 2, 3 (UNTIL is exclusive-ish)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_recurrence_via_update(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Was Recurring",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
# Remove recurrence by passing empty list
|
||||
await gc.update_event(eventId=event_id, recurrence=[])
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
matching = [e for e in listed["events"] if "Was Recurring" in e["summary"]]
|
||||
assert len(matching) == 1 # Just the single event now
|
||||
|
||||
|
||||
class TestRecurringAvailability:
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_events_block_availability(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T10:00:00Z",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Daily Standup" in result["busy"][0]["events"]
|
||||
assert result["total_busy_minutes"] == 15
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Event schema enforces the supported Google Calendar mock shape."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
Calendar,
|
||||
CalendarPerson,
|
||||
CalendarState,
|
||||
Event,
|
||||
EventAttendee,
|
||||
EventDateTime,
|
||||
)
|
||||
from google_calendar.tools.search import _parse_event_end, _parse_event_start
|
||||
|
||||
|
||||
def _minimal_event(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"id": "evt-1",
|
||||
"summary": "s",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_event_accepts_payload_without_created_and_updated():
|
||||
event = Event.model_validate(_minimal_event())
|
||||
assert event.created is None
|
||||
assert event.updated is None
|
||||
|
||||
|
||||
def test_event_rejects_unmodeled_extra_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(conferenceData={"entryPoints": []}))
|
||||
|
||||
|
||||
def test_event_coerces_single_recurrence_string_to_list():
|
||||
event = Event.model_validate(_minimal_event(recurrence="RRULE:FREQ=WEEKLY"))
|
||||
assert event.recurrence == ["RRULE:FREQ=WEEKLY"]
|
||||
|
||||
|
||||
def test_event_recurrence_requires_supported_prefix():
|
||||
for recurrence in ["RRULE:FREQ=WEEKLY", "EXRULE:FREQ=WEEKLY", "RDATE:20250601", "EXDATE:20250601"]:
|
||||
assert Event.model_validate(_minimal_event(recurrence=[recurrence]))
|
||||
|
||||
for recurrence in ["FREQ=WEEKLY", "BAD:FREQ=WEEKLY"]:
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(recurrence=[recurrence]))
|
||||
|
||||
|
||||
def test_calendar_state_round_trips_legacy_event():
|
||||
state = CalendarState.model_validate({"events": {"evt-1": _minimal_event(recurrence="RRULE:FREQ=DAILY")}})
|
||||
dumped = state.model_dump(mode="json", exclude_none=True)
|
||||
reloaded = CalendarState.model_validate(dumped)
|
||||
assert reloaded == state
|
||||
|
||||
|
||||
def test_calendar_state_round_trips_multi_calendar_state():
|
||||
state = CalendarState.model_validate(
|
||||
{
|
||||
"timeZone": "America/New_York",
|
||||
"events": {},
|
||||
"calendars": {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"timeZone": "Europe/London",
|
||||
"events": {"evt-1": _minimal_event()},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
dumped = state.model_dump(mode="json", exclude_none=True)
|
||||
assert dumped["timeZone"] == "America/New_York"
|
||||
assert dumped["calendars"]["work"]["timeZone"] == "Europe/London"
|
||||
assert dumped["calendars"]["work"]["events"]["evt-1"]["id"] == "evt-1"
|
||||
assert CalendarState.model_validate(dumped) == state
|
||||
|
||||
|
||||
def test_calendar_state_rejects_explicit_primary_calendar():
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate(
|
||||
{
|
||||
"events": {"evt-1": _minimal_event()},
|
||||
"calendars": {
|
||||
"primary": {
|
||||
"summary": "Primary",
|
||||
"events": {"evt-2": _minimal_event(id="evt-2")},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_calendar_events_must_be_keyed_by_event_id():
|
||||
with pytest.raises(ValidationError):
|
||||
Calendar.model_validate(
|
||||
{
|
||||
"summary": "Work",
|
||||
"events": {"wrong-key": _minimal_event(id="evt-1")},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate({"events": {"wrong-key": _minimal_event(id="evt-1")}})
|
||||
|
||||
|
||||
def test_calendar_time_zone_is_validated():
|
||||
assert Calendar.model_validate({"summary": "Work", "timeZone": "America/Los_Angeles"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Calendar.model_validate({"summary": "Work", "timeZone": "Not/AZone"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate({"timeZone": "Not/AZone"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"dateTime": "2025-06-01T10:00:00Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00-04:00"},
|
||||
{"dateTime": "2025-06-01T10:00:00.123Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00", "timeZone": "America/New_York"},
|
||||
{"dateTime": "2025-06-01T10:00:00-04:00", "timeZone": "America/New_York"},
|
||||
{"date": "2025-06-01"},
|
||||
],
|
||||
)
|
||||
def test_event_datetime_accepts_google_calendar_shapes(payload):
|
||||
assert EventDateTime.model_validate(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"dateTime": "2025-06-01T10:00:00Z", "date": "2025-06-01"},
|
||||
{"dateTime": "2025-06-01 10:00:00Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00"},
|
||||
{"date": "2025-6-1"},
|
||||
{"date": "2025-02-31"},
|
||||
{"dateTime": "2025-06-01T10:00:00", "timeZone": "Not/AZone"},
|
||||
{"dateTime": "2025-06-01T10:00:00Z", "timeZone": "America/New_York"},
|
||||
],
|
||||
)
|
||||
def test_event_datetime_rejects_invalid_shapes(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
EventDateTime.model_validate(payload)
|
||||
|
||||
|
||||
def test_offsetless_datetime_uses_event_timezone():
|
||||
event = _minimal_event(
|
||||
start={"dateTime": "2025-06-01T10:00:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-06-01T11:00:00", "timeZone": "America/New_York"},
|
||||
)
|
||||
|
||||
parsed_start = _parse_event_start(event)
|
||||
|
||||
assert parsed_start is not None
|
||||
assert parsed_start.isoformat() == "2025-06-01T10:00:00-04:00"
|
||||
|
||||
|
||||
def test_legacy_all_day_same_day_end_is_normalized():
|
||||
event = _minimal_event(
|
||||
start={"date": "2025-06-01"},
|
||||
end={"date": "2025-06-01"},
|
||||
)
|
||||
|
||||
assert _parse_event_end(event) == datetime(2025, 6, 2, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_event_rejects_mixed_start_end_shapes():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(start={"dateTime": "2025-06-01T10:00:00Z"}, end={"date": "2025-06-02"}))
|
||||
|
||||
|
||||
def test_event_rejects_end_before_start():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_event_rejects_empty_id():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(id=""))
|
||||
|
||||
|
||||
def test_event_rejects_invalid_audit_timestamp():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(created="2025-06-01T10:00:00"))
|
||||
|
||||
|
||||
def test_event_transparency_is_typed():
|
||||
event = Event.model_validate(_minimal_event(transparency="transparent"))
|
||||
assert event.transparency == "transparent"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(transparency="busy-ish"))
|
||||
|
||||
|
||||
def test_from_gmail_events_default_to_transparent():
|
||||
event = Event.model_validate(_minimal_event(eventType="fromGmail"))
|
||||
|
||||
assert event.transparency == "transparent"
|
||||
|
||||
|
||||
def test_attendee_email_and_guest_count_are_validated():
|
||||
assert EventAttendee.model_validate({"email": "alice@example.com", "additionalGuests": 1})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EventAttendee.model_validate({"email": "not-an-email"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EventAttendee.model_validate({"email": "alice@example.com", "additionalGuests": -1})
|
||||
|
||||
|
||||
def test_event_rejects_duplicate_attendees_case_insensitively():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
attendees=[
|
||||
{"email": "alice@example.com"},
|
||||
{"email": "ALICE@example.com"},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_creator_and_organizer_are_typed_person_objects():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
creator={"email": "creator@example.com", "displayName": "Creator"},
|
||||
organizer={"email": "organizer@example.com", "displayName": "Organizer", "self": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.creator is not None
|
||||
assert event.creator.email == "creator@example.com"
|
||||
assert event.organizer is not None
|
||||
assert event.organizer.self is True
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarPerson.model_validate({"email": "not-an-email"})
|
||||
|
||||
|
||||
def test_extended_properties_and_source_are_typed():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
extendedProperties={
|
||||
"private": {"task_id": "task-123"},
|
||||
"shared": {"project": "Migration"},
|
||||
},
|
||||
source={"title": "Sprint planning notes"},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.extendedProperties is not None
|
||||
assert event.extendedProperties.private == {"task_id": "task-123"}
|
||||
assert event.source is not None
|
||||
assert event.source.title == "Sprint planning notes"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(extendedProperties={"private": {"task_id": {"nested": "no"}}}))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(source={"title": ""}))
|
||||
|
||||
|
||||
def test_extended_properties_reject_unmodeled_top_level_keys():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
extendedProperties={
|
||||
"private": {"task_id": "task-123"},
|
||||
"unexpected": {"not": "modeled"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_event_type_defaults_to_default():
|
||||
event = Event.model_validate(_minimal_event())
|
||||
assert event.eventType == "default"
|
||||
|
||||
|
||||
def test_focus_time_event_accepts_matching_properties():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="focusTime",
|
||||
focusTimeProperties={
|
||||
"autoDeclineMode": "declineOnlyNewConflictingInvitations",
|
||||
"chatStatus": "doNotDisturb",
|
||||
"declineMessage": "Focusing right now",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.focusTimeProperties is not None
|
||||
assert event.focusTimeProperties.chatStatus == "doNotDisturb"
|
||||
|
||||
|
||||
def test_event_type_rejects_missing_or_mismatched_properties():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(eventType="focusTime"))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(eventType="default", focusTimeProperties={"chatStatus": "available"}))
|
||||
|
||||
|
||||
def test_working_location_properties_match_location_type():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "customLocation", "customLocation": {"label": "Client office"}},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.workingLocationProperties is not None
|
||||
assert event.workingLocationProperties.type == "customLocation"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="workingLocation", workingLocationProperties={"type": "customLocation"})
|
||||
)
|
||||
|
||||
|
||||
def test_working_location_home_office_is_empty_marker():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "homeOffice", "homeOffice": {}},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.workingLocationProperties is not None
|
||||
assert event.workingLocationProperties.type == "homeOffice"
|
||||
assert event.workingLocationProperties.homeOffice is not None
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "homeOffice", "homeOffice": {"label": "Home"}},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_birthday_properties_validate_special_date_shape():
|
||||
Event.model_validate(_minimal_event(eventType="birthday", birthdayProperties={"type": "birthday"}))
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "anniversary", "contact": "people/c12345"})
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "self", "contact": "people/c12345"})
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "anniversary", "contact": "people/a"})
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Tests for event search behavior."""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
AttendeeResponseStatus,
|
||||
AvailabilityDurationMinutes,
|
||||
ListEventsMaxResults,
|
||||
Rfc3339OffsetDateTimeString,
|
||||
SearchEventsMaxResults,
|
||||
SearchEventsOrderBy,
|
||||
)
|
||||
|
||||
|
||||
def _get_gc():
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"events": {
|
||||
"evt-budget": {
|
||||
"id": "evt-budget",
|
||||
"summary": "Budget Review",
|
||||
"description": "Q3 planning with the finance team",
|
||||
"location": "Conference Room A",
|
||||
"start": {"dateTime": "2025-06-03T14:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-03T15:00:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-02T00:00:00Z",
|
||||
"creator": {"email": "manager@example.com", "displayName": "Budget Manager"},
|
||||
"organizer": {"email": "finance-lead@example.com", "displayName": "Finance Lead"},
|
||||
"attendees": [
|
||||
{
|
||||
"email": "alice@example.com",
|
||||
"displayName": "Alice Chen",
|
||||
"responseStatus": "accepted",
|
||||
},
|
||||
{"email": "bob@example.com", "displayName": "Bob Smith", "responseStatus": "declined"},
|
||||
],
|
||||
},
|
||||
"evt-design": {
|
||||
"id": "evt-design",
|
||||
"summary": "Design Sync",
|
||||
"description": "Review updated mobile mockups",
|
||||
"location": "Studio",
|
||||
"start": {"dateTime": "2025-06-05T16:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-05T17:00:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-04T00:00:00Z",
|
||||
"attendees": [
|
||||
{"email": "carol@example.com", "displayName": "Carol Lee", "responseStatus": "tentative"}
|
||||
],
|
||||
},
|
||||
"evt-naive": {
|
||||
"id": "evt-naive",
|
||||
"summary": "Naive Time Review",
|
||||
"description": "Stored without a timezone offset",
|
||||
"start": {"dateTime": "2025-06-07T10:00:00"},
|
||||
"end": {"dateTime": "2025-06-07T11:00:00"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-06T00:00:00Z",
|
||||
},
|
||||
"evt-broken": {
|
||||
"id": "evt-broken",
|
||||
"summary": "Broken Planning",
|
||||
"description": "Missing start and end should be reported when range-filtered",
|
||||
},
|
||||
"evt-standup": {
|
||||
"id": "evt-standup",
|
||||
"summary": "Daily Standup",
|
||||
"description": "Engineering status updates",
|
||||
"start": {"dateTime": "2025-06-01T09:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T09:15:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-03T00:00:00Z",
|
||||
"recurrence": ["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
},
|
||||
},
|
||||
"calendars": {
|
||||
"work": {
|
||||
"id": "work",
|
||||
"summary": "Work Calendar",
|
||||
"events": {
|
||||
"evt-roadmap": {
|
||||
"id": "evt-roadmap",
|
||||
"summary": "Roadmap Planning",
|
||||
"description": "Quarterly product planning",
|
||||
"location": "War Room",
|
||||
"start": {"dateTime": "2025-06-04T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-04T11:30:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-05T00:00:00Z",
|
||||
"creator": {"email": "product@example.com", "displayName": "Product"},
|
||||
"organizer": {"email": "dana@example.com", "displayName": "Dana Patel"},
|
||||
"attendees": [{"email": "dana@example.com", "displayName": "Dana Patel"}],
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_calendar"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_globals(calendar_data, outputdir):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
workspace.mkdir()
|
||||
state.set_agent_workspace(str(workspace))
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_all_query_terms_case_insensitively():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="budget finance")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
assert result["events"][0]["calendar_id"] == "primary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_supports_quoted_phrases():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query='"Conference Room A"')
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["summary"] == "Budget Review"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_quoted_phrases_do_not_span_field_boundaries():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query='"review q3"')
|
||||
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_attendee_query_text():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="carol@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-design"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_attendee_filter_can_search_without_keywords():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="", attendee_email="bob@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_response_status_for_attendee():
|
||||
gc = _get_gc()
|
||||
|
||||
accepted = await gc.search_events(
|
||||
query="", attendee_email="alice@example.com", response_status=AttendeeResponseStatus.ACCEPTED
|
||||
)
|
||||
assert accepted["count"] == 1
|
||||
assert accepted["events"][0]["id"] == "evt-budget"
|
||||
|
||||
declined = await gc.search_events(
|
||||
query="", attendee_email="alice@example.com", response_status=AttendeeResponseStatus.DECLINED
|
||||
)
|
||||
assert declined["events"] == []
|
||||
assert declined["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_response_status_without_attendee():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="", response_status=AttendeeResponseStatus.DECLINED)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
def test_search_events_response_status_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.search_events.__annotations__["response_status"] == AttendeeResponseStatus | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(AttendeeResponseStatus).validate_python("maybe")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_creator_and_organizer():
|
||||
gc = _get_gc()
|
||||
|
||||
by_creator = await gc.search_events(query="", creator_email="manager@example.com")
|
||||
assert by_creator["count"] == 1
|
||||
assert by_creator["events"][0]["id"] == "evt-budget"
|
||||
|
||||
by_organizer = await gc.search_events(query="", organizer_email="dana@example.com")
|
||||
assert by_organizer["count"] == 1
|
||||
assert by_organizer["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_creator_and_organizer_query_text():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="finance-lead@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_time_range_filters_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="planning",
|
||||
timeMin="2025-06-04T00:00:00Z",
|
||||
timeMax="2025-06-06T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_handles_naive_event_times_with_aware_ranges():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="naive",
|
||||
timeMin="2025-06-07T00:00:00Z",
|
||||
timeMax="2025-06-08T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-naive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_calendar_id_scopes_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", calendar_id="work")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
assert result["events"][0]["calendar_summary"] == "Work Calendar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_nonexistent_calendar_returns_empty_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", calendar_id="nonexistent")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_expands_recurring_events_with_time_range():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="standup",
|
||||
timeMin="2025-06-03T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 2
|
||||
assert [event["start"]["dateTime"] for event in result["events"]] == [
|
||||
"2025-06-03T09:00:00+00:00",
|
||||
"2025-06-04T09:00:00+00:00",
|
||||
]
|
||||
assert {event["recurringEventId"] for event in result["events"]} == {"evt-standup"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_expands_recurring_events_without_time_range():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="standup")
|
||||
|
||||
assert result["count"] == 5
|
||||
assert [event["start"]["dateTime"] for event in result["events"]] == [
|
||||
"2025-06-01T09:00:00+00:00",
|
||||
"2025-06-02T09:00:00+00:00",
|
||||
"2025-06-03T09:00:00+00:00",
|
||||
"2025-06-04T09:00:00+00:00",
|
||||
"2025-06-05T09:00:00+00:00",
|
||||
]
|
||||
assert {event["recurringEventId"] for event in result["events"]} == {"evt-standup"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_can_order_by_updated_and_limit_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", maxResults=1, orderBy=SearchEventsOrderBy.UPDATED)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_max_results_zero_returns_no_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", maxResults=0)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
def test_search_events_max_results_is_schema_non_negative():
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsMaxResults).validate_python(-1)
|
||||
|
||||
|
||||
def test_range_bounds_require_rfc3339_offset_in_schema():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.list_events.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.list_events.__annotations__["timeMax"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.search_events.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString | None
|
||||
assert gc.check_availability.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.check_availability.__annotations__["attendee_emails"] == list[EmailStr] | None
|
||||
assert gc.check_availability.__annotations__["duration_minutes"] == AvailabilityDurationMinutes
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(Rfc3339OffsetDateTimeString).validate_python("2025-06-01T00:00:00")
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(AvailabilityDurationMinutes).validate_python(0)
|
||||
|
||||
|
||||
def test_search_events_order_by_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.search_events.__annotations__["orderBy"] == SearchEventsOrderBy | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsOrderBy).validate_python("summary")
|
||||
|
||||
|
||||
def test_list_events_order_by_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.list_events.__annotations__["orderBy"] == SearchEventsOrderBy | None
|
||||
assert inspect.signature(gc.list_events).parameters["orderBy"].default is None
|
||||
assert gc.list_events.__annotations__["maxResults"] == ListEventsMaxResults | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsOrderBy).validate_python("summary")
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(ListEventsMaxResults).validate_python(-1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_reports_skipped_unparseable_events():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="planning",
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["skipped_unparseable"] == ["evt-broken"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_no_match_returns_empty_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="vendor escalation")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_requires_query_or_attendee_filter():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_reports_unsupported_query_operator():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="before:2024-01-01 planning")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 0
|
||||
assert result["warnings"] == [
|
||||
"Unsupported Calendar query operator 'before:'; use search_events parameters such as timeMin, timeMax, "
|
||||
"attendee_email, response_status, organizer_email, or creator_email."
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for the _snapshot_on_write decorator — final.json written after every write tool call."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_gc():
|
||||
"""Import the server module lazily so tests control its module globals."""
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
"""Seed a minimal calendar data file."""
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(json.dumps({"events": {}}))
|
||||
return data_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_calendar"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_globals(calendar_data, outputdir):
|
||||
"""Patch google_calendar module globals for isolated testing."""
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
workspace.mkdir()
|
||||
state.set_agent_workspace(str(workspace))
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Test Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert result["event"]["summary"] == "Test Event"
|
||||
assert final.exists(), "final.json must be written after create_event"
|
||||
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert len(snapshot["events"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Original",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
final = outputdir / "final.json"
|
||||
final.unlink() # isolate the update's snapshot
|
||||
|
||||
await gc.update_event(eventId=event_id, summary="Updated")
|
||||
|
||||
assert final.exists(), "final.json must be written after update_event"
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert snapshot["events"][event_id]["summary"] == "Updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="To Delete",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
final = outputdir / "final.json"
|
||||
final.unlink()
|
||||
|
||||
await gc.delete_event(eventId=event_id)
|
||||
|
||||
assert final.exists(), "final.json must be written after delete_event"
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert event_id not in snapshot["events"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_does_not_write_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
await gc.list_events(timeMin="2025-01-01T00:00:00Z", timeMax="2025-12-31T23:59:59Z")
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_does_not_write_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
await gc.search_events(query="test")
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_final_path_skips_snapshot():
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.set_snapshot_paths(final_path=None)
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="No Output",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert result["event"]["summary"] == "No Output"
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests for google_calendar utility functions."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from google_calendar.utils import get_calendar_data_path, load_events_from_json
|
||||
|
||||
|
||||
class TestGetCalendarDataPath:
|
||||
def test_computes_external_services_path(self):
|
||||
result = get_calendar_data_path("/workspace/dumps/workspace")
|
||||
assert result == Path("/workspace/dumps/external_services/calendar_data.json")
|
||||
|
||||
def test_path_is_sibling_to_workspace(self):
|
||||
result = get_calendar_data_path("/a/b/workspace")
|
||||
assert result.parent == Path("/a/b/external_services")
|
||||
|
||||
|
||||
class TestLoadEventsFromJson:
|
||||
def test_loads_events_and_adds_timestamps(self, tmp_path):
|
||||
data = {"events": {"evt1": {"summary": "Meeting"}}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
|
||||
assert "evt1" in result["events"]
|
||||
assert result["events"]["evt1"]["id"] == "evt1"
|
||||
assert "created" in result["events"]["evt1"]
|
||||
assert "updated" in result["events"]["evt1"]
|
||||
|
||||
def test_wraps_bare_dict_in_events(self, tmp_path):
|
||||
data = {"evt1": {"summary": "Meeting"}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
assert "events" in result
|
||||
assert "evt1" in result["events"]
|
||||
|
||||
def test_preserves_existing_timestamps(self, tmp_path):
|
||||
data = {"events": {"evt1": {"summary": "Meeting", "created": "2024-01-01", "updated": "2024-01-01"}}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
assert result["events"]["evt1"]["created"] == "2024-01-01"
|
||||
Reference in New Issue
Block a user