Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
@@ -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()">&#8249;</button>
<div class="week-label" id="week-label"></div>
<button class="nav-btn" onclick="nextWeek()">&#8250;</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()">&#x2715;</button>' +
'</div>';
html += '<div class="modal-body">';
html += '<div class="modal-row"><span class="modal-icon">&#128197;</span><span class="modal-text">' + timeStr + '</span></div>';
if (ev.location) {
html += '<div class="modal-row"><span class="modal-icon">&#128205;</span><span class="modal-text">' + esc(ev.location) + '</span></div>';
}
if (ev.description) {
html += '<div class="modal-row"><span class="modal-icon">&#128221;</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
+72
View File
@@ -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"