diff --git a/google/google-calendar-mcp/.python-version b/google/google-calendar-mcp/.python-version new file mode 100644 index 000000000..24ee5b1be --- /dev/null +++ b/google/google-calendar-mcp/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/google/google-calendar-mcp/README.md b/google/google-calendar-mcp/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/google/google-calendar-mcp/client.py b/google/google-calendar-mcp/client.py new file mode 100644 index 000000000..19f5c8c4e --- /dev/null +++ b/google/google-calendar-mcp/client.py @@ -0,0 +1,18 @@ +import asyncio +from fastmcp import Client + + +async def example(): + async with Client("http://127.0.0.1:9000/mcp/google-drive") as client: + res = await client.ping() + res = await client.list_tools() + for r in res: + print(r.name) + # rres = await client.call_tool( + # name="get_weather", + # arguments={"city": "New York", "cred_token": "1234567890"}, + # ) + # print(rres) + +if __name__ == "__main__": + asyncio.run(example()) \ No newline at end of file diff --git a/google/google-calendar-mcp/pyproject.toml b/google/google-calendar-mcp/pyproject.toml new file mode 100644 index 000000000..75c3ba24c --- /dev/null +++ b/google/google-calendar-mcp/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "google-calendar-mcp" +version = "0.1.0" +description = "Obot's google calendar mcp server" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fastmcp>=2.8.0", + "google>=3.0.0", + "google-api-python-client>=2.172.0", + "rfc3339-validator>=0.1.4", +] diff --git a/google/google-calendar-mcp/server.py b/google/google-calendar-mcp/server.py new file mode 100644 index 000000000..394e2129b --- /dev/null +++ b/google/google-calendar-mcp/server.py @@ -0,0 +1,710 @@ +from fastmcp import FastMCP +from pydantic import Field +from typing import Annotated, Literal +import os +from tools.helper import setup_logger, get_client, get_user_timezone +from googleapiclient.errors import HttpError +from fastmcp.exceptions import ToolError +from rfc3339_validator import validate_rfc3339 +from tools.event import ( + DEFAULT_MAX_RESULTS, + GOOGLE_EVENT_TYPE_OPTIONS, + MOVABLE_EVENT_TYPES, + get_current_time_rfc3339, + validate_recurrence_list, + is_valid_date, + is_valid_iana_timezone, + get_current_user_email, + can_update_property, + has_calendar_write_access, +) +logger = setup_logger(__name__) + +# Configure server-specific settings +PORT = os.getenv("PORT", 9000) +MCP_PATH = os.getenv("MCP_PATH", "/mcp/google-drive") + +mcp = FastMCP( + name="GoogleDriveMCPServer", + on_duplicate_tools="error", # Handle duplicate registrations + on_duplicate_resources="warn", + on_duplicate_prompts="replace", + mask_error_details=True, # only include details for ToolError and convert other errors to generic error\ +) + +@mcp.tool( + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + }, + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def list_calendars(cred_token: str = None) -> list: + """Lists all calendars for the authenticated user.""" + try: + service = get_client(cred_token) + calendars = service.calendarList().list().execute() + return calendars.get("items", []) + except HttpError as err: + raise ToolError(f"Failed to list google calendars. HttpError: {err}") + + +@mcp.tool( + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + }, + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def get_calendar(calendar_id: Annotated[str, Field(description="calendar id to get")], + cred_token: str = None) -> dict: + """Get details of a specific google calendar""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + service = get_client(cred_token) + try: + calendar = service.calendars().get(calendarId=calendar_id).execute() + return calendar + except HttpError as err: + raise ToolError(f"Failed to get google calendar. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to get google calendar. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def create_calendar(summary: Annotated[str, Field(description="calendar title to create")], + time_zone: Annotated[str | None, Field(description="calendar timezone to create")] = None, + cred_token: str = None) -> dict: + """Creates a new google calendar.""" + if summary == "": + raise ValueError(f"argument `summary` can't be empty") + service = get_client(cred_token) + if time_zone is None: + time_zone = get_user_timezone(service) + elif not is_valid_iana_timezone(time_zone): + raise ValueError( + f"Invalid time_zone: {time_zone}. It must be a valid IANA timezone string." + ) + + calendar_body = {"summary": summary, "timeZone": time_zone} + try: + created_calendar = service.calendars().insert(body=calendar_body).execute() + return created_calendar + except HttpError as err: + raise ToolError(f"Failed to create google calendar. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to create google calendar. Exception: {e}") + + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def update_calendar(calendar_id: Annotated[str, Field(description="calendar id to update")], + summary: Annotated[str | None, Field(description="calendar title to update")] = None, + time_zone: Annotated[str | None, Field(description="calendar timezone to update")] = None, + description: Annotated[str | None, Field(description="calendar description to update")] = None, + location: Annotated[str | None, Field(description="Geographic location of the calendar as free-form text to update")] = None, + cred_token: str = None) -> dict: + """Updates an existing google calendar""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + service = get_client(cred_token) + try: + calendar = service.calendars().get(calendarId=calendar_id).execute() + if summary: + calendar["summary"] = summary + if time_zone: + calendar["timeZone"] = time_zone + if description: + calendar["description"] = description + if location: + calendar["location"] = location + + updated_calendar = ( + service.calendars().update(calendarId=calendar_id, body=calendar).execute() + ) + return updated_calendar + except HttpError as err: + raise ToolError(f"Failed to update google calendar. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to update google calendar. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def delete_calendar(calendar_id: Annotated[str, Field(description="calendar id to delete")], + cred_token: str = None) -> str: + """Deletes a google calendar""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + service = get_client(cred_token) + try: + service.calendars().delete(calendarId=calendar_id).execute() + return f"Google calendar {calendar_id} deleted successfully." + except HttpError as err: + raise ToolError(f"Failed to delete google calendar. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to delete google calendar. Exception: {e}") + + +@mcp.tool( + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + }, + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def list_events(calendar_id: Annotated[str, Field(description="calendar id")], + event_type: Annotated[Literal["birthday", "default", "focusTime", "fromGmail", "outOfOffice", "workingLocation"], Field(description="The type of event to list. Defaults to 'default'")] = "default", + single_event: Annotated[bool, Field(description="Whether to list only single event.")] = False, + time_min: Annotated[str | None, Field(description="Upper bound (exclusive) for an event's start time to filter by. if both time_min and time_max are None, time_min will be set to the current time. Must be an RFC3339 timestamp with mandatory time zone offset.")] = None, + time_max: Annotated[str | None, Field(description="Lower bound (exclusive) for an event's end time to filter by. Must be an RFC3339 timestamp with mandatory time zone offset.")] = None, + order_by: Annotated[Literal["updated"] | None, Field(description="Order by which to sort events. Valid options are: updated. If set, results will be returned in ascending order.")] = None, + q: Annotated[str | None, Field(description="Free text search terms to find events by")] = None, + max_results: Annotated[int, Field(description="Maximum number of events to return.", ge=1, le=500)] = 250, + cred_token: str = None) -> list: + """Lists events for a specific google calendar.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + service = get_client(cred_token) + + # optional parameters + params = {} + params["singleEvents"] = single_event + params["eventTypes"] = event_type + if time_min: + if not validate_rfc3339(time_min): + raise ValueError( + f"Invalid time_min: {time_min}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + params["timeMin"] = time_min + if time_max: + if not validate_rfc3339(time_max): + raise ValueError( + f"Invalid time_max: {time_max}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + params["timeMax"] = time_max + + if ( + not time_min and not time_max + ): # if no time_min or time_max is provided, default to the current time, so it will list all upcoming events + time_min = get_current_time_rfc3339() + params["timeMin"] = time_min + + if order_by: + params["orderBy"] = order_by + + q = os.getenv("Q") + if q: + params["q"] = q + + # Filter out None or empty values + params = {k: v for k, v in params.items() if v not in [None, ""]} + + max_results_to_return = max_results + + try: + page_token = None + all_events = [] + while True: + events_result = ( + service.events() + .list(calendarId=calendar_id, **params, pageToken=page_token) + .execute() + ) + events_result_list = events_result.get("items", []) + if len(events_result_list) >= max_results_to_return: + all_events.extend(events_result_list[:max_results_to_return]) + break + else: + all_events.extend(events_result_list) + max_results_to_return -= len(events_result_list) + + page_token = events_result.get("nextPageToken") + if not page_token: + break + return all_events + except HttpError as err: + raise ToolError(f"Failed to list events from calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to list events from calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + }, + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def get_event(calendar_id: Annotated[str, Field(description="calendar id to get event from")], + event_id: Annotated[str, Field(description="event id to get")], + cred_token: str = None) -> dict: + """Gets details of a specific google event.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + service = get_client(cred_token) + + try: + event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() + return event + except HttpError as err: + raise ToolError(f"Failed to get event {event_id} from calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to get event {event_id} from calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def move_event(calendar_id: Annotated[str, Field(description="calendar id to move event from")], + event_id: Annotated[str, Field(description="event id to move")], + new_calendar_id: Annotated[str, Field(description="calendar id to move the event to")], + cred_token: str = None) -> dict: + """Moves an event to a different google calendar.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + if new_calendar_id == "": + raise ValueError("argument `new_calendar_id` can't be empty") + service = get_client(cred_token) + + try: + existing_event = ( + service.events().get(calendarId=calendar_id, eventId=event_id).execute() + ) + if ( + existing_event_type := existing_event.get("eventType") + ) not in MOVABLE_EVENT_TYPES: + raise ValueError( + f"Events with type '{existing_event_type}' can not be moved." + ) + + event = ( + service.events() + .move(calendarId=calendar_id, eventId=event_id, destination=new_calendar_id) + .execute() + ) + return event + except HttpError as err: + raise ToolError(f"Failed to move event {event_id} to calendar {new_calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to move event {event_id} to calendar {new_calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def quick_add_event(text: Annotated[str, Field(description="The text of the event to add")], + calendar_id: Annotated[str, Field(description="The ID of the calendar to add event for")] = "primary", + cred_token: str = None) -> dict: + """Quickly adds an event to the calendar based on a simple text string.""" + if text == "": + raise ValueError("argument `text` can't be empty") + service = get_client(cred_token) + + try: + event = service.events().quickAdd(calendarId=calendar_id, text=text).execute() + return event + except HttpError as err: + raise ToolError(f"Failed to quick add event to calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to quick add event to calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def create_event(calendar_id: Annotated[str, Field(description="Calendar id to create event in. Set to `primary` to create event in the primary calendar")], + summary: Annotated[str, Field(description="Event title")] = "My Event", + location: Annotated[str, Field(description="Geographic location of the event as free-form text.")] = "", + description: Annotated[str, Field(description="Event description")] = "", + time_zone: Annotated[str | None, Field(description="Event time zone to create. Defaults to the user's timezone. Must be a valid IANA timezone string")] = None, + start_date: Annotated[str | None, Field(description="Event start date in the format 'yyyy-mm-dd', only used if this is an all-day event")] = None, + start_datetime: Annotated[str | None, Field(description="Event start date and time to create. Must be a valid RFC 3339 formatted date/time string. A time zone offset is required unless a time zone is explicitly specified in timeZone")] = None, + end_date: Annotated[str | None, Field(description="Event end date in the format 'yyyy-mm-dd', only used if this is an all-day event")] = None, + end_datetime: Annotated[str | None, Field(description="Event end date and time to create. Must be a valid RFC 3339 formatted date/time string. A time zone offset is required unless a time zone is explicitly specified in timeZone")] = None, + recurrence: Annotated[list[str] | None, Field(description='To create a recurring event, provide a list of strings, where each string is an RRULE, EXRULE, RDATE, or EXDATE line as defined by the RFC5545. For example, ["RRULE:FREQ=YEARLY", "EXDATE:20250403T100000Z"]. Note that DTSTART and DTEND are not allowed in this field, because they are already specified in the start_datetime and end_datetime fields.')] = None, + attendees: Annotated[list[str] | None, Field(description="A list of email addresses of the attendees")] = None, + cred_token: str = None) -> dict: + """Creates an event in a given google calendar.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + service = get_client(cred_token) + if time_zone is None: + time_zone = get_user_timezone(service) + elif not is_valid_iana_timezone(time_zone): + raise ValueError( + f"Invalid time_zone: {time_zone}. It must be a valid IANA timezone string." + ) + + start = {} + if start_date: + if not is_valid_date(start_date): + raise ValueError( + f"Invalid start_date: {start_date}. It must be a valid date string in the format YYYY-MM-DD." + ) + start["date"] = start_date + elif start_datetime: + if not validate_rfc3339(start_datetime): + raise ValueError( + f"Invalid start_datetime: {start_datetime}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + start["dateTime"] = start_datetime + else: + raise ValueError("Either start_date or start_datetime must be provided.") + start["timeZone"] = time_zone + + end = {} + if end_date: + if not is_valid_date(end_date): + raise ValueError( + f"Invalid end_date: {end_date}. It must be a valid date string in the format YYYY-MM-DD." + ) + end["date"] = end_date + elif end_datetime: + if not validate_rfc3339(end_datetime): + raise ValueError( + f"Invalid end_datetime: {end_datetime}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + end["dateTime"] = end_datetime + else: + raise ValueError("Either end_date or end_datetime must be provided.") + end["timeZone"] = time_zone + + event_body = { + "summary": summary, + "location": location, + "description": description, + "start": start, + "end": end, + "reminders": { + "useDefault": True, # TODO: make this configurable + }, + } + + if recurrence: + event_body["recurrence"] = validate_recurrence_list(recurrence) + + if attendees: + event_body["attendees"] = attendees + + try: + event = ( + service.events().insert(calendarId=calendar_id, body=event_body).execute() + ) + return event + except HttpError as err: + raise ToolError(f"Failed to create event in calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to create event in calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def update_event(calendar_id: Annotated[str, Field(description="Calendar id to update event in.")], + event_id: Annotated[str, Field(description="Event id to update")], + summary: Annotated[str | None, Field(description="Event title")] = None, + location: Annotated[str | None, Field(description="Geographic location of the event as free-form text.")] = None, + description: Annotated[str | None, Field(description="Event description")] = None, + time_zone: Annotated[str | None, Field(description="Event time zone to update. Defaults to the user's timezone. Must be a valid IANA timezone string")] = None, + start_date: Annotated[str | None, Field(description="Event date in the format 'yyyy-mm-dd', only used if this is an all-day event")] = None, + start_datetime: Annotated[str | None, Field(description="Event start date and time to update. Must be a valid RFC 3339 formatted date/time string. A time zone offset is required unless a time zone is explicitly specified in timeZone")] = None, + end_date: Annotated[str | None, Field(description="Event end date in the format 'yyyy-mm-dd', only used if this is an all-day event")] = None, + end_datetime: Annotated[str | None, Field(description="Event end date and time to update. Must be a valid RFC 3339 formatted date/time string. A time zone offset is required unless a time zone is explicitly specified in timeZone")] = None, + recurrence: Annotated[list[str] | None, Field(description='For a recurring event, provide a list of strings, where each string is an RRULE, EXRULE, RDATE, or EXDATE line as defined by the RFC5545. For example, ["RRULE:FREQ=YEARLY", "EXDATE:20250403T100000Z"]. Note that DTSTART and DTEND are not allowed in this field, because they are already specified in the start_datetime and end_datetime fields.')] = None, + add_attendees: Annotated[list[str] | None, Field(description="A list of email addresses of the attendees to add. This will add the new attendees to the existing attendees list")] = None, + replace_attendees: Annotated[list[str] | None, Field(description="A list of email addresses of the attendees to replace. This is only valid when add_attendees is empty. This will replace the existing attendees list with the new list")] = None, + cred_token: str = None) -> dict: + """Updates an existing google calendar event.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + service = get_client(cred_token) + + try: + existing_event = ( + service.events().get(calendarId=calendar_id, eventId=event_id).execute() + ) + existing_event_type = existing_event.get("eventType") + except HttpError as err: + raise ToolError(f"Failed to get event {event_id} from calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to get event {event_id} from calendar {calendar_id}. Exception: {e}") + + def return_field_update_error(field: str, event_type: str): + return f"Operation Forbidden: Updating property '{field}' for event type '{event_type}' is not allowed." + + event_body = {} + if summary: + if not can_update_property(existing_event_type, "summary"): + return return_field_update_error("summary", existing_event_type) + + event_body["summary"] = summary + + if location: + if not can_update_property(existing_event_type, "location"): + return return_field_update_error("location", existing_event_type) + event_body["location"] = location + + if description: + if not can_update_property(existing_event_type, "description"): + return return_field_update_error("description", existing_event_type) + event_body["description"] = description + + if time_zone and not is_valid_iana_timezone(time_zone): + raise ValueError( + f"Invalid time_zone: {time_zone}. It must be a valid IANA timezone string." + ) + + start = {} + if not can_update_property(existing_event_type, "start"): + return return_field_update_error("start", existing_event_type) + + if start_date: + if not is_valid_date(start_date): + raise ValueError( + f"Invalid start_date: {start_date}. It must be a valid date string in the format YYYY-MM-DD." + ) + start["date"] = start_date + elif start_datetime: + if not validate_rfc3339(start_datetime): + raise ValueError( + f"Invalid start_datetime: {start_datetime}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + start["dateTime"] = start_datetime + if time_zone: + start["timeZone"] = time_zone + if start != {}: + event_body["start"] = start + + end = {} + if not can_update_property(existing_event_type, "end"): + return return_field_update_error("end", existing_event_type) + + if end_date: + if not is_valid_date(end_date): + raise ValueError( + f"Invalid end_date: {end_date}. It must be a valid date string in the format YYYY-MM-DD." + ) + end["date"] = end_date + elif end_datetime: + if not validate_rfc3339(end_datetime): + raise ValueError( + f"Invalid end_datetime: {end_datetime}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + end["dateTime"] = end_datetime + if time_zone: + end["timeZone"] = time_zone + if end != {}: + event_body["end"] = end + + if recurrence: + if not can_update_property(existing_event_type, "recurrence"): + return return_field_update_error("recurrence", existing_event_type) + + if validate_recurrence_list(recurrence): + event_body["recurrence"] = recurrence + + if add_attendees or replace_attendees: + if not can_update_property(existing_event_type, "attendees"): + return return_field_update_error("attendees", existing_event_type) + + existing_attendees = existing_event.get("attendees", []) + existing_attendee_map = { + a["email"]: a for a in existing_attendees if "email" in a + } + final_attendees = [] + + if add_attendees: + # ADD mode takes priority if both are present + new_emails = { + email.strip() for email in add_attendees if email.strip() + } + existing_emails = set(existing_attendee_map.keys()) + + final_attendees = existing_attendees.copy() # preserve full metadata + + for email in new_emails: + if email not in existing_emails: + final_attendees.append({"email": email}) + + elif replace_attendees: + new_emails = { + email.strip() for email in replace_attendees if email.strip() + } + for email in new_emails: + if email in existing_attendee_map: + final_attendees.append(existing_attendee_map[email]) + else: + final_attendees.append({"email": email}) + + event_body["attendees"] = final_attendees + + try: + existing_event_type = existing_event.get("eventType") + + if not has_calendar_write_access(service, calendar_id): + raise PermissionError("You do not have write access to this calendar.") + + existing_event.update(event_body) + + updated_event = ( + service.events() + .update(calendarId=calendar_id, eventId=event_id, body=existing_event) + .execute() + ) + return updated_event + except HttpError as err: + raise ToolError(f"Failed to update event {event_id} in calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to update event {event_id} in calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def respond_to_event(calendar_id: Annotated[str, Field(description="Calendar id to respond to event in.")], + event_id: Annotated[str, Field(description="Event id to respond to")], + response: Annotated[Literal["accepted", "declined", "tentative"], Field(description="The response to the event invitation")], + cred_token: str = None) -> dict: + """Responds to a calendar event by updating the current user's attendee status.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + service = get_client(cred_token) + + try: + # Get current user's email + user_email = get_current_user_email(service) + + event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() + + # Only update the responseStatus for the current user + updated = False + for attendee in event.get("attendees", []): + if attendee["email"].lower() == user_email.lower(): + attendee["responseStatus"] = response + updated = True + break + + if not updated: + raise ValueError( + f"User {user_email} is not listed as an attendee on this event." + ) + + updated_event = ( + service.events() + .patch( + calendarId=calendar_id, + eventId=event_id, + body={"attendees": event["attendees"]}, + ) + .execute() + ) + + return updated_event + + except HttpError as err: + raise ToolError(f"Failed to respond to event {event_id} in calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to respond to event {event_id} in calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def delete_event(calendar_id: Annotated[str, Field(description="Calendar id to delete event from.")], + event_id: Annotated[str, Field(description="Event id to delete")], + cred_token: str = None) -> dict: + """Deletes an event from the calendar.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + service = get_client(cred_token) + + try: + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + return f"Event {event_id} deleted successfully." + except HttpError as err: + raise ToolError(f"Failed to delete event {event_id} in calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to delete event {event_id} in calendar {calendar_id}. Exception: {e}") + + +@mcp.tool( + exclude_args=["cred_token"], # the access token, is excluded to LLM +) +def list_recurring_event_instances(calendar_id: Annotated[str, Field(description="Calendar id to list recurring event instances from.")], + event_id: Annotated[str, Field(description="Event id to list recurring event instances for")], + time_min: Annotated[str | None, Field(description="Upper bound (exclusive) for an event's start time to filter by. Must be an RFC3339 timestamp with mandatory time zone offset.")] = None, + time_max: Annotated[str | None, Field(description="Lower bound (exclusive) for an event's end time to filter by. Must be an RFC3339 timestamp with mandatory time zone offset.")] = None, + max_results: Annotated[int, Field(description="Maximum number of events to return.", ge=1, le=500)] = 250, + cred_token: str = None) -> list: + """Gets all instances of a recurring event.""" + if calendar_id == "": + raise ValueError("argument `calendar_id` can't be empty") + if event_id == "": + raise ValueError("argument `event_id` can't be empty") + service = get_client(cred_token) + + params = {} + if time_min: + if not validate_rfc3339(time_min): + raise ValueError( + f"Invalid time_min: {time_min}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + params["timeMin"] = time_min + if time_max: + if not validate_rfc3339(time_max): + raise ValueError( + f"Invalid time_max: {time_max}. It must be a valid RFC 3339 formatted date/time string, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z" + ) + params["timeMax"] = time_max + + try: + page_token = None + all_instances = [] + while True: + instances = ( + service.events() + .instances( + calendarId=calendar_id, eventId=event_id, pageToken=page_token + ) + .execute() + ) + instances_result_list = instances.get("items", []) + if len(instances_result_list) >= max_results: + all_instances.extend(instances_result_list[:max_results]) + break + else: + all_instances.extend(instances_result_list) + max_results_to_return -= len(instances_result_list) + page_token = instances.get("nextPageToken") + if not page_token: + break + return all_instances + except HttpError as err: + raise ToolError(f"Failed to list recurring event instances for event {event_id} in calendar {calendar_id}. HttpError: {err}") + except Exception as e: + raise ToolError(f"Failed to list recurring event instances for event {event_id} in calendar {calendar_id}. Exception: {e}") + + +if __name__ == "__main__": + mcp.run( + transport="streamable-http", # fixed to streamable-http + host="0.0.0.0", + port=PORT, + path=MCP_PATH, + ) \ No newline at end of file diff --git a/google/google-calendar-mcp/tools/event.py b/google/google-calendar-mcp/tools/event.py new file mode 100644 index 000000000..922173830 --- /dev/null +++ b/google/google-calendar-mcp/tools/event.py @@ -0,0 +1,277 @@ +from datetime import datetime, timezone +from tools.helper import ( + setup_logger, + get_obot_user_timezone, +) +from googleapiclient.errors import HttpError +from zoneinfo import available_timezones, ZoneInfo + +logger = setup_logger(__name__) + +DEFAULT_MAX_RESULTS = 250 +GOOGLE_EVENT_TYPE_OPTIONS = [ + "birthday", + "default", + "focusTime", + "fromGmail", + "outOfOffice", + "workingLocation", +] +MOVABLE_EVENT_TYPES = ["default"] +CALENDAR_EVENT_TYPE_RULES = { + "default": { + "fully_updatable": True, + "updatable_properties": [ + "summary", + "description", + "location", + "start", + "end", + "attendees", + "recurrence", + "reminders", + "colorId", + "visibility", + "transparency", + "status", + "extendedProperties", + "attachments", + "guestsCanInviteOthers", + "guestsCanModify", + "guestsCanSeeOtherGuests", + "source", + "sequence", + # All standard properties can be updated + ], + "restrictions": [], + "notes": "Most flexible event type with virtually no restrictions on updates.", + }, + "fromGmail": { + "fully_updatable": False, + "updatable_properties": [], + "restrictions": [ + "Cannot create new fromGmail events via the API", + "Cannot change the organizer", + "Cannot modify core properties like summary, description, location, or times", + ], + "notes": "Limited to updating UI and preference properties only.", + }, + "birthday": { + "fully_updatable": False, + "updatable_properties": [ + "colorId", + "summary", + "reminders", + "start", + "end", + ], + "restrictions": [ + "Cannot modify birthdayProperties", + "Start/end time updates must remain all-day events spanning exactly one day", + "Timing updates are restricted if linked to a contact", + "Cannot change the organizer", + "Cannot create custom birthday properties via the API", + ], + "notes": "Use People API for comprehensive contact birthday management.", + }, + "focusTime": { + "fully_updatable": False, + "updatable_properties": [ + # Standard properties + "summary", + "description", + "start", + "end", + "reminders", + "colorId", + "visibility", + "transparency", + # Focus time specific properties + "focusTimeProperties", + "focusTimeProperties.autoDeclineMode", + "focusTimeProperties.chatStatus", + "focusTimeProperties.declineMessage", + ], + "restrictions": [ + "Only available on primary calendars", + "Only for specific Google Workspace users", + "Cannot be created on secondary calendars", + ], + "notes": "Used for dedicated focus periods.", + }, + "outOfOffice": { + "fully_updatable": False, + "updatable_properties": [ + # Standard properties + "summary", + "description", + "start", + "end", + "reminders", + "colorId", + "visibility", + "transparency", + # Out of office specific properties + "outOfOfficeProperties", + "outOfOfficeProperties.autoDeclineMode", + "outOfOfficeProperties.declineMessage", + ], + "restrictions": [ + "Only available on primary calendars", + "Only for specific Google Workspace users", + "Cannot be created on secondary calendars", + ], + "notes": "Represents time away from work.", + }, + "workingLocation": { + "fully_updatable": False, + "updatable_properties": [ + # Standard properties + "summary", + "description", + "start", + "end", + "reminders", + "colorId", + "visibility", + "transparency", + # Working location specific properties + "workingLocationProperties", + "workingLocationProperties.type", + "workingLocationProperties.homeOffice", + "workingLocationProperties.customLocation", + "workingLocationProperties.customLocation.label", + "workingLocationProperties.officeLocation", + "workingLocationProperties.officeLocation.buildingId", + "workingLocationProperties.officeLocation.floorId", + "workingLocationProperties.officeLocation.floorSectionId", + "workingLocationProperties.officeLocation.deskId", + "workingLocationProperties.officeLocation.label", + ], + "restrictions": [ + "Only available on primary calendars", + "Only for specific Google Workspace users", + "Cannot be created on secondary calendars", + ], + "notes": "Indicates where someone is working.", + }, +} + + +def can_update_property(event_type, property_name): + """ + Check if a specific property can be updated for a given event type. + + Args: + event_type (str): The event type ('default', 'fromGmail', etc.) + property_name (str): The property to check + + Returns: + bool: True if the property can be updated, False otherwise + """ + if event_type not in CALENDAR_EVENT_TYPE_RULES: + raise ValueError(f"Unknown event type: {event_type}") + + # Default events can update all standard properties + if event_type == "default": + return True + + # For other event types, check the specific list + return ( + property_name in CALENDAR_EVENT_TYPE_RULES[event_type]["updatable_properties"] + ) + + +def _get_event_type_restrictions(event_type): + """ + Get the list of restrictions for a given event type. + + Args: + event_type (str): The event type ('default', 'fromGmail', etc.) + + Returns: + list: List of restriction strings + """ + if event_type not in CALENDAR_EVENT_TYPE_RULES: + raise ValueError(f"Unknown event type: {event_type}") + + return CALENDAR_EVENT_TYPE_RULES[event_type]["restrictions"] + + +def _get_updatable_properties(event_type): + if event_type not in CALENDAR_EVENT_TYPE_RULES: + raise ValueError(f"Unknown event type: {event_type}") + + return CALENDAR_EVENT_TYPE_RULES[event_type]["updatable_properties"] + + +# Private helper functions +def is_valid_date(date_string: str) -> bool: + try: + datetime.strptime(date_string, "%Y-%m-%d") + return True + except ValueError: + return False + + +def is_valid_iana_timezone(timezone: str) -> bool: + return timezone in available_timezones() + + +def _is_valid_recurrence_line_syntax(line: str) -> bool: + return any( + line.startswith(prefix) for prefix in ("RRULE:", "EXRULE:", "RDATE", "EXDATE") + ) + + +def get_current_time_rfc3339(): + try: + timezone = ZoneInfo(get_obot_user_timezone()) + except ValueError: + # Invalid timezone, fallback to UTC + timezone = ZoneInfo("UTC") + return datetime.now(timezone).isoformat() + + +def validate_recurrence_list(recurrence_list: list[str]) -> list[str]: + """ + Parse a string into a list of recurrence rules. + + Args: + recurrence (str): The recurrence string to parse + + Raises: + ValueError: If the recurrence string is not a valid JSON array of strings, where each string is an RRULE, EXRULE, RDATE, or EXDATE line as defined by the RFC5545. + ValueError: If the recurrence string is not a valid recurrence rule syntax. + + Returns: + list: A list of recurrence rules + """ + + for r in recurrence_list: + if not _is_valid_recurrence_line_syntax(r): + raise ValueError( + f"Invalid recurrence rule: {r}. It must be a valid RRULE, EXRULE, RDATE, or EXDATE string." + ) + + return recurrence_list + +def get_current_user_email(service) -> str: + """ + Gets the email of the current user, by getting the user_id of the primary calendar. + """ + user_info = service.calendars().get(calendarId="primary").execute() + return user_info["id"] + + +def has_calendar_write_access(service, calendar_id: str) -> bool: + "Validate if the user has writer access to the calendar" + try: + calendar = service.calendarList().get(calendarId=calendar_id).execute() + return calendar.get("accessRole") in ("owner", "writer") + except HttpError as e: + if e.resp.status == 403: + return False + raise Exception( + f"HttpError retrieving calendar For validating user access to {calendar_id}: {e}" + ) \ No newline at end of file diff --git a/google/google-calendar-mcp/tools/helper.py b/google/google-calendar-mcp/tools/helper.py new file mode 100644 index 000000000..eff6dffe1 --- /dev/null +++ b/google/google-calendar-mcp/tools/helper.py @@ -0,0 +1,73 @@ +import sys +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +import os +import logging +from fastmcp.exceptions import ToolError + + +def setup_logger(name, tool_name: str = "Google Calendar MCP server"): + """Setup a logger that writes to sys.stderr. This will eventually show up in GPTScript's debugging logs. + + Args: + name (str): The name of the logger. + + Returns: + logging.Logger: The logger. + """ + # Create a logger + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) # Set the logging level + + # Create a stream handler that writes to sys.stderr + stderr_handler = logging.StreamHandler(sys.stderr) + + # Create a log formatter + formatter = logging.Formatter( + f"[{tool_name} Debugging Log]: %(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + stderr_handler.setFormatter(formatter) + + # Add the handler to the logger + logger.addHandler(stderr_handler) + + return logger + + +logger = setup_logger(__name__) + + +def str_to_bool(value): + """Convert a string to a boolean.""" + return str(value).lower() in ("true", "1", "yes") + + +def get_client(cred_token: str, service_name: str = "calendar", version: str = "v3"): + creds = Credentials(token=cred_token) + try: + service = build(serviceName=service_name, version=version, credentials=creds) + return service + except HttpError as err: + raise ToolError(f"Failed to build {service_name} client. HttpError: {err}") + + +def get_obot_user_timezone(): + return os.getenv("OBOT_USER_TIMEZONE", "UTC").strip() + + +def get_user_timezone(service): + """Fetches the authenticated user's time zone from User's Google Calendar settings.""" + try: + settings = service.settings().get(setting="timezone").execute() + return settings.get( + "value", get_obot_user_timezone() + ) # Default to Obot's user timezone if not found + except HttpError as err: + if err.status_code == 403: + raise Exception(f"HttpError retrieving user timezone: {err}") + logger.error(f"HttpError retrieving user timezone: {err}") + return "UTC" + except Exception as e: + logger.error(f"Exception retrieving user timezone: {e}") + return "UTC" diff --git a/google/google-calendar-mcp/uv.lock b/google/google-calendar-mcp/uv.lock new file mode 100644 index 000000000..0cc7675c6 --- /dev/null +++ b/google/google-calendar-mcp/uv.lock @@ -0,0 +1,746 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload-time = "2025-06-10T00:02:38.826Z" }, + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload-time = "2025-06-10T00:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload-time = "2025-06-10T00:03:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload-time = "2025-06-10T00:03:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload-time = "2025-06-10T00:03:22.563Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload-time = "2025-06-10T00:03:24.586Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/a3/d5b2c47b25d13cca8108e077bf4a72b255b113fb525f4c22ce9ca5af9b08/fastmcp-2.8.0.tar.gz", hash = "sha256:8a6427ece23d0a324d4be2043598c8b89a91b2b5688873d8ae1e7aeaa7960513", size = 2554559, upload-time = "2025-06-11T01:31:24.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/05/b9b0ee091578ff37da8ef0bee8fff80bed95daff61834982a6064e3e327f/fastmcp-2.8.0-py3-none-any.whl", hash = "sha256:772468e98dacd55ab3381f49dd2583341c41b0e5ef0d9c7620fd43833d949c0c", size = 137492, upload-time = "2025-06-11T01:31:22.195Z" }, +] + +[[package]] +name = "google" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/97/b49c69893cddea912c7a660a4b6102c6b02cd268f8c7162dd70b7c16f753/google-3.0.0.tar.gz", hash = "sha256:143530122ee5130509ad5e989f0512f7cb218b2d4eddbafbad40fd10e8d8ccbe", size = 44978, upload-time = "2020-07-11T14:50:45.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/35/17c9141c4ae21e9a29a43acdfd848e3e468a810517f862cad07977bf8fe9/google-3.0.0-py2.py3-none-any.whl", hash = "sha256:889cf695f84e4ae2c55fbc0cfdaf4c1e729417fa52ab1db0485202ba173e4935", size = 45258, upload-time = "2020-07-11T14:49:58.287Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.172.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/69/c0cec6be5878d4de161f64096edb3d4a2d1a838f036b8425ea8358d0dfb3/google_api_python_client-2.172.0.tar.gz", hash = "sha256:dcb3b7e067154b2aa41f1776cf86584a5739c0ac74e6ff46fc665790dca0e6a6", size = 13074841, upload-time = "2025-06-10T16:58:41.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/fc/8850ccf21c5df43faeaf8bba8c4149ee880b41b8dc7066e3259bcfd921ca/google_api_python_client-2.172.0-py3-none-any.whl", hash = "sha256:9f1b9a268d5dc1228207d246c673d3a09ee211b41a11521d38d9212aeaa43af7", size = 13595800, upload-time = "2025-06-10T16:58:38.143Z" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, +] + +[[package]] +name = "google-calendar-mcp" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "google" }, + { name = "google-api-python-client" }, + { name = "rfc3339-validator" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.8.0" }, + { name = "google", specifier = ">=3.0.0" }, + { name = "google-api-python-client", specifier = ">=2.172.0" }, + { name = "rfc3339-validator", specifier = ">=0.1.4" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httplib2" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/ad/2371116b22d616c194aa25ec410c9c6c37f23599dcd590502b74db197584/httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81", size = 351116, upload-time = "2023-03-21T22:29:37.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/d2fbdaaa5959339d53ba38e94c123e4e84b8fbc4b84beb0e70d7c1608486/httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc", size = 96854, upload-time = "2023-03-21T22:29:35.683Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mcp" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/f2/dc2450e566eeccf92d89a00c3e813234ad58e2ba1e31d11467a09ac4f3b9/mcp-1.9.4.tar.gz", hash = "sha256:cfb0bcd1a9535b42edaef89947b9e18a8feb49362e1cc059d6e7fc636f2cb09f", size = 333294, upload-time = "2025-06-12T08:20:30.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/fc/80e655c955137393c443842ffcc4feccab5b12fa7cb8de9ced90f90e6998/mcp-1.9.4-py3-none-any.whl", hash = "sha256:7fcf36b62936adb8e63f89346bccca1268eeca9bf6dfb562ee10b1dfbda9dac0", size = 130232, upload-time = "2025-06-12T08:20:28.551Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, +] + +[[package]] +name = "protobuf" +version = "6.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" }, + { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/8f/9af0f46acc943b8c4592d06523f26a150acf6e6e37e8bd5f0ace925e996d/pydantic-2.11.6.tar.gz", hash = "sha256:12b45cfb4af17e555d3c6283d0b55271865fb0b43cc16dd0d52749dc7abf70e7", size = 787868, upload-time = "2025-06-13T09:00:29.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/11/7912a9a194ee4ea96520740d1534bc31a03a4a59d62e1d7cac9461d3f379/pydantic-2.11.6-py3-none-any.whl", hash = "sha256:a24478d2be1b91b6d3bc9597439f69ed5e87f68ebd285d86f7c7932a084b72e7", size = 444718, upload-time = "2025-06-13T09:00:27.134Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/0332bd8a25779a0e2082b0e179805ad39afad642938b371ae0882e7f880d/starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", size = 2582856, upload-time = "2025-05-29T15:45:27.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/81/c60b35fe9674f63b38a8feafc414fca0da378a9dbd5fa1e0b8d23fcc7a9b/starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37", size = 72796, upload-time = "2025-05-29T15:45:26.305Z" }, +] + +[[package]] +name = "typer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] diff --git a/google/sheets/appendCellsToSpreadsheet.py b/google/sheets/appendCellsToSpreadsheet.py deleted file mode 100644 index 8cc590720..000000000 --- a/google/sheets/appendCellsToSpreadsheet.py +++ /dev/null @@ -1,37 +0,0 @@ -import csv -import io -import os - -from gspread.exceptions import APIError -from gspread.utils import ValueInputOption - -from auth import gspread_client - - -def main(): - spreadsheet_id = os.getenv('SPREADSHEET_ID') - if spreadsheet_id is None: - raise ValueError("spreadsheet_id must be set") - - raw_data = os.getenv('DATA') - if raw_data is None: - raise ValueError("data must be set") - else: - data_csv_io = io.StringIO(raw_data) - data_csv = csv.reader(data_csv_io) - data = [row for row in data_csv] - - service = gspread_client() - try: - spreadsheet = service.open_by_key( - spreadsheet_id) - sheet = spreadsheet.sheet1 - sheet.append_rows(data, value_input_option=ValueInputOption.user_entered) - print("Data written successfully") - except APIError as err: - print(err) - - - -if __name__ == "__main__": - main() diff --git a/google/sheets/auth.py b/google/sheets/auth.py index 33ec48653..4a0132167 100644 --- a/google/sheets/auth.py +++ b/google/sheets/auth.py @@ -6,7 +6,7 @@ def client(service_name: str, version: str): - token = os.getenv('GOOGLE_OAUTH_TOKEN') + token = os.getenv("GOOGLE_OAUTH_TOKEN") if token is None: raise ValueError("GOOGLE_OAUTH_TOKEN environment variable is not set") @@ -21,7 +21,8 @@ def client(service_name: str, version: str): def gspread_client(): import gspread - token = os.getenv('GOOGLE_OAUTH_TOKEN') + + token = os.getenv("GOOGLE_OAUTH_TOKEN") if token is None: raise ValueError("GOOGLE_OAUTH_TOKEN environment variable is not set") diff --git a/google/sheets/createSpreadsheet.py b/google/sheets/createSpreadsheet.py index c770800fc..fdbb04f15 100644 --- a/google/sheets/createSpreadsheet.py +++ b/google/sheets/createSpreadsheet.py @@ -6,20 +6,20 @@ def main(): - spreadsheet_name = os.getenv('SPREADSHEET_NAME') + spreadsheet_name = os.getenv("SPREADSHEET_NAME") if spreadsheet_name is None: raise ValueError("spreadsheet_name is not set") - props = { - 'properties': { - 'title': spreadsheet_name - } - } + props = {"properties": {"title": spreadsheet_name}} - service = client('sheets', 'v4') + service = client("sheets", "v4") try: - spreadsheet = service.spreadsheets().create(body=props, fields='spreadsheetId').execute() - print(f"Spreadsheet named {spreadsheet_name} created with ID: {spreadsheet.get('spreadsheetId')}") + spreadsheet = ( + service.spreadsheets().create(body=props, fields="spreadsheetId").execute() + ) + print( + f"Spreadsheet named {spreadsheet_name} created with ID: {spreadsheet.get('spreadsheetId')}" + ) except HttpError as err: print(err) diff --git a/google/sheets/querySpreadsheet.py b/google/sheets/querySpreadsheet.py index ffdf822e3..d66f63796 100644 --- a/google/sheets/querySpreadsheet.py +++ b/google/sheets/querySpreadsheet.py @@ -7,21 +7,20 @@ async def main(): - spreadsheet_id = os.getenv('SPREADSHEET_ID') + spreadsheet_id = os.getenv("SPREADSHEET_ID") if spreadsheet_id is None: raise ValueError("spreadsheet_id parameter must be set") - query = os.getenv('QUERY') + query = os.getenv("QUERY") if query is None: raise ValueError("query parameter must be set") - show_columns = os.getenv('SHOW_COLUMNS') + show_columns = os.getenv("SHOW_COLUMNS") if show_columns is not None: - show_columns = [item.strip() for item in show_columns.split(',')] - sheet_name = os.getenv('SHEET_NAME') + show_columns = [item.strip() for item in show_columns.split(",")] + sheet_name = os.getenv("SHEET_NAME") service = gspread_client() try: - spreadsheet = service.open_by_key( - spreadsheet_id) + spreadsheet = service.open_by_key(spreadsheet_id) if sheet_name is None: sheet = spreadsheet.sheet1 else: @@ -33,11 +32,11 @@ async def main(): df = pd.DataFrame(values) filtered_df = df.query(query) # Set the max rows and max columns to display - pd.set_option('display.max_rows', None) + pd.set_option("display.max_rows", None) if show_columns is None: - pd.set_option('display.max_columns', 5) + pd.set_option("display.max_columns", 5) else: - pd.set_option('display.max_columns', len(show_columns)) + pd.set_option("display.max_columns", len(show_columns)) filtered_df = filtered_df[show_columns] print(filtered_df) diff --git a/google/sheets/readSpreadsheet.py b/google/sheets/readSpreadsheet.py index f536cb63e..7f4085d9b 100644 --- a/google/sheets/readSpreadsheet.py +++ b/google/sheets/readSpreadsheet.py @@ -5,22 +5,23 @@ async def main(): - spreadsheet_id = os.getenv('SPREADSHEET_ID') + spreadsheet_id = os.getenv("SPREADSHEET_ID") if spreadsheet_id is None: raise ValueError("spreadsheet_id parameter must be set") - range = os.getenv('RANGE') - sheet_name = os.getenv('SHEET_NAME') + range = os.getenv("RANGE") + sheet_name = os.getenv("SHEET_NAME") service = gspread_client() try: - spreadsheet = service.open_by_key( - spreadsheet_id) + spreadsheet = service.open_by_key(spreadsheet_id) if sheet_name is None: sheet = spreadsheet.sheet1 else: sheet = spreadsheet.worksheet(sheet_name) + + all_values = sheet.get_all_values() if range is None: - values = sheet.get_all_values() + values = all_values else: values = sheet.get(range) @@ -28,6 +29,13 @@ async def main(): print("No data found.") return else: + print( + f"Overview of the sheet: {len(all_values)} rows and {len(all_values[0])} columns" + ) + if range is None: + print("Sheet data:") + else: + print(f"Data in range {range}:") for row in values: print(row) diff --git a/google/sheets/readTablesFromSpreadsheet.py b/google/sheets/readTablesFromSpreadsheet.py index 60a30f3a0..082e4f4c3 100644 --- a/google/sheets/readTablesFromSpreadsheet.py +++ b/google/sheets/readTablesFromSpreadsheet.py @@ -5,22 +5,21 @@ async def main(): - spreadsheet_id = os.getenv('SPREADSHEET_ID') + spreadsheet_id = os.getenv("SPREADSHEET_ID") if spreadsheet_id is None: raise ValueError("spreadsheet_id parameter must be set") - range = os.getenv('RANGE') + range = os.getenv("RANGE") if range is None: range = "A:Z" - sheet_name = os.getenv('SHEET_NAME') + sheet_name = os.getenv("SHEET_NAME") if sheet_name is not None: range = f"{sheet_name}!{range}" service = gspread_client() try: - spreadsheet = service.open_by_key( - spreadsheet_id) + spreadsheet = service.open_by_key(spreadsheet_id) if sheet_name is None: sheet = spreadsheet.sheet1 diff --git a/google/sheets/tool.gpt b/google/sheets/tool.gpt index 1bb565ca2..320b8ef0b 100644 --- a/google/sheets/tool.gpt +++ b/google/sheets/tool.gpt @@ -4,72 +4,73 @@ Description: Create, read, and write to spreadsheets in Google Sheets Metadata: bundle: true Metadata: mcp: true Metadata: categories: Official,Data & Analytics,Office Productivity -Share Tools: Read Spreadsheet, Query Spreadsheet, Read Tables From Spreadsheet, Create Spreadsheet, Append Cells To Spreadsheet, Update Cells In Spreadsheet +Share Tools: Read Spreadsheet, Query Spreadsheet, Read Tables From Spreadsheet, Create Spreadsheet, Update Cells In Spreadsheet, Update With Formula --- Name: Read Spreadsheet -Description: Read data from a Google Sheet. Spreadsheet ID is required if the spreadsheet does not belong to the user. +Description: Read data from a Google Sheet. Share Context: Google Sheets Context Credential: ../credential -Param: spreadsheet_id: The ID of the Spreadsheet -Param: range: The range of cells on the Spreadsheet to read (Optional, by default reads the entire Spreadsheet) -Param: sheet_name: The name of the page to read from (Optional, by default reads the first page) +Param: spreadsheet_id: (Required) The ID of the Spreadsheet +Param: range: (Optional) A1 notation of the cells to read (e.g. "A2:D100" or "A1:Z5"), defaults to the entire sheet, can also be a single cell (e.g. "A1") +Param: sheet_name: (Optional) The name of the sheet to read from, defaults to the first sheet #!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/readSpreadsheet.py --- Name: Query Spreadsheet -Description: Filter and query specific data from a Google Sheet using a SQL-like syntax. Spreadsheet ID is required if the spreadsheet does not belong to the user. +Description: Filter and query specific data from a Google Sheet using SQL-like syntax. Perfect for finding rows that match certain criteria. Share Context: Google Sheets Context Credential: ../credential -Param: spreadsheet_id: The ID of the Spreadsheet -Param: query: The sql-like query to run against the spreadsheet. Should be the format expected by the pandas query function (e.g. "column1 == 'value1' and column2 > 10") -Param: show_columns: a comma-delimited list of columns to show in the output (Optional, by default shows first 5 columns) -Param: sheet_name: The name of the page to read from (Optional, by default reads the first page) +Param: spreadsheet_id: (Required) The ID of the Spreadsheet +Param: query: (Required) The SQL-like query using pandas syntax (e.g. "column1 == 'value1' and column2 > 10") +Param: show_columns: (Optional) Comma-delimited list of columns to include in output, defaults to first 5 columns +Param: sheet_name: (Optional) The name of the sheet to read from, defaults to the first sheet #!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/querySpreadsheet.py --- Name: Read Tables From Spreadsheet -Description: Detect multiple tables and read data from a Google Sheet. Spreadsheet ID is required if the spreadsheet does not belong to the user. +Description: Automatically detect and extract multiple tables from a Google Sheet. Useful when a sheet contains separate data tables with blank rows between them. Share Context: Google Sheets Context Credential: ../credential -Param: spreadsheet_id: The ID of the Spreadsheet -Param: range: The range of cells on the Spreadsheet to read (Optional, by default reads the entire Spreadsheet) -Param: sheet_name: The name of the page to read from (Optional, by default reads the first page) +Param: spreadsheet_id: (Required) The ID of the Spreadsheet +Param: range: (Optional) A1 notation of cells to read, defaults to the entire sheet +Param: sheet_name: (Optional) The name of the sheet to read from, defaults to the first sheet #!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/readTablesFromSpreadsheet.py --- Name: Create Spreadsheet -Description: Create a new Google Sheet +Description: Create a new Google Sheet with the specified name. Returns the ID of the newly created spreadsheet. Share Context: Google Sheets Context Credential: ../credential -Param: spreadsheet_name: The name of the spreadsheet to create +Param: spreadsheet_name: (Required) The name of the spreadsheet to create #!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/createSpreadsheet.py --- -Name: Append Cells To Spreadsheet -Description: Append data to a Google Sheet. Spreadsheet ID is required if the spreadsheet does not belong to the user. +Name: Update With Formula +Description: Apply the same formula pattern across an entire row or column in a Google Sheet. Perfect for creating sequences, calculated fields, or applying uniform formulas with row/column references that automatically adjust (e.g., creating running totals, sequential numbering, or formulas that reference position-relative data). Share Context: Google Sheets Context Credential: ../credential -Share Tools: Read Spreadsheet, Read Tables From Spreadsheet -Param: spreadsheet_id: The ID of the Spreadsheet -Param: data: The data to append to the Spreadsheet. Columns are separated by commas and rows are separated by newlines. Newlines inside a cell should be represented by CR LF characters. The data in each cell must be wrapped in double quotes and escape any characters that would break csv parsing (e.g. '"A1","B1, ""this is a quoted string""","C1"\n"A2","B2","C2"\n"A3","B3","C3\r\nwith newline"') +Param: spreadsheet_id: (Required) The ID of the Spreadsheet +Param: sheet_name: (Optional) The name of the page to read from (by default reads the first page) +Param: target_range: (Required) A1 notation of the cells to fill (e.g. "D2:D100" or "A5:Z5"), must be a single row or column, can also be a single cell (e.g. "A1") +Param: formula_template: (Required) a template for Google Sheets formulas that supports: - {row} → the row number - {col} → the column letter e.g. "=A{row}-B{row}" or "=SUM({col}1:{col}10)" -#!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/appendCellsToSpreadsheet.py +#!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/update_with_formula.py --- Name: Update Cells In Spreadsheet -Description: Update data within one or more cells in a Google Sheet. Spreadsheet ID is required if the spreadsheet does not belong to the user. +Description: Update individual cells with specific values or formulas in a Google Sheet. Use when you need to update different cells with unique content (unlike Update With Formula which applies the same pattern across a range). Perfect for inserting varied data, custom formulas at specific locations, or making targeted changes to a spreadsheet. Share Context: Google Sheets Context Credential: ../credential -Share Tools: Read Spreadsheet, Read Tables From Spreadsheet -Param: spreadsheet_id: The ID of the Spreadsheet -Param: update_cells: A json list of objects that contains the row, column. and the data to put in that cell. Each object in the list should have the following format: `{"row": 1,"column":1,"value": "New Value"}`. Row and Column indexes start at 1. +Param: spreadsheet_id: (Required) The ID of the Spreadsheet +Param: sheet_name: (Optional) The name of the page to read from, defaults to the first page +Param: update_cells: (Required) A json list of objects that each contains the `coordinate` and the `data` field, the coordinate is an A1 notation of the cell to update, e.g. "A1", "B2", etc, the data is either a string or a formula. Example: [{"coordinate": "A1", "data": "New Value"}, {"coordinate": "B2", "data": "=SUM(A1:A2)"}] -#!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/updateCellsInSpreadsheet.py +#!/usr/bin/env python3 ${GPTSCRIPT_TOOL_DIR}/update_cells.py --- Name: Google Sheets Context @@ -78,15 +79,16 @@ Type: context #!sys.echo -You have access to a set of tools to access, create, and modify Google Sheets. -Do not output sheet IDs because they are not helpful for the user. -If the user does not provide a URL for the Google Sheet they want to work with, ask them to provide it. - -When reading data from a Google Sheet, always start by reading just the 2 rows to determine if the first row contains column names. -If the user asks to filter or list only specific information from the spreadsheet, try to use the 'Query Spreadsheet' tool. -Write the query such that it returns the minimum number of columns and rows necessary to answer the user request. - -Do your best to always return the complete data that the user asked for, even if it is a large dataset. +You can access, create, and modify Google Sheets via available tools. + +1. Always ask the user to supply the sheet URL if spreadsheet_id is not provided. +2. Never expose raw sheet IDs in your output. +3. To detect headers, initially read only the first two rows: + - If row 1 contains column names, treat it as headers. + - Otherwise proceed without headers. +4. For filtering or selecting specific fields, prefer the "Query Spreadsheet" tool: + - Craft your query to fetch only the columns and rows needed. +5. When the user requests full data, return the complete range—even if large. --- diff --git a/google/sheets/updateCellsInSpreadsheet.py b/google/sheets/updateCellsInSpreadsheet.py deleted file mode 100644 index 2eca19a93..000000000 --- a/google/sheets/updateCellsInSpreadsheet.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -import os - -from gspread.worksheet import Cell - -from auth import gspread_client - - -def main(): - spreadsheet_id = os.getenv('SPREADSHEET_ID') - if spreadsheet_id is None: - raise ValueError("spreadsheet_id parameter must be set") - - update_cells = os.getenv('UPDATE_CELLS') - if update_cells is None: - raise ValueError("update_cells parameter must be set") - try: - update_cells_object = json.loads(update_cells) - except json.JSONDecodeError as err: - print(f"JSON parsing error for update_cells input: {err}") - exit(1) - - service = gspread_client() - - cells = [] - try: - for cell in update_cells_object: - if not all(key in cell for key in ("row", "column", "value")): - raise ValueError(f"Missing required keys in cell dictionary: {cell}") - cell_object = Cell(row=cell["row"], col=cell["column"], value=cell["value"]) - cells.append(cell_object) - except Exception as err: - print(f"Error mapping input to Cell: {err}") - exit(1) - - try: - spreadsheet = service.open_by_key( - spreadsheet_id) - sheet = spreadsheet.sheet1 - except Exception as err: - print(f"Error opening spreadsheet: {err}") - exit(1) - - try: - sheet.update_cells(cells) - except Exception as err: - print(f"Error updating cells: {err}") - exit(1) - - print("Data written successfully") - - -if __name__ == "__main__": - main() diff --git a/google/sheets/update_cells.py b/google/sheets/update_cells.py new file mode 100644 index 000000000..01362b312 --- /dev/null +++ b/google/sheets/update_cells.py @@ -0,0 +1,70 @@ +import json +import os + +import gspread +from gspread.worksheet import Cell +from gspread.utils import ValueInputOption +from auth import gspread_client + + +def update_cells(): + spreadsheet_id = os.getenv("SPREADSHEET_ID") + if spreadsheet_id is None: + raise ValueError("spreadsheet_id parameter must be set") + sheet_name = os.getenv("SHEET_NAME") + + update_cells = os.getenv("UPDATE_CELLS") + if update_cells is None: + raise ValueError("update_cells parameter must be set") + try: + update_cells_object = json.loads(update_cells) + print(update_cells_object) + except json.JSONDecodeError as err: + print(f"JSON parsing error for update_cells input: {err}") + exit(1) + + service = gspread_client() + + data_cells = [] + formula_cells = [] + + try: + for cell in update_cells_object: + if not all(key in cell for key in ("coordinate", "data")): + raise ValueError(f"Missing required keys in cell dictionary: {cell}") + row, col = gspread.utils.a1_to_rowcol(cell["coordinate"]) + cell_object = Cell(row=row, col=col, value=cell["data"]) + if cell["data"].startswith("="): + formula_cells.append(cell_object) + else: + data_cells.append(cell_object) + except Exception as err: + print(f"Error mapping input to Cell: {err}") + exit(1) + + try: + spreadsheet = service.open_by_key(spreadsheet_id) + if sheet_name is not None: + sheet = spreadsheet.worksheet(sheet_name) + else: + sheet = spreadsheet.sheet1 + except Exception as err: + print(f"Error opening spreadsheet: {err}") + exit(1) + + try: + if len(data_cells) > 0: + sheet.update_cells(data_cells) + if len(formula_cells) > 0: + sheet.update_cells( + formula_cells, value_input_option=ValueInputOption.user_entered + ) + except Exception as err: + print(f"Error updating cells: {err}") + exit(1) + + print("Data written successfully") + + +if __name__ == "__main__": + update_cells() diff --git a/google/sheets/update_with_formula.py b/google/sheets/update_with_formula.py new file mode 100644 index 000000000..9b90dcbeb --- /dev/null +++ b/google/sheets/update_with_formula.py @@ -0,0 +1,98 @@ +import os + +from auth import gspread_client +from gspread.utils import a1_range_to_grid_range, rowcol_to_a1, ValueInputOption +from gspread.exceptions import APIError + + +def update_with_formula(): + spreadsheet_id = os.getenv("SPREADSHEET_ID") + if spreadsheet_id is None: + raise ValueError("spreadsheet_id parameter must be set") + + sheet_name = os.getenv("SHEET_NAME") + + target_range = os.getenv("TARGET_RANGE") + if target_range is None: + raise ValueError("target_range parameter must be set") + + formula_template = os.getenv("FORMULA_TEMPLATE") + if formula_template is None: + raise ValueError("formula_template parameter must be set") + if not formula_template.startswith("="): + formula_template = f"={formula_template}" + + service = gspread_client() + + try: + spreadsheet = service.open_by_key(spreadsheet_id) + if sheet_name is not None: + sheet = spreadsheet.worksheet(sheet_name) + else: + sheet = spreadsheet.sheet1 + except Exception as err: + print(f"Error opening spreadsheet: {err}") + exit(1) + + """ + Injects formulas into the given range on a sheet. + + :param spreadsheet_id: the “key” of the spreadsheet (from its URL) + :param sheet_name: the name (tab) within the spreadsheet + :param target_range: A1 notation of the cells to fill (e.g. "D2:D100" or "A5:Z5") + :param formula_template: + a template for Google Sheets formulas that supports: + - {row} → the row number + - {col} → the column letter + e.g. "=B{row}-C{row}" or "={col}2*2" + """ + + try: + grid = a1_range_to_grid_range(target_range) + start_col, start_row, end_col, end_row = ( + grid["startColumnIndex"], + grid["startRowIndex"], + grid["endColumnIndex"], + grid["endRowIndex"], + ) + except Exception as err: + print(f"Error parsing target_range {target_range}: {err}") + return + + formulas = [] + for r in range(start_row, end_row): + row_formulas = [] + for c in range(start_col, end_col): + # get the column letter ("A", "B", ..., "AA", etc.) + col_letter = rowcol_to_a1(1, c)[:-1] + row_formulas.append(formula_template.format(row=r + 1, col=col_letter)) + formulas.append(row_formulas) + + try: + sheet.update( + target_range, + formulas, + value_input_option=ValueInputOption.user_entered, + ) + except APIError as e: + # if it's an out-of-bounds error, grow and retry + if "out of bounds" in str(e): + needed_rows = end_row - sheet.row_count + if needed_rows > 0: + sheet.add_rows(needed_rows) + needed_cols = end_col - sheet.col_count + if needed_cols > 0: + sheet.add_cols(needed_cols) + sheet.update( + target_range, + formulas, + value_input_option=ValueInputOption.USER_ENTERED, + ) + else: + print(f"Error updating cells with formula {formula_template}: {e}") + exit(1) + print(f"Successfully updated {target_range} with formula {formula_template}") + + +if __name__ == "__main__": + update_with_formula()