diff --git a/README.md b/README.md index 76bf701..f048140 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ pip install gslides-api ### Authentication -First, set up your Google API credentials. See [CREDENTIALS.md](CREDENTIALS.md) for detailed instructions. +First, set up your Google API credentials. See [CREDENTIALS.md](docs/CREDENTIALS.md) for detailed instructions. ```python from gslides_api import initialize_credentials @@ -60,6 +60,60 @@ new_slide = presentation.add_slide() - **Layouts**: Access and use slide layouts and masters - **Requests**: Type-safe request builders for batch operations - **Markdown Support**: Convert between Markdown and Google Slides content +- **MCP Server**: Expose Google Slides operations as tools for AI assistants + +## MCP Server + +gslides-api includes an MCP (Model Context Protocol) server that exposes Google Slides operations as tools for AI assistants like Claude. + +### Installation + +```bash +pip install gslides-api[mcp] +``` + +### Quick Start + +```bash +# Set credentials path +export GSLIDES_CREDENTIALS_PATH=/path/to/credentials + +# Run the MCP server +python -m gslides_api.mcp.server +``` + +### Available Tools + +| Tool | Description | +|------|-------------| +| `get_presentation` | Get full presentation by URL or ID | +| `get_slide` | Get slide by name (speaker notes) | +| `get_element` | Get element by slide and element name | +| `get_slide_thumbnail` | Get slide thumbnail image | +| `read_element_markdown` | Read text element as markdown | +| `write_element_markdown` | Write markdown to text element | +| `replace_element_image` | Replace image from URL | +| `copy_slide` | Duplicate a slide | +| `move_slide` | Reorder slide position | +| `delete_slide` | Remove a slide | + +### MCP Configuration + +Add to your `.mcp.json`: + +```json +{ + "gslides": { + "type": "stdio", + "command": "python", + "args": ["-m", "gslides_api.mcp.server"] + } +} +``` + +The server reads credentials from `GSLIDES_CREDENTIALS_PATH` environment variable. Use `--credential-path` to override. + +See [docs/MCP_SERVER.md](docs/MCP_SERVER.md) for detailed documentation. ## API Coverage diff --git a/CREDENTIALS.md b/docs/CREDENTIALS.md similarity index 100% rename from CREDENTIALS.md rename to docs/CREDENTIALS.md diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md new file mode 100644 index 0000000..1305729 --- /dev/null +++ b/docs/MCP_SERVER.md @@ -0,0 +1,394 @@ +# gslides-api MCP Server + +The gslides-api MCP (Model Context Protocol) server exposes Google Slides operations as tools for AI assistants. This enables AI systems to read, modify, and manipulate Google Slides presentations programmatically. + +## Table of Contents + +- [Installation](#installation) +- [Credential Setup](#credential-setup) +- [Server Startup](#server-startup) +- [Tool Reference](#tool-reference) +- [Output Formats](#output-formats) +- [Configuration](#configuration) +- [Troubleshooting](#troubleshooting) + +## Installation + +Install gslides-api with the MCP extra: + +```bash +pip install gslides-api[mcp] +# or with poetry +poetry add gslides-api -E mcp +``` + +## Credential Setup + +See [CREDENTIALS.md](CREDENTIALS.md) for detailed instructions on setting up Google API credentials. + +## Server Startup + +### Command Line + +```bash +# Using the CLI argument +python -m gslides_api.mcp.server --credential-path /path/to/credentials + +# Using environment variable +export GSLIDES_CREDENTIALS_PATH=/path/to/credentials +python -m gslides_api.mcp.server + +# With custom default output format +python -m gslides_api.mcp.server --credential-path /path/to/credentials --default-format outline + +# Using the installed script +gslides-mcp --credential-path /path/to/credentials +``` + +### CLI Arguments + +| Argument | Description | Default | +|----------|-------------|---------| +| `--credential-path` | Path to Google API credentials directory | `GSLIDES_CREDENTIALS_PATH` env var | +| `--default-format` | Default output format: `raw`, `domain`, or `outline` | `raw` | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `GSLIDES_CREDENTIALS_PATH` | Alternative to `--credential-path` (CLI arg takes precedence) | + +### MCP Configuration (`.mcp.json`) + +Add to your `.mcp.json` configuration: + +```json +{ + "gslides": { + "type": "stdio", + "command": "python", + "args": ["-m", "gslides_api.mcp.server"] + } +} +``` + +The server reads credentials from the `GSLIDES_CREDENTIALS_PATH` environment variable. Use `--credential-path` to override: + +```json +{ + "gslides": { + "type": "stdio", + "command": "python", + "args": ["-m", "gslides_api.mcp.server", "--credential-path", "/path/to/credentials"] + } +} +``` + +## Tool Reference + +### Query Tools + +#### `get_presentation` + +Get a full presentation by URL or deck ID. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `how` | string | No | Server default | Output format: `raw`, `domain`, or `outline` | + +**Example:** +```json +{ + "presentation_id_or_url": "https://docs.google.com/presentation/d/1abc123/edit", + "how": "outline" +} +``` + +--- + +#### `get_slide` + +Get a single slide by name (first line of speaker notes). + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name (first line of speaker notes) | +| `how` | string | No | Server default | Output format: `raw`, `domain`, or `outline` | + +**Example:** +```json +{ + "presentation_id_or_url": "1abc123", + "slide_name": "Introduction", + "how": "domain" +} +``` + +--- + +#### `get_element` + +Get a single element by slide name and element name (alt-title). + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name | +| `element_name` | string | Yes | - | Element name (from alt-text title) | +| `how` | string | No | Server default | Output format | + +--- + +#### `get_slide_thumbnail` + +Get a slide thumbnail image, optionally with black borders around text boxes. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name | +| `add_text_box_borders` | boolean | No | `false` | Add 1pt black outlines to text boxes | +| `size` | string | No | `LARGE` | Thumbnail size: `SMALL` (200px), `MEDIUM` (800px), `LARGE` (1600px) | + +**Returns:** JSON with base64-encoded PNG image data. + +**Example Response:** +```json +{ + "success": true, + "slide_name": "Introduction", + "slide_id": "p1", + "width": 1600, + "height": 900, + "mime_type": "image/png", + "image_base64": "iVBORw0KGgo..." +} +``` + +--- + +### Markdown Tools + +#### `read_element_markdown` + +Read the text content of a shape element as markdown. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name | +| `element_name` | string | Yes | - | Element name (text box alt-title) | + +**Returns:** Markdown content preserving bold, italic, bullets, etc. + +--- + +#### `write_element_markdown` + +Write markdown content to a shape element (text box). + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name | +| `element_name` | string | Yes | - | Element name (text box alt-title) | +| `markdown` | string | Yes | - | Markdown content to write | + +--- + +### Image Tools + +#### `replace_element_image` + +Replace an image element with a new image from URL. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name | +| `element_name` | string | Yes | - | Element name (image alt-title) | +| `image_url` | string | Yes | - | URL of new image | + +--- + +### Slide Manipulation Tools + +#### `copy_slide` + +Duplicate a slide within the presentation. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name to copy | +| `insertion_index` | integer | No | After original | Position for new slide (0-indexed) | + +**Returns:** New slide info including `new_slide_id`. + +--- + +#### `move_slide` + +Move a slide to a new position in the presentation. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name to move | +| `insertion_index` | integer | Yes | - | New position (0-indexed) | + +--- + +#### `delete_slide` + +Delete a slide from the presentation. + +**Arguments:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `presentation_id_or_url` | string | Yes | - | Google Slides URL or presentation ID | +| `slide_name` | string | Yes | - | Slide name to delete | + +--- + +## Output Formats + +The `how` parameter controls the output format for query tools: + +### `raw` + +Returns the raw JSON response from the Google Slides API. Most verbose, includes all API fields. + +```json +{ + "presentationId": "1abc123", + "title": "My Presentation", + "slides": [...], + "masters": [...], + "layouts": [...] +} +``` + +### `domain` + +Returns the gslides-api domain object serialized via `model_dump()`. Structured Pydantic models with Python-friendly field names. + +```json +{ + "presentationId": "1abc123", + "title": "My Presentation", + "slides": [...], + "pageSize": {"width": {...}, "height": {...}} +} +``` + +### `outline` + +Returns a condensed structure optimized for AI consumption. Includes slide names, element names, alt-descriptions, and text content as markdown. + +```json +{ + "presentation_id": "1abc123", + "title": "My Presentation", + "slides": [ + { + "slide_name": "Introduction", + "slide_id": "p1", + "elements": [ + { + "element_name": "Title", + "element_id": "e1", + "type": "shape", + "alt_description": "Main title", + "content_markdown": "# Welcome to the Presentation" + }, + { + "element_name": "Hero Image", + "element_id": "e2", + "type": "image", + "alt_description": "Company logo" + } + ] + } + ] +} +``` + +## Slide and Element Naming + +### Slide Names + +Slide names are derived from the **first line of the speaker notes**, stripped of whitespace. If a slide has no speaker notes or the first line is empty, it cannot be referenced by name (use the outline format to discover slide IDs). + +### Element Names + +Element names come from the **alt-text title** of each element. In Google Slides: +1. Right-click an element +2. Select "Alt text" +3. Enter a name in the "Title" field + +Elements without alt-text titles cannot be referenced by name. + +## Error Handling + +All tools return detailed error responses when failures occur: + +```json +{ + "error": true, + "error_type": "SlideNotFound", + "message": "No slide found with name 'Introduction'", + "details": { + "presentation_id": "1abc123", + "searched_slide_name": "Introduction", + "available_slides": ["Cover", "Overview", "Conclusion"] + } +} +``` + +### Error Types + +| Error Type | Description | +|------------|-------------| +| `ValidationError` | Invalid input parameter | +| `SlideNotFound` | Specified slide name not found | +| `ElementNotFound` | Specified element name not found on slide | +| `PresentationError:*` | Google API error (includes specific exception type) | + +## Troubleshooting + +### "API client not initialized" + +Ensure you've provided a valid credential path via `--credential-path` or the `GSLIDES_CREDENTIALS_PATH` environment variable. + +### OAuth flow opens repeatedly + +Delete `token.json` in your credentials directory and re-authenticate. + +### "Rate limit exceeded" + +The server implements exponential backoff for rate limits. If you continue to see errors, reduce the frequency of API calls. + +### Slide/Element not found + +- Use `get_presentation` with `how=outline` to see all available slide and element names +- Ensure slides have speaker notes with content on the first line +- Ensure elements have alt-text titles set + +### Import errors for MCP + +Ensure you've installed the MCP extra: +```bash +pip install gslides-api[mcp] +``` diff --git a/gslides_api/client.py b/gslides_api/client.py index a8a3ae7..a90a6e1 100644 --- a/gslides_api/client.py +++ b/gslides_api/client.py @@ -26,7 +26,11 @@ class GoogleAPIClient: """The credentials object to build the connections to the APIs""" def __init__( - self, auto_flush: bool = True, initial_wait_s: int = 60, n_backoffs: int = 4 + self, + auto_flush: bool = True, + initial_wait_s: int = 60, + n_backoffs: int = 4, + _shared_services: Optional["GoogleAPIClient"] = None, ) -> None: """Constructor method @@ -34,11 +38,21 @@ def __init__( auto_flush: Whether to automatically flush batch requests initial_wait_s: Initial wait time in seconds for exponential backoff n_backoffs: Number of backoff attempts before giving up + _shared_services: Optional parent client to share services from (internal use) """ - self.crdtls: Optional[Credentials] = None - self.sht_srvc: Optional[Resource] = None - self.sld_srvc: Optional[Resource] = None - self.drive_srvc: Optional[Resource] = None + if _shared_services is not None: + # Reuse shared, read-only state from another client + self.crdtls = _shared_services.crdtls + self.sht_srvc = _shared_services.sht_srvc + self.sld_srvc = _shared_services.sld_srvc + self.drive_srvc = _shared_services.drive_srvc + else: + self.crdtls: Optional[Credentials] = None + self.sht_srvc: Optional[Resource] = None + self.sld_srvc: Optional[Resource] = None + self.drive_srvc: Optional[Resource] = None + + # Per-instance mutable batch state (never shared) self.pending_batch_requests: list[GSlidesAPIRequest] = [] self.pending_presentation_id: Optional[str] = None self.auto_flush = auto_flush @@ -103,6 +117,38 @@ def set_credentials(self, credentials: Optional[BaseCredentials]) -> None: self.drive_srvc = build("drive", "v3", credentials=credentials) logger.info("Built drive connection") + def initialize_credentials(self, credential_location: str) -> None: + """Initialize credentials from a directory containing token.json/credentials.json. + + Args: + credential_location: Path to directory containing Google API credentials + """ + SCOPES = [ + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive", + ] + + _creds = None + if os.path.exists(os.path.join(credential_location, "token.json")): + _creds = Credentials.from_authorized_user_file( + os.path.join(credential_location, "token.json"), SCOPES + ) + if not _creds or not _creds.valid: + if _creds and _creds.expired and _creds.refresh_token: + _creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(credential_location, "credentials.json"), SCOPES + ) + _creds = flow.run_local_server( + prompt="consent", + access_type="offline", + ) + with open(os.path.join(credential_location, "token.json"), "w") as token: + token.write(_creds.to_json()) + self.set_credentials(_creds) + @property def sheet_service(self) -> Resource: """Returns the connects to the sheets API @@ -156,6 +202,31 @@ def is_initialized(self) -> bool: and self.drive_srvc is not None ) + def create_child_client(self, auto_flush: bool = False) -> "GoogleAPIClient": + """Create a new client that shares this client's services but has its own batch state. + + This is useful for concurrent operations where each operation needs isolated + batch state while reusing the same authenticated Google API service connections. + + Args: + auto_flush: Whether the child client should automatically flush batch requests. + Defaults to False. + + Returns: + A new GoogleAPIClient instance that shares this client's services. + + Raises: + RuntimeError: If this client has not been initialized with credentials. + """ + if not self.is_initialized: + raise RuntimeError("Cannot create child client from uninitialized parent client") + return GoogleAPIClient( + auto_flush=auto_flush, + initial_wait_s=self.initial_wait_s, + n_backoffs=self.n_backoffs, + _shared_services=self, + ) + def flush_batch_update(self) -> Dict[str, Any]: if not len(self.pending_batch_requests): return {} @@ -474,39 +545,12 @@ def _get_thumbnail(): def initialize_credentials(credential_location: str): - """ + """Initialize credentials on the module-level api_client. - :param credential_location: - :return: - """ + This is a convenience function that calls api_client.initialize_credentials(). + For initializing credentials on a specific GoogleAPIClient instance, call the + initialize_credentials() method directly on that instance. - SCOPES = [ - "https://www.googleapis.com/auth/presentations", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/drive", - ] - - _creds = None - # The file token.json stores the user's access and refresh tokens, and is - # created automatically when the authorization flow completes for the first - # time. - if os.path.exists(os.path.join(credential_location, "token.json")): - _creds = Credentials.from_authorized_user_file( - os.path.join(credential_location, "token.json"), SCOPES - ) - # If there are no (valid) credentials available, let the user log in. - if not _creds or not _creds.valid: - if _creds and _creds.expired and _creds.refresh_token: - _creds.refresh(Request()) - else: - flow = InstalledAppFlow.from_client_secrets_file( - os.path.join(credential_location, "credentials.json"), SCOPES - ) - _creds = flow.run_local_server( - prompt="consent", - access_type="offline", - ) - # Save the credentials for the next run - with open(os.path.join(credential_location, "token.json"), "w") as token: - token.write(_creds.to_json()) - api_client.set_credentials(_creds) + :param credential_location: Path to directory containing Google API credentials + """ + api_client.initialize_credentials(credential_location) diff --git a/gslides_api/mcp/__init__.py b/gslides_api/mcp/__init__.py new file mode 100644 index 0000000..1d69816 --- /dev/null +++ b/gslides_api/mcp/__init__.py @@ -0,0 +1,49 @@ +"""MCP server for gslides-api. + +This module provides an MCP (Model Context Protocol) server that exposes +Google Slides operations as tools for AI assistants. + +Usage: + python -m gslides_api.mcp.server --credential-path /path/to/credentials + +Or set the GSLIDES_CREDENTIALS_PATH environment variable. +""" + +from .models import ( + ElementOutline, + ErrorResponse, + OutputFormat, + PresentationOutline, + SlideOutline, + SuccessResponse, + ThumbnailSizeOption, +) +from .server import initialize_server, main, mcp +from .utils import ( + find_element_by_name, + find_slide_by_name, + get_element_name, + get_slide_name, + parse_presentation_id, +) + +__all__ = [ + # Server + "mcp", + "main", + "initialize_server", + # Models + "OutputFormat", + "ThumbnailSizeOption", + "ErrorResponse", + "SuccessResponse", + "ElementOutline", + "SlideOutline", + "PresentationOutline", + # Utils + "parse_presentation_id", + "get_slide_name", + "get_element_name", + "find_slide_by_name", + "find_element_by_name", +] diff --git a/gslides_api/mcp/models.py b/gslides_api/mcp/models.py new file mode 100644 index 0000000..1bb91dd --- /dev/null +++ b/gslides_api/mcp/models.py @@ -0,0 +1,71 @@ +"""Models for the gslides-api MCP server.""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class OutputFormat(str, Enum): + """Output format for presentation/slide/element data.""" + + RAW = "raw" # Raw Google Slides API JSON response + DOMAIN = "domain" # gslides-api domain object model_dump() + OUTLINE = "outline" # Bare-bones structure with names and markdown content + + +class ThumbnailSizeOption(str, Enum): + """Thumbnail size options.""" + + SMALL = "SMALL" # 200px width + MEDIUM = "MEDIUM" # 800px width + LARGE = "LARGE" # 1600px width + + +class ErrorResponse(BaseModel): + """Structured error response for tool failures.""" + + error: bool = True + error_type: str = Field(description="Type of error (e.g., SlideNotFound, ValidationError)") + message: str = Field(description="Human-readable error message") + details: Dict[str, Any] = Field( + default_factory=dict, description="Additional context about the error" + ) + + +class ElementOutline(BaseModel): + """Outline representation of a page element.""" + + element_name: Optional[str] = Field(None, description="Element name from alt-text title") + element_id: str = Field(description="Element object ID") + type: str = Field(description="Element type (shape, image, table, etc.)") + alt_description: Optional[str] = Field(None, description="Alt-text description if present") + content_markdown: Optional[str] = Field( + None, description="Markdown content for text elements" + ) + + +class SlideOutline(BaseModel): + """Outline representation of a slide.""" + + slide_name: Optional[str] = Field(None, description="Slide name from speaker notes") + slide_id: str = Field(description="Slide object ID") + elements: List[ElementOutline] = Field(default_factory=list) + + +class PresentationOutline(BaseModel): + """Outline representation of a presentation.""" + + presentation_id: str = Field(description="Presentation ID") + title: str = Field(description="Presentation title") + slides: List[SlideOutline] = Field(default_factory=list) + + +class SuccessResponse(BaseModel): + """Success response for modification operations.""" + + success: bool = True + message: str = Field(description="Success message") + details: Dict[str, Any] = Field( + default_factory=dict, description="Additional details about the operation" + ) diff --git a/gslides_api/mcp/server.py b/gslides_api/mcp/server.py new file mode 100644 index 0000000..d163892 --- /dev/null +++ b/gslides_api/mcp/server.py @@ -0,0 +1,822 @@ +"""MCP server for gslides-api. + +This module provides an MCP server that exposes Google Slides operations as tools. +""" + +import argparse +import base64 +import json +import logging +import os +import re +import sys +import tempfile +import traceback +import uuid +from typing import Any, Dict, Optional + +from mcp.server import FastMCP + +from gslides_api.client import GoogleAPIClient +from gslides_api.domain.domain import ( + Color, + DashStyle, + Outline, + OutlineFill, + RgbColor, + SolidFill, + ThumbnailSize, + Weight, +) +from gslides_api.element.base import ElementKind +from gslides_api.element.element import ImageElement +from gslides_api.element.shape import ShapeElement +from gslides_api.presentation import Presentation +from gslides_api.request.request import UpdateShapePropertiesRequest + +from .models import ( + ErrorResponse, + OutputFormat, + PresentationOutline, + SlideOutline, + SuccessResponse, + ThumbnailSizeOption, +) +from .utils import ( + build_element_outline, + build_presentation_outline, + build_slide_outline, + element_not_found_error, + find_element_by_name, + find_slide_by_name, + get_available_element_names, + get_available_slide_names, + get_slide_name, + parse_presentation_id, + presentation_error, + slide_not_found_error, + validation_error, +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Global API client factory - initialized with auto_flush=True for the factory itself +# Child clients created for each request will have auto_flush=False +_api_client_factory: Optional[GoogleAPIClient] = None + +# Default output format - can be overridden via CLI arg +DEFAULT_OUTPUT_FORMAT: OutputFormat = OutputFormat.RAW + + +def get_api_client() -> GoogleAPIClient: + """Get a new API client for the current request. + + Creates a child client with isolated batch state that shares the + initialized Google API services from the factory client. This allows + concurrent tool invocations without corrupting shared batch state. + """ + if _api_client_factory is None: + raise RuntimeError("API client not initialized. Call initialize_server() first.") + return _api_client_factory.create_child_client(auto_flush=False) + + +def initialize_server(credential_path: str, default_format: OutputFormat = OutputFormat.RAW): + """Initialize the MCP server with credentials. + + Args: + credential_path: Path to the Google API credentials directory + default_format: Default output format for tools + """ + global _api_client_factory, DEFAULT_OUTPUT_FORMAT + + # Create factory client with auto_flush=True (default behavior for non-MCP use) + _api_client_factory = GoogleAPIClient(auto_flush=True) + + # Initialize credentials on the factory client + _api_client_factory.initialize_credentials(credential_path) + + # Set the global api_client in the gslides_api.client module for backward compatibility + import gslides_api.client + + gslides_api.client.api_client = _api_client_factory + + DEFAULT_OUTPUT_FORMAT = default_format + logger.info(f"MCP server initialized with credentials from {credential_path}") + logger.info(f"Default output format: {default_format.value}") + + +# Create the MCP server +mcp = FastMCP("gslides-api") + + +def _get_effective_format(how: Optional[str]) -> OutputFormat: + """Get the effective output format, using default if not specified.""" + if how is None: + return DEFAULT_OUTPUT_FORMAT + try: + return OutputFormat(how) + except ValueError: + return DEFAULT_OUTPUT_FORMAT + + +def _format_response(data: Any, error: Optional[ErrorResponse] = None) -> str: + """Format a response as JSON string.""" + if error is not None: + return json.dumps(error.model_dump(), indent=2) + if hasattr(data, "model_dump"): + return json.dumps(data.model_dump(), indent=2) + return json.dumps(data, indent=2, default=str) + + +# ============================================================================= +# QUERY TOOLS +# ============================================================================= + + +@mcp.tool() +def get_presentation( + presentation_id_or_url: str, + how: str = None, +) -> str: + """Get a full presentation by URL or deck ID. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + how: Output format - 'raw' (Google API JSON), 'domain' (model_dump), or 'outline' (condensed) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + format_type = _get_effective_format(how) + client = get_api_client() + + try: + if format_type == OutputFormat.RAW: + # Get raw JSON from Google API + result = client.get_presentation_json(pres_id) + client.flush_batch_update() + return _format_response(result) + + elif format_type == OutputFormat.DOMAIN: + # Get domain object and dump + presentation = Presentation.from_id(pres_id, api_client=client) + client.flush_batch_update() + return _format_response(presentation.model_dump()) + + else: # OUTLINE + presentation = Presentation.from_id(pres_id, api_client=client) + client.flush_batch_update() + outline = build_presentation_outline(presentation) + return _format_response(outline) + + except Exception as e: + logger.error(f"Error getting presentation: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def get_slide( + presentation_id_or_url: str, + slide_name: str, + how: str = None, +) -> str: + """Get a single slide by name (first line of speaker notes). + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes, stripped) + how: Output format - 'raw' (Google API JSON), 'domain' (model_dump), or 'outline' (condensed) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + format_type = _get_effective_format(how) + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + if format_type == OutputFormat.RAW: + result = client.get_slide_json(pres_id, slide.objectId) + client.flush_batch_update() + return _format_response(result) + + elif format_type == OutputFormat.DOMAIN: + client.flush_batch_update() + return _format_response(slide.model_dump()) + + else: # OUTLINE + client.flush_batch_update() + outline = build_slide_outline(slide) + return _format_response(outline) + + except Exception as e: + logger.error(f"Error getting slide: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def get_element( + presentation_id_or_url: str, + slide_name: str, + element_name: str, + how: str = None, +) -> str: + """Get a single element by slide name and element name (alt-title). + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes) + element_name: Element name (from alt-text title, stripped) + how: Output format - 'raw' (Google API JSON), 'domain' (model_dump), or 'outline' (condensed) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + format_type = _get_effective_format(how) + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + element = find_element_by_name(slide, element_name) + + if element is None: + available = get_available_element_names(slide) + client.flush_batch_update() + return _format_response(None, element_not_found_error(pres_id, slide_name, element_name, available)) + + client.flush_batch_update() + + if format_type == OutputFormat.RAW: + # For raw, we return the element's API format + return _format_response(element.to_api_format() if hasattr(element, "to_api_format") else element.model_dump()) + + elif format_type == OutputFormat.DOMAIN: + return _format_response(element.model_dump()) + + else: # OUTLINE + outline = build_element_outline(element) + return _format_response(outline) + + except Exception as e: + logger.error(f"Error getting element: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def get_slide_thumbnail( + presentation_id_or_url: str, + slide_name: str, + add_text_box_borders: bool = False, + size: str = "LARGE", +) -> str: + """Get a slide thumbnail image, optionally with black borders around text boxes. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes) + add_text_box_borders: Add 1pt black outlines to all text boxes + size: Thumbnail size - 'SMALL' (200px), 'MEDIUM' (800px), or 'LARGE' (1600px) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + # Validate size + try: + thumbnail_size = ThumbnailSize[size.upper()] + except KeyError: + return _format_response( + None, + validation_error("size", f"Invalid size '{size}'. Must be SMALL, MEDIUM, or LARGE", size), + ) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + if add_text_box_borders: + # Create a temporary copy, add borders, get thumbnail, delete copy + copy_result = client.copy_presentation(pres_id, f"_temp_thumbnail_{pres_id}") + temp_pres_id = copy_result["id"] + + try: + # Load the temp presentation + temp_presentation = Presentation.from_id(temp_pres_id, api_client=client) + + # Find the same slide in the copy + temp_slide = find_slide_by_name(temp_presentation, slide_name) + if temp_slide is None: + # Fall back to finding by index + slide_index = presentation.slides.index(slide) + temp_slide = temp_presentation.slides[slide_index] + + # Add black borders to all shape elements + black_outline = Outline( + outlineFill=OutlineFill( + solidFill=SolidFill( + color=Color(rgbColor=RgbColor(red=0.0, green=0.0, blue=0.0)), + alpha=1.0, + ) + ), + weight=Weight(magnitude=1.0, unit="PT"), + dashStyle=DashStyle.SOLID, + ) + + for element in temp_slide.page_elements_flat: + if element.type == ElementKind.SHAPE: + from gslides_api.domain.text import ShapeProperties + + update_request = UpdateShapePropertiesRequest( + objectId=element.objectId, + shapeProperties=ShapeProperties(outline=black_outline), + fields="outline", + ) + client.batch_update([update_request], temp_pres_id) + + client.flush_batch_update() + + # Get thumbnail from the temp slide + thumbnail = temp_slide.thumbnail(size=thumbnail_size, api_client=client) + client.flush_batch_update() + + finally: + # Always clean up the temp presentation + try: + client.delete_file(temp_pres_id) + except Exception as cleanup_error: + logger.warning(f"Failed to delete temp presentation: {cleanup_error}") + else: + # Just get the thumbnail directly + thumbnail = slide.thumbnail(size=thumbnail_size, api_client=client) + client.flush_batch_update() + + # Save thumbnail to temp file with unique name to avoid concurrent collisions + image_data = thumbnail.payload + + # Sanitize slide_name for filename (replace unsafe chars with underscore) + safe_slide_name = re.sub(r'[^\w\-]', '_', slide_name) + unique_suffix = uuid.uuid4().hex[:8] + filename = f"{pres_id}_{safe_slide_name}_{unique_suffix}_thumbnail.png" + file_path = os.path.join(tempfile.gettempdir(), filename) + + with open(file_path, 'wb') as f: + f.write(image_data) + + result = { + "success": True, + "file_path": file_path, + "slide_name": slide_name, + "slide_id": slide.objectId, + "width": thumbnail.width, + "height": thumbnail.height, + "mime_type": thumbnail.mime_type, + } + return _format_response(result) + + except Exception as e: + logger.error(f"Error getting thumbnail: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +# ============================================================================= +# MARKDOWN TOOLS +# ============================================================================= + + +@mcp.tool() +def read_element_markdown( + presentation_id_or_url: str, + slide_name: str, + element_name: str, +) -> str: + """Read the text content of a shape element as markdown. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes) + element_name: Element name (text box alt-title) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + element = find_element_by_name(slide, element_name) + + if element is None: + available = get_available_element_names(slide) + client.flush_batch_update() + return _format_response(None, element_not_found_error(pres_id, slide_name, element_name, available)) + + # Check if it's a shape element + if not isinstance(element, ShapeElement): + client.flush_batch_update() + return _format_response( + None, + validation_error( + "element_name", + f"Element '{element_name}' is not a text element (type: {element.type.value})", + element_name, + ), + ) + + markdown_content = element.read_text(as_markdown=True) + client.flush_batch_update() + + result = { + "success": True, + "element_name": element_name, + "element_id": element.objectId, + "markdown": markdown_content, + } + return _format_response(result) + + except Exception as e: + logger.error(f"Error reading element markdown: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def write_element_markdown( + presentation_id_or_url: str, + slide_name: str, + element_name: str, + markdown: str, +) -> str: + """Write markdown content to a shape element (text box). + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes) + element_name: Element name (text box alt-title) + markdown: Markdown content to write + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + element = find_element_by_name(slide, element_name) + + if element is None: + available = get_available_element_names(slide) + client.flush_batch_update() + return _format_response(None, element_not_found_error(pres_id, slide_name, element_name, available)) + + # Check if it's a shape element + if not isinstance(element, ShapeElement): + client.flush_batch_update() + return _format_response( + None, + validation_error( + "element_name", + f"Element '{element_name}' is not a text element (type: {element.type.value})", + element_name, + ), + ) + + # Write the markdown content + element.write_text(markdown, as_markdown=True, api_client=client) + client.flush_batch_update() + + result = SuccessResponse( + message=f"Successfully wrote markdown to element '{element_name}'", + details={ + "element_id": element.objectId, + "slide_name": slide_name, + "content_length": len(markdown), + }, + ) + return _format_response(result) + + except Exception as e: + logger.error(f"Error writing element markdown: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +# ============================================================================= +# IMAGE TOOLS +# ============================================================================= + + +@mcp.tool() +def replace_element_image( + presentation_id_or_url: str, + slide_name: str, + element_name: str, + image_url: str, +) -> str: + """Replace an image element with a new image from URL. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name (first line of speaker notes) + element_name: Element name (image alt-title) + image_url: URL of new image + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + element = find_element_by_name(slide, element_name) + + if element is None: + available = get_available_element_names(slide) + client.flush_batch_update() + return _format_response(None, element_not_found_error(pres_id, slide_name, element_name, available)) + + # Check if it's an image element + if not isinstance(element, ImageElement): + client.flush_batch_update() + return _format_response( + None, + validation_error( + "element_name", + f"Element '{element_name}' is not an image element (type: {element.type.value})", + element_name, + ), + ) + + # Replace the image + element.replace_image(url=image_url, api_client=client) + client.flush_batch_update() + + result = SuccessResponse( + message=f"Successfully replaced image in element '{element_name}'", + details={ + "element_id": element.objectId, + "slide_name": slide_name, + "new_image_url": image_url, + }, + ) + return _format_response(result) + + except Exception as e: + logger.error(f"Error replacing image: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +# ============================================================================= +# SLIDE MANIPULATION TOOLS +# ============================================================================= + + +@mcp.tool() +def copy_slide( + presentation_id_or_url: str, + slide_name: str, + insertion_index: int = None, +) -> str: + """Duplicate a slide within the presentation. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name to copy + insertion_index: Position for new slide (None = after original) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + # Duplicate the slide + new_slide = slide.duplicate(api_client=client) + + # Move to specified position if provided + if insertion_index is not None: + new_slide.move(insertion_index, api_client=client) + + client.flush_batch_update() + + # Get the name of the new slide (will be same speaker notes initially) + new_slide_name = get_slide_name(new_slide) + + result = SuccessResponse( + message=f"Successfully copied slide '{slide_name}'", + details={ + "original_slide_id": slide.objectId, + "new_slide_id": new_slide.objectId, + "new_slide_name": new_slide_name, + "insertion_index": insertion_index, + }, + ) + return _format_response(result) + + except Exception as e: + logger.error(f"Error copying slide: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def move_slide( + presentation_id_or_url: str, + slide_name: str, + insertion_index: int, +) -> str: + """Move a slide to a new position in the presentation. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name to move + insertion_index: New position (0-indexed) + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + # Get current index for reporting + current_index = presentation.slides.index(slide) + + # Move the slide + slide.move(insertion_index, api_client=client) + client.flush_batch_update() + + result = SuccessResponse( + message=f"Successfully moved slide '{slide_name}' to position {insertion_index}", + details={ + "slide_id": slide.objectId, + "previous_index": current_index, + "new_index": insertion_index, + }, + ) + return _format_response(result) + + except Exception as e: + logger.error(f"Error moving slide: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +@mcp.tool() +def delete_slide( + presentation_id_or_url: str, + slide_name: str, +) -> str: + """Delete a slide from the presentation. + + Args: + presentation_id_or_url: Google Slides URL or presentation ID + slide_name: Slide name to delete + """ + try: + pres_id = parse_presentation_id(presentation_id_or_url) + except ValueError as e: + return _format_response(None, validation_error("presentation_id_or_url", str(e), presentation_id_or_url)) + + client = get_api_client() + + try: + presentation = Presentation.from_id(pres_id, api_client=client) + slide = find_slide_by_name(presentation, slide_name) + + if slide is None: + available = get_available_slide_names(presentation) + client.flush_batch_update() + return _format_response(None, slide_not_found_error(pres_id, slide_name, available)) + + slide_id = slide.objectId + + # Delete the slide + slide.delete(api_client=client) + client.flush_batch_update() + + result = SuccessResponse( + message=f"Successfully deleted slide '{slide_name}'", + details={ + "deleted_slide_id": slide_id, + }, + ) + return _format_response(result) + + except Exception as e: + logger.error(f"Error deleting slide: {e}\n{traceback.format_exc()}") + return _format_response(None, presentation_error(pres_id, e)) + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + + +def main(): + """Main entry point for the MCP server.""" + parser = argparse.ArgumentParser(description="gslides-api MCP Server") + parser.add_argument( + "--credential-path", + type=str, + default=os.environ.get("GSLIDES_CREDENTIALS_PATH"), + help="Path to Google API credentials directory (or set GSLIDES_CREDENTIALS_PATH env var)", + ) + parser.add_argument( + "--default-format", + type=str, + choices=["raw", "domain", "outline"], + default="raw", + help="Default output format for tools (default: raw)", + ) + + args = parser.parse_args() + + if not args.credential_path: + print( + "Error: Credential path required. Use --credential-path or set GSLIDES_CREDENTIALS_PATH", + file=sys.stderr, + ) + sys.exit(1) + + # Initialize the server + default_format = OutputFormat(args.default_format) + initialize_server(args.credential_path, default_format) + + # Run the MCP server + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/gslides_api/mcp/utils.py b/gslides_api/mcp/utils.py new file mode 100644 index 0000000..e8ba946 --- /dev/null +++ b/gslides_api/mcp/utils.py @@ -0,0 +1,376 @@ +"""Utility functions for the gslides-api MCP server.""" + +import re +from typing import List, Optional, Tuple + +from gslides_api.element.base import ElementKind, PageElementBase +from gslides_api.element.element import PageElement +from gslides_api.page.slide import Slide +from gslides_api.presentation import Presentation + +from .models import ElementOutline, ErrorResponse, PresentationOutline, SlideOutline + +# Pattern to match Google Slides URLs and extract the presentation ID +# Matches: https://docs.google.com/presentation/d/{ID}/edit +# https://docs.google.com/presentation/d/{ID} +# https://docs.google.com/presentation/d/{ID}/edit#slide=id.p +GOOGLE_SLIDES_URL_PATTERN = re.compile( + r"^https?://docs\.google\.com/presentation/d/([a-zA-Z0-9_-]+)(?:/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$" +) + + +def parse_presentation_id(url_or_id: str) -> str: + """Extract presentation ID from a Google Slides URL or return the ID as-is. + + Args: + url_or_id: Either a full Google Slides URL or a presentation ID + + Returns: + The presentation ID + + Raises: + ValueError: If the input looks like a URL but doesn't match the expected pattern + """ + url_or_id = url_or_id.strip() + + # Check if it looks like a URL + if url_or_id.startswith("http://") or url_or_id.startswith("https://"): + match = GOOGLE_SLIDES_URL_PATTERN.match(url_or_id) + if match: + return match.group(1) + else: + raise ValueError( + f"Invalid Google Slides URL format: {url_or_id}. " + "Expected format: https://docs.google.com/presentation/d/{ID}/edit" + ) + + # Assume it's already a presentation ID + return url_or_id + + +def get_slide_name(slide: Slide) -> Optional[str]: + """Get the name of a slide from its speaker notes. + + The slide name is the first line of the speaker notes, stripped. + + Args: + slide: The slide to get the name from + + Returns: + The slide name, or None if no speaker notes + """ + try: + speaker_notes = slide.speaker_notes + if speaker_notes is None: + return None + text = speaker_notes.read_text() + if not text: + return None + text = text.strip() + if not text: + return None + # First line only + first_line = text.split("\n")[0].strip() + return first_line if first_line else None + except Exception: + return None + + +def get_element_name(element: PageElementBase) -> Optional[str]: + """Get the name of an element from its alt-text title. + + Args: + element: The element to get the name from + + Returns: + The element name, or None if no alt-text title + """ + if hasattr(element, "title") and element.title: + return element.title.strip() or None + return None + + +def get_element_alt_description(element: PageElementBase) -> Optional[str]: + """Get the alt-text description of an element. + + Args: + element: The element to get the description from + + Returns: + The alt-text description, or None if not present + """ + if hasattr(element, "description") and element.description: + return element.description.strip() or None + return None + + +def find_slide_by_name(presentation: Presentation, slide_name: str) -> Optional[Slide]: + """Find a slide by its name (first line of speaker notes). + + Args: + presentation: The presentation to search in + slide_name: The slide name to find + + Returns: + The slide if found, None otherwise + """ + for slide in presentation.slides: + name = get_slide_name(slide) + if name == slide_name: + return slide + return None + + +def find_element_by_name( + slide: Slide, element_name: str +) -> Optional[PageElement]: + """Find an element on a slide by its name (alt-text title). + + Args: + slide: The slide to search in + element_name: The element name to find + + Returns: + The element if found, None otherwise + """ + for element in slide.page_elements_flat: + name = get_element_name(element) + if name == element_name: + return element + return None + + +def get_available_slide_names(presentation: Presentation) -> List[str]: + """Get a list of all slide names in a presentation. + + Args: + presentation: The presentation to get slide names from + + Returns: + List of slide names (or slide IDs for unnamed slides) + """ + names = [] + for i, slide in enumerate(presentation.slides): + name = get_slide_name(slide) + if name: + names.append(name) + else: + names.append(f"(unnamed slide at index {i}, id: {slide.objectId})") + return names + + +def get_available_element_names(slide: Slide) -> List[str]: + """Get a list of all element names on a slide. + + Args: + slide: The slide to get element names from + + Returns: + List of element names (or element IDs for unnamed elements) + """ + names = [] + for element in slide.page_elements_flat: + name = get_element_name(element) + if name: + names.append(name) + else: + element_type = element.type.value if hasattr(element, "type") else "unknown" + names.append(f"(unnamed {element_type}, id: {element.objectId})") + return names + + +def get_element_type_string(element: PageElement) -> str: + """Get a string representation of the element type. + + Args: + element: The element to get the type from + + Returns: + String representation of the element type + """ + if hasattr(element, "type") and isinstance(element.type, ElementKind): + return element.type.value + return "unknown" + + +def get_element_markdown_content(element: PageElement) -> Optional[str]: + """Get the markdown content of a shape element. + + Args: + element: The element to get content from + + Returns: + Markdown content if it's a text element, None otherwise + """ + if hasattr(element, "type") and element.type == ElementKind.SHAPE: + try: + # Try to read text as markdown + if hasattr(element, "read_text"): + return element.read_text(as_markdown=True) + except Exception: + pass + return None + + +def build_element_outline(element: PageElement) -> ElementOutline: + """Build an outline representation of an element. + + Args: + element: The element to build outline from + + Returns: + ElementOutline representation + """ + return ElementOutline( + element_name=get_element_name(element), + element_id=element.objectId, + type=get_element_type_string(element), + alt_description=get_element_alt_description(element), + content_markdown=get_element_markdown_content(element), + ) + + +def build_slide_outline(slide: Slide) -> SlideOutline: + """Build an outline representation of a slide. + + Args: + slide: The slide to build outline from + + Returns: + SlideOutline representation + """ + elements = [build_element_outline(e) for e in slide.page_elements_flat] + return SlideOutline( + slide_name=get_slide_name(slide), + slide_id=slide.objectId, + elements=elements, + ) + + +def build_presentation_outline(presentation: Presentation) -> PresentationOutline: + """Build an outline representation of a presentation. + + Args: + presentation: The presentation to build outline from + + Returns: + PresentationOutline representation + """ + slides = [build_slide_outline(s) for s in presentation.slides] + return PresentationOutline( + presentation_id=presentation.presentationId, + title=presentation.title or "Untitled", + slides=slides, + ) + + +def create_error_response( + error_type: str, + message: str, + **details, +) -> ErrorResponse: + """Create a standardized error response. + + Args: + error_type: The type of error + message: Human-readable error message + **details: Additional context to include + + Returns: + ErrorResponse instance + """ + return ErrorResponse( + error_type=error_type, + message=message, + details=details, + ) + + +def slide_not_found_error( + presentation_id: str, slide_name: str, available_slides: List[str] +) -> ErrorResponse: + """Create a slide not found error response. + + Args: + presentation_id: The presentation ID + slide_name: The slide name that was not found + available_slides: List of available slide names + + Returns: + ErrorResponse for slide not found + """ + return create_error_response( + error_type="SlideNotFound", + message=f"No slide found with name '{slide_name}'", + presentation_id=presentation_id, + searched_slide_name=slide_name, + available_slides=available_slides, + ) + + +def element_not_found_error( + presentation_id: str, + slide_name: str, + element_name: str, + available_elements: List[str], +) -> ErrorResponse: + """Create an element not found error response. + + Args: + presentation_id: The presentation ID + slide_name: The slide name + element_name: The element name that was not found + available_elements: List of available element names + + Returns: + ErrorResponse for element not found + """ + return create_error_response( + error_type="ElementNotFound", + message=f"No element found with name '{element_name}' on slide '{slide_name}'", + presentation_id=presentation_id, + slide_name=slide_name, + searched_element_name=element_name, + available_elements=available_elements, + ) + + +def presentation_error(presentation_id: str, error: Exception) -> ErrorResponse: + """Create a presentation access error response. + + Args: + presentation_id: The presentation ID + error: The exception that occurred + + Returns: + ErrorResponse for presentation access error + """ + error_type = type(error).__name__ + return create_error_response( + error_type=f"PresentationError:{error_type}", + message=f"Failed to access presentation: {str(error)}", + presentation_id=presentation_id, + exception_type=error_type, + exception_message=str(error), + ) + + +def validation_error(field: str, message: str, value: str = None) -> ErrorResponse: + """Create a validation error response. + + Args: + field: The field that failed validation + message: Description of the validation failure + value: The invalid value (optional) + + Returns: + ErrorResponse for validation error + """ + details = {"field": field} + if value is not None: + details["invalid_value"] = value + return create_error_response( + error_type="ValidationError", + message=message, + **details, + ) diff --git a/poetry.lock b/poetry.lock index 4921538..24457ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,6 +12,37 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -36,6 +67,104 @@ files = [ {file = "certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -138,18 +267,111 @@ files = [ {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "sys_platform != \"emscripten\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "cryptography" +version = "46.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] [[package]] name = "google-api-core" @@ -278,6 +500,40 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4 [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0)"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + [[package]] name = "httplib2" version = "0.22.0" @@ -293,6 +549,43 @@ files = [ [package.dependencies] pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, + {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, +] + [[package]] name = "idna" version = "3.10" @@ -320,6 +613,43 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "marko" version = "2.1.4" @@ -337,6 +667,39 @@ codehilite = ["pygments"] repr = ["objprint"] toc = ["python-slugify"] +[[package]] +name = "mcp" +version = "1.25.0" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"}, + {file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27.1" +httpx-sse = ">=0.4" +jsonschema = ">=4.20.0" +pydantic = ">=2.11.0,<3.0.0" +pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} +python-multipart = ">=0.0.9" +pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} +sse-starlette = ">=1.6.1" +starlette = ">=0.27" +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" +uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] +rich = ["rich (>=13.9.4)"] +ws = ["websockets (>=15.0.1)"] + [[package]] name = "numpy" version = "2.3.2" @@ -717,6 +1080,19 @@ files = [ [package.dependencies] pyasn1 = ">=0.6.1,<0.7.0" +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -851,6 +1227,30 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.12.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809"}, + {file = "pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pygments" version = "2.19.2" @@ -866,6 +1266,27 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyparsing" version = "3.2.3" @@ -919,6 +1340,33 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.21" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090"}, + {file = "python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92"}, +] + [[package]] name = "pytz" version = "2025.2" @@ -932,6 +1380,54 @@ files = [ {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, ] +[[package]] +name = "pywin32" +version = "311" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, + {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, + {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, + {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, + {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, + {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +] + +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.4" @@ -973,6 +1469,131 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + [[package]] name = "rsa" version = "4.9.1" @@ -1001,6 +1622,47 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "sse-starlette" +version = "3.2.0" +description = "SSE plugin for Starlette" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf"}, + {file = "sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422"}, +] + +[package.dependencies] +anyio = ">=4.7.0" +starlette = ">=0.49.1" + +[package.extras] +daphne = ["daphne (>=4.2.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "uvicorn (>=0.34.0)"] +granian = ["granian (>=2.3.1)"] +uvicorn = ["uvicorn (>=0.34.0)"] + +[[package]] +name = "starlette" +version = "0.52.1" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, + {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "typeguard" version = "4.4.4" @@ -1086,6 +1748,26 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "uvicorn" +version = "0.40.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, + {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + [extras] image = ["pillow"] tables = ["pandas"] @@ -1093,4 +1775,4 @@ tables = ["pandas"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "fac5d12a54cfc54cca70d299d6ac28615444c56d785bf5b05e46b4446cda7fa2" +content-hash = "0238b68ea61296234b0d7e29331387ee7256f8d4ec149c545ad611b522db2863" diff --git a/pyproject.toml b/pyproject.toml index d7a64b1..c1f9a24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "gslides-api" -version = "0.2.13" +version = "0.3.3" description = "A Python library for working with Google Slides API using Pydantic domain objects" authors = ["motley.ai "] license = "MIT" @@ -19,11 +19,15 @@ requests = "^2.32.4" typeguard = "^4.4.4" pillow = {version = "^10.0.0", optional = true} pandas = {version = "^2.0.0", optional = true} +mcp = {version = "^1.0.0"} [tool.poetry.extras] image = ["pillow"] tables = ["pandas"] +[tool.poetry.scripts] +gslides-mcp = "gslides_api.mcp.server:main" + [tool.poetry.group.dev.dependencies] pytest = "^8.3.4" diff --git a/tests/mcp_tests/__init__.py b/tests/mcp_tests/__init__.py new file mode 100644 index 0000000..c067a3e --- /dev/null +++ b/tests/mcp_tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the gslides-api MCP server.""" diff --git a/tests/mcp_tests/test_concurrency.py b/tests/mcp_tests/test_concurrency.py new file mode 100644 index 0000000..96a8056 --- /dev/null +++ b/tests/mcp_tests/test_concurrency.py @@ -0,0 +1,254 @@ +"""Tests for concurrency safety of the GoogleAPIClient child client pattern. + +This module tests that child clients properly share Google API services while +maintaining isolated batch state to enable safe concurrent operations. +""" + +import asyncio +from unittest.mock import Mock, patch + +import pytest + +from gslides_api.client import GoogleAPIClient +from gslides_api.request.parent import GSlidesAPIRequest + + +class MockRequest(GSlidesAPIRequest): + """Mock request class for testing.""" + + request_id: str + + def to_request(self): + return [{"mockRequest": {"id": self.request_id}}] + + +class TestCreateChildClient: + """Test cases for the create_child_client method.""" + + def test_create_child_client_shares_services(self): + """Test that child client shares service objects with parent.""" + parent = GoogleAPIClient() + + # Mock all required components + mock_credentials = Mock() + mock_sheet_service = Mock() + mock_slide_service = Mock() + mock_drive_service = Mock() + + parent.crdtls = mock_credentials + parent.sht_srvc = mock_sheet_service + parent.sld_srvc = mock_slide_service + parent.drive_srvc = mock_drive_service + + child = parent.create_child_client() + + # Services should be the exact same objects (identity, not equality) + assert child.crdtls is parent.crdtls + assert child.sht_srvc is parent.sht_srvc + assert child.sld_srvc is parent.sld_srvc + assert child.drive_srvc is parent.drive_srvc + + def test_create_child_client_has_independent_batch_state(self): + """Test that child client has its own independent batch state.""" + parent = GoogleAPIClient() + + # Initialize parent + parent.crdtls = Mock() + parent.sht_srvc = Mock() + parent.sld_srvc = Mock() + parent.drive_srvc = Mock() + + # Add some state to parent + parent.pending_batch_requests.append(MockRequest(request_id="parent_request")) + parent.pending_presentation_id = "parent_presentation" + + child = parent.create_child_client() + + # Child should have empty batch state + assert child.pending_batch_requests == [] + assert child.pending_presentation_id is None + + # Modifying child should not affect parent + child.pending_batch_requests.append(MockRequest(request_id="child_request")) + child.pending_presentation_id = "child_presentation" + + assert len(parent.pending_batch_requests) == 1 + assert parent.pending_batch_requests[0].request_id == "parent_request" + assert parent.pending_presentation_id == "parent_presentation" + + def test_create_child_client_fails_if_not_initialized(self): + """Test that creating child from uninitialized parent raises RuntimeError.""" + parent = GoogleAPIClient() + + # Parent is not initialized + assert not parent.is_initialized + + with pytest.raises(RuntimeError) as exc_info: + parent.create_child_client() + + assert "Cannot create child client from uninitialized parent client" in str(exc_info.value) + + def test_child_client_inherits_backoff_settings(self): + """Test that child client inherits backoff settings from parent.""" + parent = GoogleAPIClient( + auto_flush=True, + initial_wait_s=120, + n_backoffs=8, + ) + + # Initialize parent + parent.crdtls = Mock() + parent.sht_srvc = Mock() + parent.sld_srvc = Mock() + parent.drive_srvc = Mock() + + child = parent.create_child_client(auto_flush=False) + + # Backoff settings should be inherited + assert child.initial_wait_s == 120 + assert child.n_backoffs == 8 + # auto_flush should be what was passed to create_child_client + assert child.auto_flush is False + + def test_child_client_auto_flush_defaults_to_false(self): + """Test that child client auto_flush defaults to False.""" + parent = GoogleAPIClient(auto_flush=True) + + parent.crdtls = Mock() + parent.sht_srvc = Mock() + parent.sld_srvc = Mock() + parent.drive_srvc = Mock() + + child = parent.create_child_client() + + assert child.auto_flush is False + + def test_child_client_can_set_auto_flush_true(self): + """Test that child client can be created with auto_flush=True.""" + parent = GoogleAPIClient(auto_flush=False) + + parent.crdtls = Mock() + parent.sht_srvc = Mock() + parent.sld_srvc = Mock() + parent.drive_srvc = Mock() + + child = parent.create_child_client(auto_flush=True) + + assert child.auto_flush is True + + +class TestConcurrentBatchUpdates: + """Test that concurrent batch operations don't interfere with each other.""" + + def setup_method(self): + """Set up test fixtures before each test method.""" + # Create parent client with mocked services + self.parent = GoogleAPIClient() + self.parent.crdtls = Mock() + + # Set up mock slide service + self.mock_slide_service = Mock() + self.mock_presentations = Mock() + self.mock_batch_update = Mock() + + self.mock_slide_service.presentations.return_value = self.mock_presentations + self.mock_presentations.batchUpdate.return_value = self.mock_batch_update + self.mock_batch_update.execute.return_value = { + "replies": [{"duplicateObject": {"objectId": "new_id"}}] + } + + self.parent.sht_srvc = Mock() + self.parent.sld_srvc = self.mock_slide_service + self.parent.drive_srvc = Mock() + + def test_concurrent_batch_updates_isolated(self): + """Test that batch updates in different child clients don't interfere.""" + child1 = self.parent.create_child_client(auto_flush=False) + child2 = self.parent.create_child_client(auto_flush=False) + + # Add requests to different children for different presentations + child1.batch_update( + [MockRequest(request_id="req1")], + "presentation_1" + ) + child2.batch_update( + [MockRequest(request_id="req2")], + "presentation_2" + ) + + # Each child should have its own state + assert len(child1.pending_batch_requests) == 1 + assert child1.pending_batch_requests[0].request_id == "req1" + assert child1.pending_presentation_id == "presentation_1" + + assert len(child2.pending_batch_requests) == 1 + assert child2.pending_batch_requests[0].request_id == "req2" + assert child2.pending_presentation_id == "presentation_2" + + # Flushing one should not affect the other + child1.flush_batch_update() + + assert len(child1.pending_batch_requests) == 0 + assert child1.pending_presentation_id is None + + # child2 should be unaffected + assert len(child2.pending_batch_requests) == 1 + assert child2.pending_batch_requests[0].request_id == "req2" + assert child2.pending_presentation_id == "presentation_2" + + def test_multiple_children_share_same_service(self): + """Test that multiple children use the same underlying slide service.""" + child1 = self.parent.create_child_client() + child2 = self.parent.create_child_client() + + # Both children should use the same slide service instance + assert child1.sld_srvc is child2.sld_srvc + assert child1.sld_srvc is self.parent.sld_srvc + + def test_child_client_is_initialized(self): + """Test that child client reports as initialized.""" + child = self.parent.create_child_client() + + assert child.is_initialized is True + + +class TestSharedServicesParameter: + """Test the _shared_services parameter directly.""" + + def test_shared_services_parameter_copies_services(self): + """Test that _shared_services parameter copies services from source.""" + source = GoogleAPIClient() + source.crdtls = Mock() + source.sht_srvc = Mock() + source.sld_srvc = Mock() + source.drive_srvc = Mock() + + target = GoogleAPIClient(_shared_services=source) + + assert target.crdtls is source.crdtls + assert target.sht_srvc is source.sht_srvc + assert target.sld_srvc is source.sld_srvc + assert target.drive_srvc is source.drive_srvc + + def test_shared_services_none_initializes_to_none(self): + """Test that without _shared_services, services start as None.""" + client = GoogleAPIClient(_shared_services=None) + + assert client.crdtls is None + assert client.sht_srvc is None + assert client.sld_srvc is None + assert client.drive_srvc is None + + def test_shared_services_with_uninitialized_source(self): + """Test that _shared_services can be used with uninitialized source.""" + source = GoogleAPIClient() + # source is uninitialized (all services are None) + + target = GoogleAPIClient(_shared_services=source) + + # Target should also have None services + assert target.crdtls is None + assert target.sht_srvc is None + assert target.sld_srvc is None + assert target.drive_srvc is None + assert not target.is_initialized diff --git a/tests/mcp_tests/test_models.py b/tests/mcp_tests/test_models.py new file mode 100644 index 0000000..47794c1 --- /dev/null +++ b/tests/mcp_tests/test_models.py @@ -0,0 +1,226 @@ +"""Tests for gslides_api.mcp.models module.""" + +import pytest + +from gslides_api.mcp.models import ( + ElementOutline, + ErrorResponse, + OutputFormat, + PresentationOutline, + SlideOutline, + SuccessResponse, + ThumbnailSizeOption, +) + + +class TestOutputFormat: + """Tests for OutputFormat enum.""" + + def test_raw_format(self): + """Test RAW format value.""" + assert OutputFormat.RAW.value == "raw" + assert OutputFormat("raw") == OutputFormat.RAW + + def test_domain_format(self): + """Test DOMAIN format value.""" + assert OutputFormat.DOMAIN.value == "domain" + assert OutputFormat("domain") == OutputFormat.DOMAIN + + def test_outline_format(self): + """Test OUTLINE format value.""" + assert OutputFormat.OUTLINE.value == "outline" + assert OutputFormat("outline") == OutputFormat.OUTLINE + + def test_invalid_format(self): + """Test that invalid format raises ValueError.""" + with pytest.raises(ValueError): + OutputFormat("invalid") + + +class TestThumbnailSizeOption: + """Tests for ThumbnailSizeOption enum.""" + + def test_small_size(self): + """Test SMALL size value.""" + assert ThumbnailSizeOption.SMALL.value == "SMALL" + + def test_medium_size(self): + """Test MEDIUM size value.""" + assert ThumbnailSizeOption.MEDIUM.value == "MEDIUM" + + def test_large_size(self): + """Test LARGE size value.""" + assert ThumbnailSizeOption.LARGE.value == "LARGE" + + +class TestErrorResponse: + """Tests for ErrorResponse model.""" + + def test_basic_error_response(self): + """Test creating a basic error response.""" + error = ErrorResponse( + error_type="TestError", + message="Test message", + ) + assert error.error is True + assert error.error_type == "TestError" + assert error.message == "Test message" + assert error.details == {} + + def test_error_response_with_details(self): + """Test creating an error response with details.""" + error = ErrorResponse( + error_type="ValidationError", + message="Invalid input", + details={"field": "name", "value": "bad"}, + ) + assert error.details == {"field": "name", "value": "bad"} + + def test_error_response_model_dump(self): + """Test that error response can be serialized.""" + error = ErrorResponse( + error_type="TestError", + message="Test message", + details={"key": "value"}, + ) + data = error.model_dump() + assert data["error"] is True + assert data["error_type"] == "TestError" + assert data["message"] == "Test message" + assert data["details"] == {"key": "value"} + + +class TestSuccessResponse: + """Tests for SuccessResponse model.""" + + def test_basic_success_response(self): + """Test creating a basic success response.""" + response = SuccessResponse( + message="Operation completed", + ) + assert response.success is True + assert response.message == "Operation completed" + assert response.details == {} + + def test_success_response_with_details(self): + """Test creating a success response with details.""" + response = SuccessResponse( + message="Slide copied", + details={"new_slide_id": "abc123", "position": 5}, + ) + assert response.details == {"new_slide_id": "abc123", "position": 5} + + +class TestElementOutline: + """Tests for ElementOutline model.""" + + def test_minimal_element_outline(self): + """Test creating a minimal element outline.""" + outline = ElementOutline( + element_id="elem123", + type="shape", + ) + assert outline.element_id == "elem123" + assert outline.type == "shape" + assert outline.element_name is None + assert outline.alt_description is None + assert outline.content_markdown is None + + def test_full_element_outline(self): + """Test creating a full element outline.""" + outline = ElementOutline( + element_name="Title", + element_id="elem123", + type="shape", + alt_description="Main title text box", + content_markdown="# Welcome", + ) + assert outline.element_name == "Title" + assert outline.element_id == "elem123" + assert outline.type == "shape" + assert outline.alt_description == "Main title text box" + assert outline.content_markdown == "# Welcome" + + +class TestSlideOutline: + """Tests for SlideOutline model.""" + + def test_minimal_slide_outline(self): + """Test creating a minimal slide outline.""" + outline = SlideOutline( + slide_id="slide123", + ) + assert outline.slide_id == "slide123" + assert outline.slide_name is None + assert outline.elements == [] + + def test_slide_outline_with_elements(self): + """Test creating a slide outline with elements.""" + elements = [ + ElementOutline(element_id="e1", type="shape"), + ElementOutline(element_id="e2", type="image"), + ] + outline = SlideOutline( + slide_name="Introduction", + slide_id="slide123", + elements=elements, + ) + assert outline.slide_name == "Introduction" + assert len(outline.elements) == 2 + assert outline.elements[0].element_id == "e1" + + +class TestPresentationOutline: + """Tests for PresentationOutline model.""" + + def test_minimal_presentation_outline(self): + """Test creating a minimal presentation outline.""" + outline = PresentationOutline( + presentation_id="pres123", + title="My Presentation", + ) + assert outline.presentation_id == "pres123" + assert outline.title == "My Presentation" + assert outline.slides == [] + + def test_presentation_outline_with_slides(self): + """Test creating a presentation outline with slides.""" + slides = [ + SlideOutline(slide_id="s1", slide_name="Cover"), + SlideOutline(slide_id="s2", slide_name="Content"), + ] + outline = PresentationOutline( + presentation_id="pres123", + title="My Presentation", + slides=slides, + ) + assert len(outline.slides) == 2 + assert outline.slides[0].slide_name == "Cover" + assert outline.slides[1].slide_name == "Content" + + def test_presentation_outline_model_dump(self): + """Test that presentation outline can be serialized.""" + outline = PresentationOutline( + presentation_id="pres123", + title="My Presentation", + slides=[ + SlideOutline( + slide_id="s1", + slide_name="Cover", + elements=[ + ElementOutline( + element_name="Title", + element_id="e1", + type="shape", + content_markdown="# Welcome", + ) + ], + ) + ], + ) + data = outline.model_dump() + assert data["presentation_id"] == "pres123" + assert data["title"] == "My Presentation" + assert len(data["slides"]) == 1 + assert data["slides"][0]["slide_name"] == "Cover" + assert data["slides"][0]["elements"][0]["element_name"] == "Title" diff --git a/tests/mcp_tests/test_utils.py b/tests/mcp_tests/test_utils.py new file mode 100644 index 0000000..f10f703 --- /dev/null +++ b/tests/mcp_tests/test_utils.py @@ -0,0 +1,163 @@ +"""Tests for gslides_api.mcp.utils module.""" + +import pytest + +from gslides_api.mcp.utils import ( + create_error_response, + element_not_found_error, + parse_presentation_id, + slide_not_found_error, + validation_error, +) + + +class TestParsePresentationId: + """Tests for parse_presentation_id function.""" + + def test_parse_simple_id(self): + """Test parsing a simple presentation ID.""" + pres_id = "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + result = parse_presentation_id(pres_id) + assert result == pres_id + + def test_parse_url_with_edit(self): + """Test parsing a Google Slides URL with /edit.""" + url = "https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_url_without_edit(self): + """Test parsing a Google Slides URL without /edit.""" + url = "https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_url_with_slide_anchor(self): + """Test parsing a Google Slides URL with slide anchor.""" + url = "https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit#slide=id.p" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_url_with_query_params(self): + """Test parsing a Google Slides URL with query parameters.""" + url = "https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit?usp=sharing" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_url_with_query_and_anchor(self): + """Test parsing a URL with both query params and anchor.""" + url = "https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit?usp=sharing#slide=id.g123" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_http_url(self): + """Test parsing an HTTP (non-HTTPS) URL.""" + url = "http://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit" + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_url_with_whitespace(self): + """Test parsing a URL with leading/trailing whitespace.""" + url = " https://docs.google.com/presentation/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit " + result = parse_presentation_id(url) + assert result == "1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM" + + def test_parse_id_with_underscores_and_hyphens(self): + """Test parsing an ID with underscores and hyphens.""" + pres_id = "abc-123_XYZ-456_789" + result = parse_presentation_id(pres_id) + assert result == pres_id + + def test_invalid_url_format(self): + """Test that invalid URL format raises ValueError.""" + url = "https://example.com/presentation/d/123" + with pytest.raises(ValueError) as exc_info: + parse_presentation_id(url) + assert "Invalid Google Slides URL format" in str(exc_info.value) + + def test_invalid_url_no_id(self): + """Test that URL without ID raises ValueError.""" + url = "https://docs.google.com/presentation/d/" + with pytest.raises(ValueError) as exc_info: + parse_presentation_id(url) + assert "Invalid Google Slides URL format" in str(exc_info.value) + + def test_invalid_url_different_service(self): + """Test that URL for different Google service raises ValueError.""" + url = "https://docs.google.com/document/d/1bj3qEcf1P6NhShY8YC0UyEwpc_bFdrxxtijqz8hBbXM/edit" + with pytest.raises(ValueError) as exc_info: + parse_presentation_id(url) + assert "Invalid Google Slides URL format" in str(exc_info.value) + + +class TestErrorResponses: + """Tests for error response creation functions.""" + + def test_create_error_response(self): + """Test creating a basic error response.""" + error = create_error_response( + error_type="TestError", + message="Test error message", + key1="value1", + key2="value2", + ) + assert error.error is True + assert error.error_type == "TestError" + assert error.message == "Test error message" + assert error.details["key1"] == "value1" + assert error.details["key2"] == "value2" + + def test_slide_not_found_error(self): + """Test creating a slide not found error.""" + error = slide_not_found_error( + presentation_id="pres123", + slide_name="Introduction", + available_slides=["Cover", "Overview", "Conclusion"], + ) + assert error.error is True + assert error.error_type == "SlideNotFound" + assert "Introduction" in error.message + assert error.details["presentation_id"] == "pres123" + assert error.details["searched_slide_name"] == "Introduction" + assert error.details["available_slides"] == ["Cover", "Overview", "Conclusion"] + + def test_element_not_found_error(self): + """Test creating an element not found error.""" + error = element_not_found_error( + presentation_id="pres123", + slide_name="Introduction", + element_name="Title", + available_elements=["Subtitle", "Body", "Image"], + ) + assert error.error is True + assert error.error_type == "ElementNotFound" + assert "Title" in error.message + assert "Introduction" in error.message + assert error.details["presentation_id"] == "pres123" + assert error.details["slide_name"] == "Introduction" + assert error.details["searched_element_name"] == "Title" + assert error.details["available_elements"] == ["Subtitle", "Body", "Image"] + + def test_validation_error(self): + """Test creating a validation error.""" + error = validation_error( + field="presentation_id", + message="Invalid format", + value="bad-value", + ) + assert error.error is True + assert error.error_type == "ValidationError" + assert "Invalid format" in error.message + assert error.details["field"] == "presentation_id" + assert error.details["invalid_value"] == "bad-value" + + def test_validation_error_no_value(self): + """Test creating a validation error without value.""" + error = validation_error( + field="slide_name", + message="Field is required", + ) + assert error.error is True + assert error.error_type == "ValidationError" + assert error.details["field"] == "slide_name" + assert "invalid_value" not in error.details