diff --git a/.claude-plugin/.mcp.json b/.claude-plugin/.mcp.json new file mode 100644 index 0000000..9972dac --- /dev/null +++ b/.claude-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "docx": { + "command": "uvx", + "args": [ + "mcp-server-docx" + ] + } +} diff --git a/README.md b/README.md index 908d668..c7b80f4 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,50 @@ with Document.open("contract.docx", author="Editor") as doc: ]) doc.save() ``` + +## MCP Server (Optional) + +For faster performance when making multiple edits, use the MCP (Model Context Protocol) server. It keeps documents loaded between operations, making repeated edits 10-30x faster. + +### Installation + +```bash +pip install docx-editor[mcp] +``` + +### Running the Server + +```bash +# Via console script +mcp-server-docx + +# Or via Python module +python -m docx_editor_mcp +``` + +### Claude Code Configuration + +The docx-editor plugin automatically configures the MCP server when installed. For manual configuration: + +```bash +claude mcp add docx -- uvx mcp-server-docx +``` + +Or add to your `.mcp.json`: + +```json +{ + "docx": { + "command": "uvx", + "args": ["mcp-server-docx"] + } +} +``` + +### Available MCP Tools + +- **Document lifecycle**: `open_document`, `save_document`, `close_document`, `reload_document`, `force_save` +- **Track changes**: `replace_text`, `delete_text`, `insert_after`, `insert_before` +- **Comments**: `add_comment`, `list_comments`, `reply_to_comment`, `resolve_comment`, `delete_comment` +- **Revisions**: `list_revisions`, `accept_revision`, `reject_revision`, `accept_all`, `reject_all` +- **Read**: `find_text`, `count_matches`, `get_visible_text` diff --git a/docx_editor/document.py b/docx_editor/document.py index f4dfebe..6d0eae9 100644 --- a/docx_editor/document.py +++ b/docx_editor/document.py @@ -150,7 +150,7 @@ def _compute_new_ref(self, old_ref: str) -> str: new_hash = compute_paragraph_hash(p) return f"P{ref.index}#{new_hash}" - def list_paragraphs(self, max_chars: int = 80) -> list[str]: + def list_paragraphs(self, max_chars: int = 80, *, start: int = 0, limit: int = 0) -> list[str]: """List all paragraphs with hash-anchored references. Returns a list of strings like "P1#a7b2| Introduction to the..." @@ -159,20 +159,26 @@ def list_paragraphs(self, max_chars: int = 80) -> list[str]: Args: max_chars: Maximum characters for the preview text (default 80). Use 0 to get only hash refs without preview text. + start: Starting paragraph index (0-based, default 0). + limit: Maximum number of paragraphs to return (0 = all). Returns: List of hash-tagged paragraph preview strings """ self._ensure_open() paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + total = len(paragraphs) + end = total if limit == 0 else min(start + limit, total) result = [] - for i, p in enumerate(paragraphs, start=1): + for i in range(start, end): + p = paragraphs[i] + idx = i + 1 # 1-based h = compute_paragraph_hash(p) tm = build_text_map(p) preview = tm.text[:max_chars] if len(tm.text) > max_chars: preview += "..." - result.append(f"P{i}#{h}| {preview}") + result.append(f"P{idx}#{h}| {preview}") return result def get_visible_text(self) -> str: @@ -192,6 +198,112 @@ def get_visible_text(self) -> str: parts.append(tm.text) return "\n".join(parts) + def search_text(self, query: str, context_chars: int = 100) -> list[dict]: + """Search for text in the document, returning matches with context. + + Args: + query: Text to search for. + context_chars: Characters of context to include before and after each match. + + Returns: + List of dicts with keys: paragraph_ref, paragraph_index, context, match_start. + """ + self._ensure_open() + paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + results = [] + for i, p in enumerate(paragraphs): + tm = build_text_map(p) + text = tm.text + idx = 0 + while True: + pos = text.find(query, idx) + if pos == -1: + break + h = compute_paragraph_hash(p) + before = text[max(0, pos - context_chars) : pos] + after = text[pos + len(query) : pos + len(query) + context_chars] + results.append({ + "paragraph_ref": f"P{i + 1}#{h}", + "paragraph_index": i + 1, + "context": f"...{before}[{query}]{after}...", + "match_start": pos, + }) + idx = pos + 1 + return results + + def get_paragraph_text(self, paragraphs: list[str]) -> list[dict]: + """Get the full text of specific paragraphs by reference. + + Args: + paragraphs: List of paragraph references (e.g., ["P1#a7b2", "P3#cc33"]). + + Returns: + List of dicts with keys: ref, text, error (if ref is invalid). + """ + self._ensure_open() + all_paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + total = len(all_paragraphs) + results = [] + for ref_str in paragraphs: + try: + ref = ParagraphRef.parse(ref_str) + except Exception: + results.append({"ref": ref_str, "text": None, "error": f"Invalid reference: {ref_str}"}) + continue + if ref.index < 1 or ref.index > total: + results.append({"ref": ref_str, "text": None, "error": f"Paragraph index out of range: {ref.index}"}) + continue + p = all_paragraphs[ref.index - 1] + actual_hash = compute_paragraph_hash(p) + if actual_hash != ref.hash: + results.append({ + "ref": ref_str, + "text": None, + "error": f"Hash mismatch: expected {ref.hash}, got {actual_hash}", + }) + continue + tm = build_text_map(p) + results.append({"ref": ref_str, "text": tm.text, "error": None}) + return results + + def get_document_info(self) -> dict: + """Get document overview information. + + Returns: + Dict with keys: paragraph_count, word_count, headings (list of {level, text}). + """ + self._ensure_open() + paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + total_words = 0 + headings = [] + for p in paragraphs: + tm = build_text_map(p) + text = tm.text.strip() + if text: + total_words += len(text.split()) + # Check for heading style + pPr = None + for child in p.childNodes: + if child.nodeType == child.ELEMENT_NODE and child.tagName == "w:pPr": + pPr = child + break + if pPr: + for child in pPr.childNodes: + if child.nodeType == child.ELEMENT_NODE and child.tagName == "w:pStyle": + style_val = child.getAttribute("w:val") + if style_val.startswith("Heading"): + try: + level = int(style_val.replace("Heading", "")) + except ValueError: + level = 0 + if level > 0: + headings.append({"level": level, "text": text}) + return { + "paragraph_count": len(paragraphs), + "word_count": total_words, + "headings": headings, + } + def replace(self, find: str, replace_with: str, *, paragraph: str, occurrence: int = 0) -> str: """Replace text with tracked changes. diff --git a/docx_editor_mcp/__init__.py b/docx_editor_mcp/__init__.py new file mode 100644 index 0000000..2bff4f5 --- /dev/null +++ b/docx_editor_mcp/__init__.py @@ -0,0 +1,3 @@ +"""MCP Server for docx_editor with persistent DOM caching.""" + +__version__ = "0.0.1" diff --git a/docx_editor_mcp/__main__.py b/docx_editor_mcp/__main__.py new file mode 100644 index 0000000..89a9268 --- /dev/null +++ b/docx_editor_mcp/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m docx_editor_mcp`.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/docx_editor_mcp/cache.py b/docx_editor_mcp/cache.py new file mode 100644 index 0000000..9e4d11a --- /dev/null +++ b/docx_editor_mcp/cache.py @@ -0,0 +1,187 @@ +"""Document cache for MCP server with LRU eviction and external change detection.""" + +import getpass +import logging +import os +import time +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +def normalize_path(path: str) -> str: + """Normalize path to absolute canonical form. + + Expands ~, resolves relative paths, and follows symlinks. + """ + return os.path.realpath(os.path.expanduser(path)) + + +@dataclass +class CachedDocument: + """A cached document with metadata for cache management.""" + + path: str + document: Any # docx_editor.Document + author: str + mtime: float = field(default=0.0) + last_access: float = field(default_factory=time.time) + dirty: bool = field(default=False) + + def __post_init__(self): + """Normalize path and initialize mtime from file if it exists.""" + # Normalize path to ensure consistency with cache key + self.path = normalize_path(self.path) + if os.path.exists(self.path): + self.mtime = os.path.getmtime(self.path) + + def touch(self) -> None: + """Update last access time.""" + self.last_access = time.time() + + def mark_dirty(self) -> None: + """Mark document as having unsaved changes.""" + self.dirty = True + + def clear_dirty(self) -> None: + """Clear dirty flag after save.""" + self.dirty = False + + def has_external_changes(self) -> bool: + """Check if file was modified externally since cached.""" + if not os.path.exists(self.path): + return False + current_mtime = os.path.getmtime(self.path) + return current_mtime != self.mtime + + def update_mtime(self) -> None: + """Update cached mtime to match current file mtime.""" + if os.path.exists(self.path): + self.mtime = os.path.getmtime(self.path) + + +class DocumentCache: + """LRU cache for Document instances with session author memory.""" + + def __init__(self, max_documents: int = 10): + """Initialize cache with maximum document count. + + Args: + max_documents: Maximum number of documents to keep in cache. + """ + self.max_documents = max_documents + self._cache: dict[str, CachedDocument] = {} + self._session_author: str | None = None + + @property + def size(self) -> int: + """Number of documents currently in cache.""" + return len(self._cache) + + def get(self, path: str) -> CachedDocument | None: + """Get a cached document by path. + + Updates last_access time on hit. + + Args: + path: Path to the document (will be normalized). + + Returns: + CachedDocument if found, None otherwise. + """ + normalized = normalize_path(path) + cached = self._cache.get(normalized) + if cached: + cached.touch() + return cached + + def put(self, cached_doc: CachedDocument) -> None: + """Add a document to the cache. + + Evicts LRU document if cache is full. + + Args: + cached_doc: CachedDocument to add. + """ + normalized = normalize_path(cached_doc.path) + + # Evict if at capacity and this is a new document + if normalized not in self._cache and self.size >= self.max_documents: + self._evict_lru() + if self.size >= self.max_documents: + raise RuntimeError("Cache full: cannot evict LRU document (dirty save failed)") + + self._cache[normalized] = cached_doc + + def remove(self, path: str) -> CachedDocument | None: + """Remove a document from the cache. + + Args: + path: Path to the document (will be normalized). + + Returns: + The removed CachedDocument, or None if not found. + """ + normalized = normalize_path(path) + return self._cache.pop(normalized, None) + + def all(self): + """Iterate over all cached documents.""" + yield from self._cache.values() + + def _evict_lru(self) -> None: + """Evict the least recently used document. + + If the document is dirty, saves it first. If save fails, + the document is NOT evicted to prevent data loss. + """ + if not self._cache: + return + + # Find LRU document + lru_path = min(self._cache, key=lambda p: self._cache[p].last_access) + lru_doc = self._cache[lru_path] + + # Save if dirty - don't evict if save fails + if lru_doc.dirty: + try: + lru_doc.document.save() + except Exception: + logger.exception("Failed to save during eviction: %s", lru_path) + return # Don't evict if save failed + + # Remove from cache + del self._cache[lru_path] + + def get_author(self, explicit_author: str | None) -> tuple[str, bool]: + """Get author name with session memory. + + Resolution order: + 1. Explicit author parameter + 2. Previously set session author + 3. System username via getpass.getuser() + 4. "Reviewer" as fallback + + Args: + explicit_author: Explicitly provided author, or None. + + Returns: + Tuple of (author_name, is_default) where is_default hints + that Claude should ask the user. + """ + if explicit_author: + self._session_author = explicit_author + return explicit_author, False + + if self._session_author: + return self._session_author, False + + # First time - use system default + try: + default = getpass.getuser() or "Reviewer" + except Exception: + default = "Reviewer" + + self._session_author = default + return default, True diff --git a/docx_editor_mcp/server.py b/docx_editor_mcp/server.py new file mode 100644 index 0000000..676bde8 --- /dev/null +++ b/docx_editor_mcp/server.py @@ -0,0 +1,422 @@ +"""MCP Server for docx_editor with persistent DOM caching.""" + +import json +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from .cache import DocumentCache + +logger = logging.getLogger(__name__) + +SERVER_INSTRUCTIONS = ( + "Document editing server for .docx files with tracked changes, comments, and revisions. " + "Use open_document to load a file, then edit with replace_text/delete_text/insert_after/insert_before. " + "Add comments with add_comment, manage revisions with accept/reject. " + "Save changes with save_document. Documents stay cached between operations for fast repeated edits. " + "For large documents: use get_document_info for an overview, search_text to find specific content, " + "and get_paragraph_text to read targeted sections. Avoid reading the full document when possible." +) + + +@dataclass +class DocxMCPServer: + """MCP Server wrapper with document cache.""" + + name: str + cache: DocumentCache + + def shutdown(self) -> None: + """Gracefully shutdown server, saving dirty documents. + + Best-effort: continues even if individual saves fail. + """ + for cached_doc in list(self.cache.all()): + if cached_doc.dirty: + try: + cached_doc.document.save() + logger.info(f"Saved dirty document: {cached_doc.path}") + except Exception: + logger.exception("Failed to save %s", cached_doc.path) + + # Clear cache + for cached_doc in list(self.cache.all()): + self.cache.remove(cached_doc.path) + + +def create_server(max_documents: int = 10) -> DocxMCPServer: + """Create a new MCP server instance. + + Args: + max_documents: Maximum documents to keep in cache. + + Returns: + Configured DocxMCPServer instance. + """ + cache = DocumentCache(max_documents=max_documents) + return DocxMCPServer(name="docx-editor", cache=cache) + + +def _get_server(ctx) -> DocxMCPServer: + """Extract DocxMCPServer from FastMCP context.""" + return ctx.request_context.lifespan_context + + +@asynccontextmanager +async def server_lifespan(mcp) -> AsyncIterator[DocxMCPServer]: + """Manage DocxMCPServer lifecycle.""" + server = create_server() + try: + yield server + finally: + server.shutdown() + + +def _create_mcp_app(): + """Create and configure the FastMCP application with all tools registered.""" + from mcp.server.fastmcp import FastMCP as _FastMCP # ty: ignore[unresolved-import] + + mcp = _FastMCP( + "docx-editor", + instructions=SERVER_INSTRUCTIONS, + lifespan=server_lifespan, + ) + _register_tools(mcp) + return mcp + + +def _register_tools(mcp) -> None: + """Register all docx_editor tools on the FastMCP instance.""" + from mcp.server.fastmcp import Context # ty: ignore[unresolved-import] + + from . import tools + + # -- Document Lifecycle -- + + @mcp.tool() + def open_document(path: str, ctx: Context, author: str | None = None) -> str: + """Open a .docx document for editing. Must be called before any other operations on the file. + + Args: + path: Absolute path to the .docx file. + author: Author name for tracked changes. Uses system username if omitted. + """ + return json.dumps(tools.open_document(_get_server(ctx), path, author)) + + @mcp.tool() + def save_document(path: str, ctx: Context) -> str: + """Save changes to a document. Fails if the file was modified externally. + + Args: + path: Path to the document (as used in open_document). + """ + return json.dumps(tools.save_document(_get_server(ctx), path)) + + @mcp.tool() + def close_document(path: str, ctx: Context) -> str: + """Close a document and remove it from the cache. Warns if there are unsaved changes. + + Args: + path: Path to the document. + """ + return json.dumps(tools.close_document(_get_server(ctx), path)) + + @mcp.tool() + def reload_document(path: str, ctx: Context) -> str: + """Reload a document from disk, discarding all cached changes. + + Args: + path: Path to the document. + """ + return json.dumps(tools.reload_document(_get_server(ctx), path)) + + @mcp.tool() + def force_save(path: str, ctx: Context) -> str: + """Force save a document, ignoring external modifications. Use with care. + + Args: + path: Path to the document. + """ + return json.dumps(tools.force_save(_get_server(ctx), path)) + + # -- Paragraphs & Track Changes -- + + @mcp.tool() + def list_paragraphs(path: str, ctx: Context, max_chars: int = 80, start: int = 0, limit: int = 0) -> str: + """List all paragraphs with hash-anchored references. Call this before editing to get paragraph refs. + + Returns references like "P1#a7b2| Introduction to the..." that you must pass to edit tools. + + Args: + path: Path to the document. + max_chars: Maximum characters for the preview text (default 80). + start: Starting paragraph index, 0-based (default 0). + limit: Maximum paragraphs to return, 0 for all (default 0). + """ + return json.dumps(tools.list_paragraphs(_get_server(ctx), path, max_chars, start, limit)) + + @mcp.tool() + def replace_text(path: str, old_text: str, new_text: str, paragraph: str, ctx: Context, occurrence: int = 0) -> str: + """Replace text in a document with tracked changes (redlining). + + Args: + path: Path to the document. + old_text: The text to find and replace. + new_text: The replacement text. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence within the paragraph to replace (0 = first). + """ + return json.dumps(tools.replace_text(_get_server(ctx), path, old_text, new_text, paragraph, occurrence)) + + @mcp.tool() + def delete_text(path: str, text: str, paragraph: str, ctx: Context, occurrence: int = 0) -> str: + """Delete text from a document with tracked changes. + + Args: + path: Path to the document. + text: The text to delete. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence within the paragraph to delete (0 = first). + """ + return json.dumps(tools.delete_text(_get_server(ctx), path, text, paragraph, occurrence)) + + @mcp.tool() + def insert_after(path: str, anchor: str, text: str, paragraph: str, ctx: Context, occurrence: int = 0) -> str: + """Insert text after an anchor string with tracked changes. + + Args: + path: Path to the document. + anchor: The text to find as the insertion point. + text: The text to insert after the anchor. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence of anchor within the paragraph to use (0 = first). + """ + return json.dumps(tools.insert_after(_get_server(ctx), path, anchor, text, paragraph, occurrence)) + + @mcp.tool() + def insert_before(path: str, anchor: str, text: str, paragraph: str, ctx: Context, occurrence: int = 0) -> str: + """Insert text before an anchor string with tracked changes. + + Args: + path: Path to the document. + anchor: The text to find as the insertion point. + text: The text to insert before the anchor. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence of anchor within the paragraph to use (0 = first). + """ + return json.dumps(tools.insert_before(_get_server(ctx), path, anchor, text, paragraph, occurrence)) + + @mcp.tool() + def batch_edit(path: str, operations: list[dict], ctx: Context) -> str: + """Apply multiple edits atomically. All paragraph hashes are validated before any edit is applied. + + Args: + path: Path to the document. + operations: List of edit operations, each a dict with keys: + action ("replace"/"delete"/"insert_after"/"insert_before"), + paragraph (ref from list_paragraphs), + and action-specific fields (find/replace_with, text, anchor, occurrence). + """ + return json.dumps(tools.batch_edit(_get_server(ctx), path, operations)) + + @mcp.tool() + def rewrite_paragraph(path: str, paragraph: str, new_text: str, ctx: Context) -> str: + """Rewrite a paragraph with automatic word-level tracked changes. Use only for structural edits + (sentence restructuring, reordering) that can't be expressed as independent find/replace pairs. + + Args: + path: Path to the document. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + new_text: Desired new text for the paragraph. + """ + return json.dumps(tools.rewrite_paragraph(_get_server(ctx), path, paragraph, new_text)) + + @mcp.tool() + def batch_rewrite(path: str, rewrites: list[list[str]], ctx: Context) -> str: + """Rewrite multiple paragraphs atomically with word-level tracked changes. + All paragraph hashes are validated before any rewrites are applied. + + Args: + path: Path to the document. + rewrites: List of [paragraph_ref, new_text] pairs. + """ + return json.dumps(tools.batch_rewrite(_get_server(ctx), path, rewrites)) + + # -- Comments -- + + @mcp.tool() + def add_comment(path: str, anchor_text: str, comment_text: str, ctx: Context) -> str: + """Add a comment anchored to specific text in the document. + + Args: + path: Path to the document. + anchor_text: The text to attach the comment to. + comment_text: The comment content. + """ + return json.dumps(tools.add_comment(_get_server(ctx), path, anchor_text, comment_text)) + + @mcp.tool() + def list_comments(path: str, ctx: Context, author: str | None = None) -> str: + """List all comments in the document, optionally filtered by author. + + Args: + path: Path to the document. + author: Filter comments by this author name. + """ + return json.dumps(tools.list_comments(_get_server(ctx), path, author)) + + @mcp.tool() + def reply_to_comment(path: str, comment_id: int, reply_text: str, ctx: Context) -> str: + """Reply to an existing comment. + + Args: + path: Path to the document. + comment_id: ID of the comment to reply to. + reply_text: The reply content. + """ + return json.dumps(tools.reply_to_comment(_get_server(ctx), path, comment_id, reply_text)) + + @mcp.tool() + def resolve_comment(path: str, comment_id: int, ctx: Context) -> str: + """Mark a comment as resolved. + + Args: + path: Path to the document. + comment_id: ID of the comment to resolve. + """ + return json.dumps(tools.resolve_comment(_get_server(ctx), path, comment_id)) + + @mcp.tool() + def delete_comment(path: str, comment_id: int, ctx: Context) -> str: + """Delete a comment from the document. + + Args: + path: Path to the document. + comment_id: ID of the comment to delete. + """ + return json.dumps(tools.delete_comment(_get_server(ctx), path, comment_id)) + + # -- Revisions -- + + @mcp.tool() + def list_revisions(path: str, ctx: Context, author: str | None = None) -> str: + """List all tracked revisions (insertions/deletions), optionally filtered by author. + + Args: + path: Path to the document. + author: Filter revisions by this author name. + """ + return json.dumps(tools.list_revisions(_get_server(ctx), path, author)) + + @mcp.tool() + def accept_revision(path: str, revision_id: int, ctx: Context) -> str: + """Accept a specific tracked revision, making it permanent. + + Args: + path: Path to the document. + revision_id: ID of the revision to accept. + """ + return json.dumps(tools.accept_revision(_get_server(ctx), path, revision_id)) + + @mcp.tool() + def reject_revision(path: str, revision_id: int, ctx: Context) -> str: + """Reject a specific tracked revision, reverting the change. + + Args: + path: Path to the document. + revision_id: ID of the revision to reject. + """ + return json.dumps(tools.reject_revision(_get_server(ctx), path, revision_id)) + + @mcp.tool() + def accept_all(path: str, ctx: Context, author: str | None = None) -> str: + """Accept all tracked revisions, optionally only those by a specific author. + + Args: + path: Path to the document. + author: Only accept revisions by this author. + """ + return json.dumps(tools.accept_all(_get_server(ctx), path, author)) + + @mcp.tool() + def reject_all(path: str, ctx: Context, author: str | None = None) -> str: + """Reject all tracked revisions, optionally only those by a specific author. + + Args: + path: Path to the document. + author: Only reject revisions by this author. + """ + return json.dumps(tools.reject_all(_get_server(ctx), path, author)) + + # -- Read -- + + @mcp.tool() + def find_text(path: str, text: str, ctx: Context) -> str: + """Check if text exists in the document. + + Args: + path: Path to the document. + text: The text to search for. + """ + return json.dumps(tools.find_text(_get_server(ctx), path, text)) + + @mcp.tool() + def count_matches(path: str, text: str, ctx: Context) -> str: + """Count how many times text appears in the document. Use before replace/delete to verify uniqueness. + + Args: + path: Path to the document. + text: The text to count occurrences of. + """ + return json.dumps(tools.count_matches(_get_server(ctx), path, text)) + + @mcp.tool() + def search_text(path: str, query: str, ctx: Context, context_chars: int = 100) -> str: + """Search for text in the document, returning matches with surrounding context and paragraph refs. + + Args: + path: Path to the document. + query: Text to search for. + context_chars: Characters of context before and after each match (default 100). + """ + return json.dumps(tools.search_text(_get_server(ctx), path, query, context_chars)) + + @mcp.tool() + def get_paragraph_text(path: str, paragraphs: list[str], ctx: Context) -> str: + """Read specific paragraphs in full by their hash-anchored refs. + + Args: + path: Path to the document. + paragraphs: List of paragraph references (e.g., ["P1#a7b2", "P3#cc33"]). + """ + return json.dumps(tools.get_paragraph_text(_get_server(ctx), path, paragraphs)) + + @mcp.tool() + def get_document_info(path: str, ctx: Context) -> str: + """Get document overview: paragraph count, word count, and heading outline. + + Args: + path: Path to the document. + """ + return json.dumps(tools.get_document_info(_get_server(ctx), path)) + + @mcp.tool() + def get_visible_text(path: str, ctx: Context, max_chars: int = 10000) -> str: + """Get the visible text of the document. Auto-truncates large documents with a hint to use exploration tools. + + Args: + path: Path to the document. + max_chars: Maximum characters to return (default 10000). Set to 0 for no limit. + """ + return json.dumps(tools.get_visible_text(_get_server(ctx), path, max_chars)) + + +def main() -> None: + """Main entry point for running the MCP server. + + This is called when running `mcp-server-docx` or + `python -m docx_editor_mcp`. + """ + app = _create_mcp_app() + app.run(transport="stdio") diff --git a/docx_editor_mcp/tools.py b/docx_editor_mcp/tools.py new file mode 100644 index 0000000..90d6ec6 --- /dev/null +++ b/docx_editor_mcp/tools.py @@ -0,0 +1,1088 @@ +"""MCP Tools for docx_editor operations.""" + +import logging +import os +from typing import Any, cast + +from docx_editor import Document +from docx_editor.exceptions import TextNotFoundError + +from .cache import CachedDocument, normalize_path +from .server import DocxMCPServer + +logger = logging.getLogger(__name__) + + +def _get_cached_or_error(server: DocxMCPServer, path: str) -> CachedDocument | dict[str, Any]: + """Get cached document or return error dict. + + Returns: + CachedDocument if found, or error dict if not open. + """ + normalized = normalize_path(path) + cached = server.cache.get(normalized) + if not cached: + error: dict[str, Any] = {"success": False, "error": f"Document not open: {path}"} + return error + return cached + + +def _check_external_changes(cached: CachedDocument) -> dict | None: + """Check for external changes, return error dict if detected.""" + if cached.has_external_changes(): + return { + "success": False, + "error": f"File was modified externally: {cached.path}. Use reload_document or force_save.", + } + return None + + +# ============================================================================= +# Document Lifecycle Tools (Task 3.1) +# ============================================================================= + + +def open_document( + server: DocxMCPServer, + path: str, + author: str | None = None, +) -> dict[str, Any]: + """Open a document and add to cache. + + Args: + server: The MCP server instance. + path: Path to the document. + author: Author name for track changes. + + Returns: + Result dict with success, path, author, and optional hint. + """ + normalized = normalize_path(path) + + # Check if file exists + if not os.path.exists(normalized): + return {"success": False, "error": f"File not found: {path}"} + + # Check if already cached + cached = server.cache.get(normalized) + if cached: + return { + "success": True, + "path": normalized, + "author": cached.author, + "cached": True, + } + + # Get author with session memory + resolved_author, is_default = server.cache.get_author(author) + + # Open document + try: + doc = Document.open(normalized, author=resolved_author) + except Exception as e: + return {"success": False, "error": f"Failed to open document: {e}"} + + # Cache it + cached = CachedDocument(path=normalized, document=doc, author=resolved_author) + server.cache.put(cached) + + result = { + "success": True, + "path": normalized, + "author": resolved_author, + } + + # Add hint if using system default + if is_default: + result["hint"] = f"Author set to '{resolved_author}' (system default). Use author parameter to change." + + return result + + +def save_document(server: DocxMCPServer, path: str) -> dict[str, Any]: + """Save a document to disk. + + Args: + server: The MCP server instance. + path: Path to the document. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + # Check for external changes + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + cached.document.save() + cached.clear_dirty() + cached.update_mtime() + return {"success": True, "path": cached.path} + except Exception as e: + return {"success": False, "error": f"Failed to save: {e}"} + + +def close_document(server: DocxMCPServer, path: str) -> dict[str, Any]: + """Close a document and remove from cache. + + Args: + server: The MCP server instance. + path: Path to the document. + + Returns: + Result dict with success status and optional warning. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + result = {"success": True, "path": cached.path} + + if cached.dirty: + result["warning"] = "Document had unsaved changes that were discarded." + + # Close the document + try: + cached.document.close() + except Exception: + logger.debug("Failed to close document: %s", path) + + server.cache.remove(cached.path) + return result + + +def reload_document(server: DocxMCPServer, path: str) -> dict[str, Any]: + """Reload a document from disk, discarding cached changes. + + Args: + server: The MCP server instance. + path: Path to the document. + + Returns: + Result dict with success status and optional warning. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + result = {"success": True, "path": cached.path} + author = cached.author + was_dirty = cached.dirty + + # Close old document + try: + cached.document.close() + except Exception: + logger.debug("Failed to close document during reload: %s", path) + + # Remove from cache + server.cache.remove(cached.path) + + # Reopen + try: + doc = Document.open(cached.path, author=author) + except Exception as e: + return {"success": False, "error": f"Failed to reload: {e}"} + + # Add back to cache + new_cached = CachedDocument(path=cached.path, document=doc, author=author) + server.cache.put(new_cached) + + if was_dirty: + result["warning"] = "Unsaved changes were discarded." + + return result + + +def force_save(server: DocxMCPServer, path: str) -> dict[str, Any]: + """Force save a document, ignoring external changes. + + Args: + server: The MCP server instance. + path: Path to the document. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + cached.document.save() + cached.clear_dirty() + cached.update_mtime() + return {"success": True, "path": cached.path} + except Exception as e: + return {"success": False, "error": f"Failed to save: {e}"} + + +# ============================================================================= +# Track Changes Tools (Task 3.2) +# ============================================================================= + + +def list_paragraphs( + server: DocxMCPServer, + path: str, + max_chars: int = 80, + start: int = 0, + limit: int = 0, +) -> dict[str, Any]: + """List all paragraphs with hash-anchored references. + + Args: + server: The MCP server instance. + path: Path to the document. + max_chars: Maximum characters for preview text. + start: Starting paragraph index, 0-based. + limit: Maximum paragraphs to return, 0 for all. + + Returns: + Result dict with success status and paragraphs list. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + total_count = len(cached.document._document_editor.dom.getElementsByTagName("w:p")) + paragraphs = cached.document.list_paragraphs(max_chars=max_chars, start=start, limit=limit) + return {"success": True, "paragraphs": paragraphs, "total": total_count} + except Exception as e: + return {"success": False, "error": str(e)} + + +def replace_text( + server: DocxMCPServer, + path: str, + old_text: str, + new_text: str, + paragraph: str, + occurrence: int = 0, +) -> dict[str, Any]: + """Replace text with tracked changes. + + Args: + server: The MCP server instance. + path: Path to the document. + old_text: Text to find and replace. + new_text: Replacement text. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence within the paragraph to replace (0 = first). + + Returns: + Result dict with success status and change_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_ref = cached.document.replace(old_text, new_text, paragraph=paragraph, occurrence=occurrence) + cached.mark_dirty() + return {"success": True, "new_ref": new_ref} + except TextNotFoundError: + return {"success": False, "error": f"Text not found: '{old_text}'"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def delete_text( + server: DocxMCPServer, + path: str, + text: str, + paragraph: str, + occurrence: int = 0, +) -> dict[str, Any]: + """Delete text with tracked changes. + + Args: + server: The MCP server instance. + path: Path to the document. + text: Text to delete. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence within the paragraph to delete (0 = first). + + Returns: + Result dict with success status and change_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_ref = cached.document.delete(text, paragraph=paragraph, occurrence=occurrence) + cached.mark_dirty() + return {"success": True, "new_ref": new_ref} + except TextNotFoundError: + return {"success": False, "error": f"Text not found: '{text}'"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def insert_after( + server: DocxMCPServer, + path: str, + anchor: str, + text: str, + paragraph: str, + occurrence: int = 0, +) -> dict[str, Any]: + """Insert text after anchor with tracked changes. + + Args: + server: The MCP server instance. + path: Path to the document. + anchor: Text to find as insertion point. + text: Text to insert. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence of anchor within the paragraph to use (0 = first). + + Returns: + Result dict with success status and change_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_ref = cached.document.insert_after(anchor, text, paragraph=paragraph, occurrence=occurrence) + cached.mark_dirty() + return {"success": True, "new_ref": new_ref} + except TextNotFoundError: + return {"success": False, "error": f"Anchor not found: '{anchor}'"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def insert_before( + server: DocxMCPServer, + path: str, + anchor: str, + text: str, + paragraph: str, + occurrence: int = 0, +) -> dict[str, Any]: + """Insert text before anchor with tracked changes. + + Args: + server: The MCP server instance. + path: Path to the document. + anchor: Text to find as insertion point. + text: Text to insert. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + occurrence: Which occurrence of anchor within the paragraph to use (0 = first). + + Returns: + Result dict with success status and change_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_ref = cached.document.insert_before(anchor, text, paragraph=paragraph, occurrence=occurrence) + cached.mark_dirty() + return {"success": True, "new_ref": new_ref} + except TextNotFoundError: + return {"success": False, "error": f"Anchor not found: '{anchor}'"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def batch_edit( + server: DocxMCPServer, + path: str, + operations: list[dict[str, Any]], +) -> dict[str, Any]: + """Apply multiple edits atomically with upfront hash validation. + + All paragraph hashes are validated before any edits are applied. + If any hash is stale, the entire batch is rejected. + + Args: + server: The MCP server instance. + path: Path to the document. + operations: List of operation dicts, each with keys: + - action: "replace", "delete", "insert_after", or "insert_before" + - paragraph: Hash-anchored paragraph reference (e.g., "P2#f3c1") + - find/replace_with: For "replace" action + - text: For "delete" (text to delete) or insert (text to insert) + - anchor: For "insert_after"/"insert_before" + - occurrence: Optional, defaults to 0 + + Returns: + Result dict with success status and list of change_ids. + """ + from docx_editor import EditOperation + + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + ops = [EditOperation(**op) for op in operations] + except (TypeError, ValueError) as e: + return {"success": False, "error": f"Invalid operation: {e}"} + + try: + new_refs = cached.document.batch_edit(ops) + cached.mark_dirty() + return {"success": True, "new_refs": new_refs} + except TextNotFoundError as e: + return {"success": False, "error": str(e)} + except Exception as e: + return {"success": False, "error": str(e)} + + +def rewrite_paragraph( + server: DocxMCPServer, + path: str, + paragraph: str, + new_text: str, +) -> dict[str, Any]: + """Rewrite a paragraph with automatic word-level tracked changes. + + Diffs old vs new text and generates fine-grained tracked insertions/deletions. + Use only when the edit cannot be decomposed into independent find/replace pairs + (e.g., sentence restructuring, reordering). + + Args: + server: The MCP server instance. + path: Path to the document. + paragraph: Paragraph reference from list_paragraphs (e.g., "P2#f3c1"). + new_text: Desired new text for the paragraph. + + Returns: + Result dict with success status and new_ref. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_ref = cached.document.rewrite_paragraph(paragraph, new_text) + cached.mark_dirty() + return {"success": True, "new_ref": new_ref} + except Exception as e: + return {"success": False, "error": str(e)} + + +def batch_rewrite( + server: DocxMCPServer, + path: str, + rewrites: list[list[str]], +) -> dict[str, Any]: + """Rewrite multiple paragraphs with upfront hash validation. + + All paragraph hashes are validated before any rewrites are applied. + If any hash is stale, the entire batch is rejected. + + Args: + server: The MCP server instance. + path: Path to the document. + rewrites: List of [paragraph_ref, new_text] pairs. + + Returns: + Result dict with success status and list of new_refs. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + tuples = [(ref, text) for ref, text in rewrites] + new_refs = cached.document.batch_rewrite(tuples) + cached.mark_dirty() + return {"success": True, "new_refs": new_refs} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================================= +# Exploration Tools +# ============================================================================= + + +def search_text( + server: DocxMCPServer, + path: str, + query: str, + context_chars: int = 100, +) -> dict[str, Any]: + """Search for text in the document with surrounding context. + + Args: + server: The MCP server instance. + path: Path to the document. + query: Text to search for. + context_chars: Characters of context before and after each match. + + Returns: + Result dict with success status, matches, and count. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + matches = cached.document.search_text(query, context_chars=context_chars) + return {"success": True, "matches": matches, "count": len(matches)} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_paragraph_text( + server: DocxMCPServer, + path: str, + paragraphs: list[str], +) -> dict[str, Any]: + """Read specific paragraphs in full by their hash-anchored refs. + + Args: + server: The MCP server instance. + path: Path to the document. + paragraphs: List of paragraph references (e.g., ["P1#a7b2", "P3#cc33"]). + + Returns: + Result dict with success status and paragraphs. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + results = cached.document.get_paragraph_text(paragraphs) + return {"success": True, "paragraphs": results} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_document_info( + server: DocxMCPServer, + path: str, +) -> dict[str, Any]: + """Get document overview: paragraph count, word count, and heading outline. + + Args: + server: The MCP server instance. + path: Path to the document. + + Returns: + Result dict with success status and document info. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + info = cached.document.get_document_info() + return {"success": True, **info} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================================= +# Comment Tools (Task 3.3) +# ============================================================================= + + +def add_comment( + server: DocxMCPServer, + path: str, + anchor_text: str, + comment_text: str, +) -> dict[str, Any]: + """Add a comment anchored to text. + + Args: + server: The MCP server instance. + path: Path to the document. + anchor_text: Text to attach comment to. + comment_text: The comment content. + + Returns: + Result dict with success status and comment_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + comment_id = cached.document.add_comment(anchor_text, comment_text) + cached.mark_dirty() + return {"success": True, "comment_id": comment_id} + except TextNotFoundError: + return {"success": False, "error": f"Anchor text not found: '{anchor_text}'"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def list_comments( + server: DocxMCPServer, + path: str, + author: str | None = None, +) -> dict[str, Any]: + """List all comments in the document. + + Args: + server: The MCP server instance. + path: Path to the document. + author: Optional filter by author. + + Returns: + Result dict with success status and comments list. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + comments = cached.document.list_comments(author=author) + return { + "success": True, + "comments": [ + { + "id": c.id, + "author": c.author, + "text": c.text, + "resolved": c.resolved, + "replies": [{"id": r.id, "author": r.author, "text": r.text} for r in c.replies], + } + for c in comments + ], + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def reply_to_comment( + server: DocxMCPServer, + path: str, + comment_id: int, + reply_text: str, +) -> dict[str, Any]: + """Reply to an existing comment. + + Args: + server: The MCP server instance. + path: Path to the document. + comment_id: ID of the comment to reply to. + reply_text: The reply content. + + Returns: + Result dict with success status and new comment_id. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + new_id = cached.document.reply_to_comment(comment_id, reply_text) + cached.mark_dirty() + return {"success": True, "comment_id": new_id} + except Exception as e: + return {"success": False, "error": str(e)} + + +def resolve_comment( + server: DocxMCPServer, + path: str, + comment_id: int, +) -> dict[str, Any]: + """Mark a comment as resolved. + + Args: + server: The MCP server instance. + path: Path to the document. + comment_id: ID of the comment to resolve. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + result = cached.document.resolve_comment(comment_id) + if result: + cached.mark_dirty() + return {"success": True} + return {"success": False, "error": f"Comment not found: {comment_id}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def delete_comment( + server: DocxMCPServer, + path: str, + comment_id: int, +) -> dict[str, Any]: + """Delete a comment. + + Args: + server: The MCP server instance. + path: Path to the document. + comment_id: ID of the comment to delete. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + result = cached.document.delete_comment(comment_id) + if result: + cached.mark_dirty() + return {"success": True} + return {"success": False, "error": f"Comment not found: {comment_id}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================================= +# Revision Tools (Task 3.4) +# ============================================================================= + + +def list_revisions( + server: DocxMCPServer, + path: str, + author: str | None = None, +) -> dict[str, Any]: + """List all tracked revisions. + + Args: + server: The MCP server instance. + path: Path to the document. + author: Optional filter by author. + + Returns: + Result dict with success status and revisions list. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + revisions = cached.document.list_revisions(author=author) + return { + "success": True, + "revisions": [ + { + "id": r.id, + "type": r.type, + "author": r.author, + "text": r.text, + } + for r in revisions + ], + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def accept_revision( + server: DocxMCPServer, + path: str, + revision_id: int, +) -> dict[str, Any]: + """Accept a specific revision. + + Args: + server: The MCP server instance. + path: Path to the document. + revision_id: ID of the revision to accept. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + result = cached.document.accept_revision(revision_id) + if result: + cached.mark_dirty() + return {"success": True} + return {"success": False, "error": f"Revision not found: {revision_id}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def reject_revision( + server: DocxMCPServer, + path: str, + revision_id: int, +) -> dict[str, Any]: + """Reject a specific revision. + + Args: + server: The MCP server instance. + path: Path to the document. + revision_id: ID of the revision to reject. + + Returns: + Result dict with success status. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + result = cached.document.reject_revision(revision_id) + if result: + cached.mark_dirty() + return {"success": True} + return {"success": False, "error": f"Revision not found: {revision_id}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def accept_all( + server: DocxMCPServer, + path: str, + author: str | None = None, +) -> dict[str, Any]: + """Accept all revisions. + + Args: + server: The MCP server instance. + path: Path to the document. + author: Optional filter by author. + + Returns: + Result dict with success status and count. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + count = cached.document.accept_all(author=author) + if count > 0: + cached.mark_dirty() + return {"success": True, "count": count} + except Exception as e: + return {"success": False, "error": str(e)} + + +def reject_all( + server: DocxMCPServer, + path: str, + author: str | None = None, +) -> dict[str, Any]: + """Reject all revisions. + + Args: + server: The MCP server instance. + path: Path to the document. + author: Optional filter by author. + + Returns: + Result dict with success status and count. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + ext_error = _check_external_changes(cached) + if ext_error: + return ext_error + + try: + count = cached.document.reject_all(author=author) + if count > 0: + cached.mark_dirty() + return {"success": True, "count": count} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================================= +# Read Tools (Task 3.5) +# ============================================================================= + + +def find_text( + server: DocxMCPServer, + path: str, + text: str, +) -> dict[str, Any]: + """Check if text exists in document. + + Args: + server: The MCP server instance. + path: Path to the document. + text: Text to search for. + + Returns: + Result dict with success status and found flag. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + match = cached.document.find_text(text) + return {"success": True, "found": match is not None} + except Exception as e: + return {"success": False, "error": str(e)} + + +def count_matches( + server: DocxMCPServer, + path: str, + text: str, +) -> dict[str, Any]: + """Count occurrences of text in document. + + Args: + server: The MCP server instance. + path: Path to the document. + text: Text to search for. + + Returns: + Result dict with success status and count. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + count = cached.document.count_matches(text) + return {"success": True, "count": count} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_visible_text( + server: DocxMCPServer, + path: str, + max_chars: int = 10000, +) -> dict[str, Any]: + """Get the visible text of the document. + + Args: + server: The MCP server instance. + path: Path to the document. + max_chars: Maximum characters to return. 0 for no limit. + + Returns: + Result dict with success status and text. + """ + result = _get_cached_or_error(server, path) + if isinstance(result, dict): + return cast(dict[str, Any], result) + cached = result + + try: + text = cached.document.get_visible_text() + if max_chars and len(text) > max_chars: + text = text[:max_chars] + return { + "success": True, + "text": text, + "truncated": True, + "hint": ( + "Document too large to return in full. Use search_text to find specific " + "content, get_paragraph_text to read specific paragraphs, or " + "get_document_info for an overview." + ), + } + return {"success": True, "text": text, "truncated": False} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/openspec/changes/add-exploration-tools/design.md b/openspec/changes/add-exploration-tools/design.md new file mode 100644 index 0000000..22b3b16 --- /dev/null +++ b/openspec/changes/add-exploration-tools/design.md @@ -0,0 +1,37 @@ +## Context + +LLMs using MCP tools will always prefer reading the full document if allowed, because they have no reason to use chunked exploration when `get_visible_text` returns everything. For large documents this wastes context and degrades quality. We need to make the exploration path the natural choice. + +## Goals / Non-Goals + +- Goals: + - Make large document editing feasible via MCP without context window overflow + - Guide the LLM to use exploration tools when the document is too large + - Keep existing behavior unchanged for small documents +- Non-Goals: + - Semantic search or NLP-based features + - Page-based navigation (docx paragraphs don't map cleanly to pages) + - Streaming or chunked responses + +## Decisions + +- **Smart truncation over hard limits**: `get_visible_text` returns full text for small docs (under `max_chars`), truncates with a hint for large docs. This preserves backward compatibility while naturally steering the LLM to exploration tools. + - Alternatives: Hard error on large docs (too disruptive), always paginate (unnecessary for small docs) + +- **Combine truncation with server instructions**: Both the truncated response and `SERVER_INSTRUCTIONS` tell the LLM about the exploration workflow. Belt and suspenders — the instructions help even before the LLM tries `get_visible_text`. + - Alternatives: Only instructions (LLM may ignore), only truncation (no upfront guidance) + +- **`max_chars=10000` default for `get_visible_text`**: Roughly 2500 tokens, enough for small docs but triggers truncation for anything substantial. + +- **Paragraph-ref based `get_paragraph_text`**: Accepts a list of hash-anchored refs (from `list_paragraphs` or `search_text`) to read specific paragraphs in full. This is the primary "targeted read" tool. + +- **`search_text` returns context + paragraph refs**: Each match includes surrounding text and its paragraph ref, so the LLM can immediately use the ref for editing without a separate `list_paragraphs` call. + +## Risks / Trade-offs + +- **LLM may still try to call `get_visible_text` repeatedly**: Mitigated by truncation hint explicitly saying "use search_text or get_paragraph_text instead" +- **`get_document_info` heading extraction depends on paragraph styles**: Not all docx files use heading styles consistently. The tool should return whatever headings exist, gracefully handling docs with no headings. + +## Open Questions + +- None currently diff --git a/openspec/changes/add-exploration-tools/proposal.md b/openspec/changes/add-exploration-tools/proposal.md new file mode 100644 index 0000000..e78f4c2 --- /dev/null +++ b/openspec/changes/add-exploration-tools/proposal.md @@ -0,0 +1,24 @@ +# Change: Add Document Exploration Tools for Large Documents + +## Why + +The current MCP tools assume the LLM can read the full document via `get_visible_text`. For large documents (100+ pages), this dumps too much text into the context window, degrading LLM performance and wasting tokens. Without exploration tools, the LLM has no way to orient itself in a large document, search for specific content, or read targeted sections. + +The LLM will never voluntarily use chunked reading unless the full-text path is blocked or clearly discouraged. Smart truncation of `get_visible_text` combined with server instructions that guide the LLM toward exploration tools solves this. + +## What Changes + +- **MODIFIED:** `get_visible_text` - Auto-truncates large documents and appends a hint directing the LLM to use exploration tools +- **MODIFIED:** `list_paragraphs` - Adds pagination via `start`/`limit` parameters for browsing large documents +- **NEW:** `search_text(path, query, context_chars?)` - Full-text search returning matches with surrounding context and paragraph refs +- **NEW:** `get_paragraph_text(path, paragraphs)` - Read specific paragraphs in full by their hash-anchored refs +- **NEW:** `get_document_info(path)` - Document overview: paragraph count, word count, heading outline +- **MODIFIED:** `SERVER_INSTRUCTIONS` - Updated to guide LLMs toward exploration workflow for large docs + +## Impact + +- Affected specs: `mcp-server` (modified capability) +- Affected code: + - `docx_editor_mcp/tools.py` (new tool functions + modified existing) + - `docx_editor_mcp/server.py` (register new tools, update instructions) + - `tests/test_mcp/test_tools.py` (new tests) diff --git a/openspec/changes/add-exploration-tools/specs/mcp-server/spec.md b/openspec/changes/add-exploration-tools/specs/mcp-server/spec.md new file mode 100644 index 0000000..1b2b05c --- /dev/null +++ b/openspec/changes/add-exploration-tools/specs/mcp-server/spec.md @@ -0,0 +1,103 @@ +## ADDED Requirements + +### Requirement: Document Exploration Tools + +The MCP server SHALL provide tools for exploring large documents without loading full text into context. + +Tools SHALL include: +- `search_text(path, query, context_chars?)` - Search for text, returning matches with surrounding context and paragraph refs +- `get_paragraph_text(path, paragraphs)` - Read specific paragraphs in full by hash-anchored ref +- `get_document_info(path)` - Document overview with paragraph count, word count, and heading outline + +#### Scenario: Search text with context + +- **GIVEN** a document contains "force majeure" in paragraph P12#a3b4 +- **WHEN** `search_text(path="doc.docx", query="force majeure", context_chars=100)` is called +- **THEN** the tool returns a list of matches +- **AND** each match includes the paragraph ref (e.g., "P12#a3b4") +- **AND** each match includes surrounding context text (up to 100 chars before and after) +- **AND** each match includes the paragraph index + +#### Scenario: Search text not found + +- **GIVEN** a document does not contain "nonexistent phrase" +- **WHEN** `search_text(path="doc.docx", query="nonexistent phrase")` is called +- **THEN** the tool returns an empty match list +- **AND** the response indicates zero matches found + +#### Scenario: Get specific paragraphs by ref + +- **GIVEN** a document has paragraphs P1#aa11, P2#bb22, P3#cc33 +- **WHEN** `get_paragraph_text(path="doc.docx", paragraphs=["P1#aa11", "P3#cc33"])` is called +- **THEN** the tool returns the full text of paragraphs P1 and P3 +- **AND** each result includes the paragraph ref and full text + +#### Scenario: Get paragraph with invalid ref + +- **GIVEN** a document does not contain paragraph "P99#dead" +- **WHEN** `get_paragraph_text(path="doc.docx", paragraphs=["P99#dead"])` is called +- **THEN** the tool returns an error for that specific paragraph +- **AND** valid paragraphs in the same request are still returned + +#### Scenario: Get document info + +- **GIVEN** a document has 150 paragraphs and headings at various levels +- **WHEN** `get_document_info(path="doc.docx")` is called +- **THEN** the tool returns paragraph count, word count, and a heading outline +- **AND** the heading outline includes heading text and level + +#### Scenario: Get document info without headings + +- **GIVEN** a document has no heading styles applied +- **WHEN** `get_document_info(path="doc.docx")` is called +- **THEN** the tool returns paragraph count and word count +- **AND** the heading outline is empty + +## MODIFIED Requirements + +### Requirement: Read Tools + +The MCP server SHALL expose read-only document inspection tools. + +Tools SHALL include: +- `find_text(path, text)` - Check if text exists in document +- `count_matches(path, text)` - Count occurrences of text +- `get_visible_text(path, max_chars?)` - Get visible text of document, auto-truncated for large docs +- `list_paragraphs(path, max_chars?, start?, limit?)` - List paragraphs with optional pagination + +`get_visible_text` SHALL accept an optional `max_chars` parameter (default: 10000). When the document text exceeds `max_chars`, the response SHALL be truncated and include a hint directing the LLM to use `search_text`, `get_paragraph_text`, or `get_document_info` for targeted exploration. + +`list_paragraphs` SHALL accept optional `start` and `limit` parameters for paginating through large documents. When omitted, all paragraphs are returned. + +#### Scenario: Count matches via MCP + +- **GIVEN** a document contains "30 days" three times +- **WHEN** `count_matches(path="doc.docx", text="30 days")` is called +- **THEN** the tool returns 3 + +#### Scenario: Get visible text for small document + +- **GIVEN** a document has 500 characters of visible text +- **WHEN** `get_visible_text(path="doc.docx")` is called +- **THEN** the full visible text is returned + +#### Scenario: Get visible text for large document + +- **GIVEN** a document has 50000 characters of visible text +- **WHEN** `get_visible_text(path="doc.docx")` is called +- **THEN** the first 10000 characters are returned +- **AND** a hint is appended: the document was truncated and the LLM should use exploration tools + +#### Scenario: Get visible text with custom max_chars + +- **GIVEN** a document has 50000 characters of visible text +- **WHEN** `get_visible_text(path="doc.docx", max_chars=5000)` is called +- **THEN** the first 5000 characters are returned +- **AND** a truncation hint is appended + +#### Scenario: List paragraphs with pagination + +- **GIVEN** a document has 200 paragraphs +- **WHEN** `list_paragraphs(path="doc.docx", start=50, limit=20)` is called +- **THEN** paragraphs 50 through 69 are returned with their hash-anchored refs +- **AND** the response indicates total paragraph count and the returned range diff --git a/openspec/changes/add-exploration-tools/tasks.md b/openspec/changes/add-exploration-tools/tasks.md new file mode 100644 index 0000000..e2e0ee9 --- /dev/null +++ b/openspec/changes/add-exploration-tools/tasks.md @@ -0,0 +1,27 @@ +## 1. Core Library Support + +- [x] 1.1 Add `search_text(query, context_chars=100)` method to `Document` class +- [x] 1.2 Add `get_paragraph_text(paragraphs)` method to `Document` class +- [x] 1.3 Add `get_document_info()` method to `Document` class (paragraph count, word count, heading outline) +- [x] 1.4 Add `start`/`limit` parameters to `Document.list_paragraphs()` + +## 2. MCP Tool Functions + +- [x] 2.1 Add `search_text` tool function in `tools.py` +- [x] 2.2 Add `get_paragraph_text` tool function in `tools.py` +- [x] 2.3 Add `get_document_info` tool function in `tools.py` +- [x] 2.4 Modify `list_paragraphs` tool to accept `start`/`limit` +- [x] 2.5 Modify `get_visible_text` tool to accept `max_chars` and auto-truncate with hint + +## 3. Server Registration and Instructions + +- [x] 3.1 Register new tools in `server.py` (`_register_tools`) +- [x] 3.2 Update `SERVER_INSTRUCTIONS` to guide LLMs toward exploration workflow + +## 4. Tests + +- [x] 4.1 Test `search_text` (found, not found, multiple matches, context extraction) +- [x] 4.2 Test `get_paragraph_text` (single, multiple, invalid ref) +- [x] 4.3 Test `get_document_info` (with headings, without headings) +- [x] 4.4 Test `list_paragraphs` pagination (start/limit, out of range) +- [x] 4.5 Test `get_visible_text` truncation (small doc returns full, large doc truncates with hint) diff --git a/openspec/changes/add-mcp-server/design.md b/openspec/changes/add-mcp-server/design.md new file mode 100644 index 0000000..87160e2 --- /dev/null +++ b/openspec/changes/add-mcp-server/design.md @@ -0,0 +1,257 @@ +## Context + +Claude Code invokes the docx skill by running Python scripts via Bash. Each script execution loads the document, performs edits, saves, and exits. For interactive editing sessions where users make 5-10 edits on the same document, the reload overhead dominates. + +MCP (Model Context Protocol) servers maintain state between tool calls, making them ideal for caching parsed document DOMs. + +### Stakeholders +- Users doing interactive document editing with Claude Code +- The docx_editor library (must remain usable standalone) + +## Goals / Non-Goals + +**Goals:** +- Provide faster repeated operations on the same document +- Keep the core library independent of MCP +- Support both MCP and script-based workflows +- Detect external file modifications + +**Non-Goals:** +- Multi-user/concurrent access (single-user tool) +- Memory-optimized large document handling +- Real-time file synchronization + +## Decisions + +### Decision: Separate `docx_editor_mcp/` package + +The MCP server lives in a separate Python package within the same repo. + +**Why:** +- Core library stays lean and usable without MCP +- Clear separation of concerns +- Optional installation via extras + +**Alternatives considered:** +- Bake MCP into core library - Rejected: adds complexity for non-MCP users +- Separate repository - Rejected: harder to keep in sync, overkill for thin wrapper + +### Decision: Document count-based cache (not memory-based) + +Cache evicts based on document count (default: 10), not memory usage. + +**Why:** +- Simple to implement and reason about +- Memory measurement in Python is non-trivial +- 10 documents is reasonable for interactive use + +**Alternatives considered:** +- Memory-based eviction - Rejected: complex, requires tracking DOM memory usage +- No limit - Rejected: unbounded memory growth + +### Decision: Explicit save only + +Documents are only persisted when the user explicitly calls `save_document`. + +**Why:** +- Predictable behavior +- User controls when changes are committed +- Matches the library's existing pattern + +**Alternatives considered:** +- Auto-save on timer - Rejected: unpredictable, may save partial work +- Save on eviction - Still implemented as safety net, but not primary mechanism + +### Decision: mtime-based external change detection + +Before each operation, check if file's mtime differs from cached value. + +**Why:** +- Simple, no external dependencies +- Catches most external modification scenarios +- Low overhead + +**Alternatives considered:** +- File watcher (watchdog) - Rejected: adds dependency, complexity +- Hash-based detection - Rejected: expensive for large files +- Ignore external changes - Rejected: risk of data loss + +### Decision: Keep both skill modes (script + MCP) + +The skill continues to work via Python scripts. MCP is an optional upgrade. + +**Why:** +- No breaking changes for existing users +- MCP is opt-in for users who need performance +- Lower barrier to entry + +### Decision: Console scripts entry point + +Provide `mcp-server-docx` command via `[project.scripts]` in pyproject.toml. + +```toml +[project.scripts] +mcp-server-docx = "docx_editor_mcp:main" +``` + +**Why:** +- Standard Python packaging pattern for CLI tools +- Works with `uvx mcp-server-docx` for zero-install execution +- Follows MCP naming convention (`mcp-server-{name}`) +- Enables manual MCP add: `claude mcp add docx -- uvx mcp-server-docx` + +**Alternatives considered:** +- Only `python -m docx_editor_mcp` - Rejected: less discoverable, harder to configure in mcp.json + +### Decision: Plugin auto-configuration via .mcp.json + +The docx-editor plugin includes `.mcp.json` to auto-configure the MCP server. + +```json +{ + "docx": { + "command": "uvx", + "args": ["mcp-server-docx"] + } +} +``` + +**Why:** +- Zero-config for plugin users - MCP server starts automatically +- Uses uvx for automatic package resolution +- No manual `claude mcp add` required + +**Alternatives considered:** +- Manual configuration only - Rejected: adds friction for users +- Bundled binary - Rejected: harder to maintain, platform-specific + +### Decision: Session author memory with hint + +On first document open without explicit author, use system username and hint Claude to ask user. + +```python +def get_author(self, explicit_author: str | None) -> tuple[str, bool]: + """Returns (author, is_first_use)""" + if explicit_author: + self.session_author = explicit_author + return explicit_author, False + if self.session_author: + return self.session_author, False + default = getpass.getuser() or "Reviewer" + self.session_author = default + return default, True # True = hint Claude to ask +``` + +**Why:** +- Cross-platform: `getpass.getuser()` works on Linux, macOS, Windows +- Non-blocking: silent default works if Claude doesn't ask +- Session-aware: author remembered for subsequent operations + +### Decision: Path normalization + +All paths normalized to absolute canonical form before cache lookup. + +```python +def normalize_path(path: str) -> str: + return os.path.realpath(os.path.expanduser(path)) +``` + +**Why:** +- Prevents duplicate cache entries for same physical file +- Handles `~`, relative paths, symlinks consistently +- Simple, no edge cases + +### Decision: MCP-first skill + +The skill teaches Claude to use MCP tools directly, not Python scripts. + +**Why:** +- Plugin auto-configures MCP - it's always available +- Simpler skill content +- Better user experience (faster, cached operations) + +**Alternatives considered:** +- Document both modes - Rejected: adds complexity, confusing +- Detect and adapt - Rejected: unnecessary since plugin ensures MCP availability + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ │ +│ Skill loads on-demand → MCP tools via Tool Search │ +│ ↓ │ +│ Claude calls: delete_paragraph("file.docx", 28) │ +└──────────────────────┬──────────────────────────────────────┘ + │ MCP Protocol (stdio) + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ MCP Server (docx_editor_mcp) │ +│ │ +│ ┌────────────────────────────────────────┐ │ +│ │ DocumentCache │ │ +│ │ - max_documents: 10 │ │ +│ │ - LRU eviction │ │ +│ │ - mtime tracking per document │ │ +│ │ │ │ +│ │ get(path) -> CachedDocument │ │ +│ │ save(path) -> update mtime │ │ +│ │ close(path) -> evict from cache │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ MCP Tools: │ +│ - Track changes: replace, delete, insert_after, etc. │ +│ - Comments: add_comment, list_comments, etc. │ +│ - Revisions: list_revisions, accept_revision, etc. │ +│ - Document: open_document, save_document, close_document │ +└─────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌───────────────┐ + │ docx_editor │ (core library, unchanged) + └───────────────┘ + │ + ↓ + ┌──────────┐ + │ file.docx│ + └──────────┘ +``` + +## Package Structure + +``` +docx-edit/ +├── docx_editor/ # Core library (existing, unchanged) +├── docx_editor_mcp/ # NEW: MCP server wrapper +│ ├── __init__.py +│ ├── server.py # MCP server entry point +│ ├── cache.py # DocumentCache, CachedDocument +│ └── tools.py # MCP tool definitions +├── skills/docx/SKILL.md # Updated: document MCP option +└── pyproject.toml # Add [mcp] optional dependency +``` + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| User forgets to save, loses work | Warn on close if dirty; save on eviction | +| External edits overwritten | mtime check before operations, return error | +| MCP server crashes with unsaved data | Best-effort save on shutdown | +| Context overhead from MCP tools | Tool Search lazy-loads definitions | + +## Migration Plan + +1. Implement `docx_editor_mcp/` package +2. Add `[mcp]` optional dependency to pyproject.toml +3. Update skill documentation with MCP setup instructions +4. Test with Claude Code MCP configuration +5. Document in README + +**Rollback:** Remove `docx_editor_mcp/` from pyproject.toml extras. Core library unaffected. + +## Resolved Questions + +1. **CLI entry point?** → Yes, `mcp-server-docx` via `[project.scripts]`. Also supports `python -m docx_editor_mcp`. +2. **Plugin auto-configuration?** → Add `.mcp.json` to plugin root with uvx-based command. diff --git a/openspec/changes/add-mcp-server/proposal.md b/openspec/changes/add-mcp-server/proposal.md new file mode 100644 index 0000000..fdfbb00 --- /dev/null +++ b/openspec/changes/add-mcp-server/proposal.md @@ -0,0 +1,31 @@ +# Change: Add MCP Server for Persistent DOM Caching + +## Why + +When Claude Code edits a document through the skill, each tool invocation runs a separate Python script. The document is loaded, edited, saved, and the DOM is freed. For multi-edit sessions (common in interactive document review), this reload penalty compounds - loading a large document takes seconds, multiplied by each edit. + +An MCP server keeps the document DOM in memory between tool calls, providing 10-30x faster performance for repeated operations on the same document. + +## What Changes + +- **NEW:** `docx_editor_mcp/` package - MCP server wrapping the core library +- **NEW:** Document cache with LRU eviction and external change detection +- **NEW:** MCP tools mirroring the library's public API +- **MODIFIED:** Skill documentation to reference MCP tools +- **NEW:** Optional dependency: `pip install docx-editor[mcp]` + +## Impact + +- Affected specs: `mcp-server` (new capability) +- Affected code: + - `docx_editor_mcp/` (new package) + - `skills/docx/SKILL.md` (documentation update) + - `pyproject.toml` (optional dependency) + +## Key Design Decisions + +1. **Separate package** - MCP code lives in `docx_editor_mcp/`, keeping core library lean +2. **Both modes supported** - Skill-based (Python scripts) remains default; MCP is opt-in +3. **MCP Tool Search** - Claude Code's lazy tool loading prevents context bloat +4. **Explicit save only** - User controls when to persist changes +5. **External change detection** - Check file mtime before operations, warn if modified externally diff --git a/openspec/changes/add-mcp-server/specs/mcp-server/spec.md b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md new file mode 100644 index 0000000..6db1fef --- /dev/null +++ b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md @@ -0,0 +1,242 @@ +## ADDED Requirements + +### Requirement: Document Cache + +The MCP server SHALL maintain an in-memory cache of open documents to avoid reloading between operations. + +The cache SHALL: +- Store parsed `Document` instances keyed by normalized absolute file path +- Normalize all paths to canonical form (resolve `~`, relative paths, symlinks) +- Limit the number of cached documents (default: 10) +- Use LRU (Least Recently Used) eviction when the limit is reached +- Save dirty documents before evicting them +- Track the last access time for each cached document + +#### Scenario: Cache hit on repeated access + +- **GIVEN** a document "contract.docx" was previously opened via MCP +- **WHEN** another MCP tool accesses "contract.docx" +- **THEN** the cached `Document` instance is returned +- **AND** no file I/O occurs + +#### Scenario: Cache miss triggers load + +- **GIVEN** a document "new.docx" is not in the cache +- **WHEN** an MCP tool accesses "new.docx" +- **THEN** the document is loaded from disk +- **AND** the document is added to the cache + +#### Scenario: LRU eviction when cache full + +- **GIVEN** the cache contains 10 documents (max limit) +- **AND** document "old.docx" has the oldest last-access time +- **WHEN** a new document "new.docx" is accessed +- **THEN** "old.docx" is evicted from the cache +- **AND** if "old.docx" has unsaved changes, it is saved before eviction +- **AND** "new.docx" is loaded into the cache + +#### Scenario: Path normalization prevents duplicate cache entries + +- **GIVEN** a document exists at "/home/user/docs/contract.docx" +- **WHEN** `open_document(path="~/docs/contract.docx")` is called +- **AND** later `replace_text(path="/home/user/docs/contract.docx", ...)` is called +- **THEN** both operations use the same cached document instance +- **AND** only one cache entry exists + +### Requirement: External Change Detection + +The MCP server SHALL detect when a cached document has been modified externally and prevent accidental overwrites. + +#### Scenario: Detect external modification + +- **GIVEN** a document "contract.docx" is cached with mtime T1 +- **AND** the file is modified externally (mtime becomes T2) +- **WHEN** an MCP edit tool is called on "contract.docx" +- **THEN** the operation fails with an error message +- **AND** the error indicates the file was modified externally +- **AND** suggests using `reload_document` or `force_save` + +#### Scenario: mtime updated after save + +- **GIVEN** a document "contract.docx" is cached +- **WHEN** `save_document` is called +- **THEN** the cached mtime is updated to match the file's new mtime + +#### Scenario: Reload document after external change + +- **GIVEN** a document "contract.docx" is cached with unsaved changes +- **AND** the file was modified externally +- **WHEN** `reload_document(path="contract.docx")` is called +- **THEN** the cached document is discarded +- **AND** the document is reloaded from disk +- **AND** the tool returns a warning that unsaved changes were discarded + +#### Scenario: Force save overwrites external changes + +- **GIVEN** a document "contract.docx" is cached +- **AND** the file was modified externally +- **WHEN** `force_save(path="contract.docx")` is called +- **THEN** the cached document is saved to disk (overwriting external changes) +- **AND** the cached mtime is updated + +### Requirement: Session Author Memory + +The MCP server SHALL remember the author name for the session and hint Claude to ask the user on first use. + +The author resolution order SHALL be: +1. Explicit `author` parameter on tool call +2. Previously set session author +3. System username via `getpass.getuser()` (cross-platform) +4. "Reviewer" as ultimate fallback + +#### Scenario: First document open hints Claude to ask + +- **GIVEN** no author has been set for this session +- **WHEN** `open_document(path="doc.docx")` is called without author +- **THEN** the document is opened with system username as author +- **AND** the response includes a hint: "Author set to 'pablo' (system default). Use author parameter to change." +- **AND** the session author is remembered + +#### Scenario: Session author remembered + +- **GIVEN** `open_document(path="doc1.docx", author="Legal Team")` was called +- **WHEN** `open_document(path="doc2.docx")` is called without author +- **THEN** "Legal Team" is used as the author (from session memory) + +### Requirement: Document Lifecycle Tools + +The MCP server SHALL provide tools to manage document lifecycle. + +Tools SHALL include: +- `open_document(path, author?)` - Open document, set author for track changes +- `save_document(path)` - Save document to disk +- `close_document(path)` - Remove document from cache +- `reload_document(path)` - Discard cached changes, reload from disk +- `force_save(path)` - Save even when external changes detected + +#### Scenario: Open document with author + +- **WHEN** `open_document(path="/path/to/doc.docx", author="Reviewer")` is called +- **THEN** the document is loaded (or retrieved from cache) +- **AND** the author is set for subsequent track changes operations +- **AND** the tool returns success with document info + +#### Scenario: Open document with default author + +- **WHEN** `open_document(path="/path/to/doc.docx")` is called without author +- **THEN** the author defaults to the session author or system username + +#### Scenario: Save document + +- **GIVEN** a document is open and has unsaved changes +- **WHEN** `save_document(path="/path/to/doc.docx")` is called +- **THEN** the document is saved to disk +- **AND** the dirty flag is cleared +- **AND** the cached mtime is updated + +#### Scenario: Close document + +- **GIVEN** a document is in the cache +- **WHEN** `close_document(path="/path/to/doc.docx")` is called +- **THEN** the document is removed from the cache +- **AND** if dirty, a warning is returned (but document is still closed) + +### Requirement: Track Changes Tools + +The MCP server SHALL expose the library's track changes functionality as MCP tools. + +Tools SHALL include: +- `replace_text(path, old_text, new_text, occurrence?)` - Replace text with tracking +- `delete_text(path, text, occurrence?)` - Delete text with tracking +- `insert_after(path, anchor, text, occurrence?)` - Insert after anchor with tracking +- `insert_before(path, anchor, text, occurrence?)` - Insert before anchor with tracking + +#### Scenario: Replace text via MCP + +- **GIVEN** a document is open via MCP +- **WHEN** `replace_text(path="doc.docx", old_text="30 days", new_text="60 days")` is called +- **THEN** the replacement is tracked (deletion + insertion markup) +- **AND** the document is marked dirty +- **AND** the tool returns success with change ID + +#### Scenario: Text not found + +- **WHEN** `delete_text(path="doc.docx", text="nonexistent")` is called +- **AND** the text does not exist in the document +- **THEN** the tool returns an error indicating text not found + +### Requirement: Comment Tools + +The MCP server SHALL expose the library's comment functionality as MCP tools. + +Tools SHALL include: +- `add_comment(path, anchor_text, comment_text)` - Add comment anchored to text +- `list_comments(path, author?)` - List all comments, optionally filtered by author +- `reply_to_comment(path, comment_id, reply_text)` - Reply to existing comment +- `resolve_comment(path, comment_id)` - Mark comment as resolved +- `delete_comment(path, comment_id)` - Delete a comment + +#### Scenario: Add comment via MCP + +- **GIVEN** a document is open via MCP +- **WHEN** `add_comment(path="doc.docx", anchor_text="ambiguous term", comment_text="Please clarify")` is called +- **THEN** a comment is added anchored to "ambiguous term" +- **AND** the document is marked dirty +- **AND** the tool returns the new comment ID + +### Requirement: Revision Tools + +The MCP server SHALL expose the library's revision management functionality as MCP tools. + +Tools SHALL include: +- `list_revisions(path, author?)` - List all tracked revisions +- `accept_revision(path, revision_id)` - Accept a specific revision +- `reject_revision(path, revision_id)` - Reject a specific revision +- `accept_all(path, author?)` - Accept all revisions, optionally filtered by author +- `reject_all(path, author?)` - Reject all revisions, optionally filtered by author + +#### Scenario: List revisions via MCP + +- **GIVEN** a document has tracked changes from multiple authors +- **WHEN** `list_revisions(path="doc.docx")` is called +- **THEN** all revisions are returned with id, type, author, and text + +#### Scenario: Accept revision via MCP + +- **GIVEN** a document has a tracked insertion with id=5 +- **WHEN** `accept_revision(path="doc.docx", revision_id=5)` is called +- **THEN** the revision is accepted (insertion becomes regular text) +- **AND** the document is marked dirty + +### Requirement: Read Tools + +The MCP server SHALL expose read-only document inspection tools. + +Tools SHALL include: +- `find_text(path, text)` - Check if text exists in document +- `count_matches(path, text)` - Count occurrences of text +- `get_visible_text(path)` - Get full visible text of document + +#### Scenario: Count matches via MCP + +- **GIVEN** a document contains "30 days" three times +- **WHEN** `count_matches(path="doc.docx", text="30 days")` is called +- **THEN** the tool returns 3 + +### Requirement: Graceful Shutdown + +The MCP server SHALL attempt to save dirty documents on shutdown. + +#### Scenario: Shutdown with dirty documents + +- **GIVEN** the cache contains dirty documents +- **WHEN** the MCP server receives a shutdown signal +- **THEN** all dirty documents are saved +- **AND** the server exits cleanly + +#### Scenario: Shutdown save failure + +- **GIVEN** a dirty document cannot be saved (e.g., disk full) +- **WHEN** the MCP server shuts down +- **THEN** the error is logged +- **AND** the server continues shutting down (best-effort) diff --git a/openspec/changes/add-mcp-server/tasks.md b/openspec/changes/add-mcp-server/tasks.md new file mode 100644 index 0000000..c94eae7 --- /dev/null +++ b/openspec/changes/add-mcp-server/tasks.md @@ -0,0 +1,52 @@ +## 1. Core MCP Package + +- [x] 1.1 Create `docx_editor_mcp/` directory structure +- [x] 1.2 Implement `cache.py` with `DocumentCache` and `CachedDocument` classes +- [x] 1.3 Implement path normalization (resolve ~, relative paths, symlinks) +- [x] 1.4 Implement mtime-based external change detection +- [x] 1.5 Implement LRU eviction with save-on-evict for dirty documents +- [x] 1.6 Implement session author memory with cross-platform default (`getpass.getuser()`) + +## 2. MCP Server + +- [x] 2.1 Implement `server.py` with MCP server initialization +- [x] 2.2 Add graceful shutdown with best-effort save + +## 3. MCP Tools + +- [x] 3.1 Implement document lifecycle tools: `open_document`, `save_document`, `close_document`, `reload_document`, `force_save` +- [x] 3.2 Implement track changes tools: `replace_text`, `delete_text`, `insert_after`, `insert_before` +- [x] 3.3 Implement comment tools: `add_comment`, `list_comments`, `reply_to_comment`, `resolve_comment`, `delete_comment` +- [x] 3.4 Implement revision tools: `list_revisions`, `accept_revision`, `reject_revision`, `accept_all`, `reject_all` +- [x] 3.5 Implement read tools: `find_text`, `count_matches`, `get_visible_text` + +## 4. Packaging + +- [x] 4.1 Add `[mcp]` optional dependency to `pyproject.toml` (depends on `mcp` package) +- [x] 4.2 Add `__main__.py` for `python -m docx_editor_mcp` invocation +- [x] 4.3 Add `[project.scripts]` entry point: `mcp-server-docx` +- [x] 4.4 Verify package installs correctly with `pip install .[mcp]` +- [ ] 4.5 Test `uvx mcp-server-docx` works from PyPI (requires PyPI publish) + +## 5. Plugin Configuration + +- [x] 5.1 Add `.mcp.json` to plugin root with uvx-based server config +- [ ] 5.2 Test plugin auto-configures MCP server on enable (requires manual verification) + +## 6. Skill Update + +- [x] 6.1 Rewrite `skills/docx/SKILL.md` to use MCP tools (MCP-first approach) +- [x] 6.2 Document available MCP tools and their parameters +- [x] 6.3 Add examples of common workflows using MCP tools + +## 7. Testing + +- [x] 7.1 Unit tests for `DocumentCache` (get, eviction, mtime detection) - tests/mcp/test_cache.py +- [x] 7.2 Integration tests for MCP tools - tests/mcp/test_tools.py +- [x] 7.3 Test external change detection behavior - tests/mcp/test_tools.py::TestExternalChangeDetection +- [x] 7.4 Test shutdown with dirty documents - tests/mcp/test_server.py::TestGracefulShutdown + +## 8. Documentation + +- [x] 8.1 Add MCP section to README +- [x] 8.2 Document MCP configuration for Claude Code diff --git a/pyproject.toml b/pyproject.toml index 08c2aa8..0b47f7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,12 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] +[project.optional-dependencies] +mcp = ["mcp>=1.0.0"] + +[project.scripts] +mcp-server-docx = "docx_editor_mcp.server:main" + [project.urls] Homepage = "https://pablospe.github.io/docx-editor/" Repository = "https://github.com/pablospe/docx-editor" @@ -43,7 +49,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["docx_editor"] +packages = ["docx_editor", "docx_editor_mcp"] [tool.ty.environment] python = "./.venv" @@ -64,7 +70,7 @@ ignore = ["B008", "TID252"] "tests/*" = ["S101"] [tool.ruff.lint.isort] -known-first-party = ["docx_editor"] +known-first-party = ["docx_editor", "docx_editor_mcp"] [tool.ruff.format] preview = true @@ -74,4 +80,4 @@ skip_empty = true [tool.coverage.run] branch = true -source = ["docx_editor"] +source = ["docx_editor", "docx_editor_mcp"] diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md index 5581e83..414d93f 100644 --- a/skills/docx/SKILL.md +++ b/skills/docx/SKILL.md @@ -7,7 +7,7 @@ description: "Comprehensive document creation, editing, and analysis with suppor ## Overview -A user may ask you to create, edit, or analyze the contents of a .docx file. A .docx file is essentially a ZIP archive containing XML files and other resources. You have different tools and workflows available for different tasks. +This skill provides tools for creating, editing, and analyzing .docx files. For editing existing documents, use the **MCP tools** which provide fast, cached operations with tracked changes support. ## Workflow Decision Tree @@ -17,25 +17,136 @@ What do you need to do? +-- Read/Analyze Content | Use pandoc for text extraction (see "Reading and analyzing content") | -+-- Navigate Document Structure (for large docs or precise targeting) ++-- Navigate Document Structure (for large docs) | Use python-docx to explore before editing (see "Navigating document structure") | +-- Create New Document | Use python-docx (recommended, simpler) -| Or docx-js for complex formatting (see "Creating with docx-js") +| Or docx-js for complex formatting (see docx-js.md) | +-- Edit Existing Document - Use docx_editor Python library (see "Editing an existing Word document") + Use MCP tools (see "Editing with MCP Tools") - Tracked changes (redlining) - Comments (add, reply, resolve) - Accept/reject revisions ``` +## Editing with MCP Tools + +The docx-editor MCP server provides fast document editing with persistent caching. Documents stay loaded between operations, making repeated edits 10-30x faster. + +### Document Lifecycle + +``` +open_document(path, author?) # Load document into cache +save_document(path) # Save changes to disk +close_document(path, force?) # Remove from cache +reload_document(path) # Discard changes, reload from disk +force_save(path) # Save even if externally modified +``` + +**Author handling:** +- On first open without explicit author, uses system username +- The MCP server remembers author for the session +- If a default is used, consider asking the user for their preferred name + +### Track Changes Tools + +All edits are automatically tracked with author attribution. + +``` +replace_text(path, old_text, new_text, occurrence?) +delete_text(path, text, occurrence?) +insert_after(path, anchor, text, occurrence?) +insert_before(path, anchor, text, occurrence?) +``` + +- `occurrence` (0-indexed) selects which match to target when text appears multiple times +- Returns change ID, or -1 if edit was in-place (within existing tracked insertion) + +### Comment Tools + +``` +add_comment(path, anchor_text, comment_text, occurrence?) +list_comments(path, author?) # Filter by author optional +reply_to_comment(path, comment_id, reply_text) +resolve_comment(path, comment_id) +delete_comment(path, comment_id) +``` + +### Revision Tools + +``` +list_revisions(path, author?) # Filter by author optional +accept_revision(path, revision_id) +reject_revision(path, revision_id) +accept_all(path, author?) # Accept all, optionally by author +reject_all(path, author?) # Reject all, optionally by author +``` + +### Read Tools + +``` +find_text(path, pattern) # Find text, returns context +count_matches(path, pattern) # Count occurrences +get_visible_text(path) # Get all visible text (insertions included, deletions excluded) +``` + +### Example: Document Review Workflow + +``` +# 1. Open the document +open_document("/path/to/contract.docx", author="Pablo") + +# 2. Check what we're working with +get_visible_text("/path/to/contract.docx") + +# 3. Make changes (all tracked) +replace_text("/path/to/contract.docx", "30 days", "60 days") +delete_text("/path/to/contract.docx", "and any affiliates") +insert_after("/path/to/contract.docx", "termination.", " Notice must be in writing.") + +# 4. Add a review comment +add_comment("/path/to/contract.docx", "indemnification", "Review with counsel") + +# 5. Save when done +save_document("/path/to/contract.docx") +``` + +### External Change Detection + +The MCP server tracks file modification times. If someone else edits the file while you have it open: +- `save_document` will fail with a warning +- Use `reload_document` to get the latest version (discards your changes) +- Use `force_save` to overwrite external changes (use carefully) + +### Best Practices + +**Target specific text:** Replace first occurrence by default. Use unique context: +``` +# BAD - might match wrong location +replace_text(path, "the", "a") + +# GOOD - unique context +replace_text(path, "the meeting was productive", "the conference was productive") +``` + +**Verify uniqueness first:** +``` +count_matches(path, "the meeting was productive") +# If count > 1, add more context or use occurrence parameter +``` + +**Multiple matches:** Use `occurrence` parameter (0-indexed): +``` +replace_text(path, "Section 1", "Article 1", occurrence=2) # Third match +``` + ## Reading and analyzing content ### Text extraction -Convert the document to markdown using pandoc. Pandoc provides excellent support for preserving document structure and can show tracked changes: +Convert the document to markdown using pandoc: ```bash # Convert document to markdown with tracked changes @@ -46,41 +157,30 @@ pandoc --track-changes=all path-to-file.docx -o output.md ### Raw XML access -For comments, complex formatting, document structure, embedded media, and metadata, unpack the document: +For comments, complex formatting, or metadata, unpack the document: ```bash unzip document.docx -d unpacked/ ``` -Key file structures: +Key files: * `word/document.xml` - Main document contents * `word/comments.xml` - Comments referenced in document.xml -* `word/media/` - Embedded images and media files -* Tracked changes use `` (insertions) and `` (deletions) tags +* `word/media/` - Embedded images and media ## Navigating document structure -Use **python-docx** to explore document structure before editing. This is useful for: -- Large documents that won't fit in context -- Finding the right text/context to target for edits -- Understanding document organization +Use **python-docx** to explore large documents before editing: ```python from docx import Document doc = Document('file.docx') -# List all paragraphs with their styles +# List paragraphs with styles for i, p in enumerate(doc.paragraphs): print(f"{i}: [{p.style.name}] {p.text[:50]}...") -# Access tables -for t, table in enumerate(doc.tables): - print(f"Table {t}:") - for r, row in enumerate(table.rows): - for c, cell in enumerate(row.cells): - print(f" [{r},{c}]: {cell.text[:30]}...") - # Find specific content for i, p in enumerate(doc.paragraphs): if "target text" in p.text: @@ -91,50 +191,29 @@ for i, p in enumerate(doc.paragraphs): ### With python-docx (recommended) -Use **python-docx** for most document creation needs. It's simpler and keeps everything in Python. - ```python from docx import Document from docx.shared import Pt, Inches -from docx.enum.text import WD_ALIGN_PARAGRAPH doc = Document() - -# Add title -title = doc.add_heading("Document Title", 0) -title.alignment = WD_ALIGN_PARAGRAPH.CENTER - -# Add paragraphs +doc.add_heading("Document Title", 0) doc.add_paragraph("This is body text.") - -# Add heading and more content doc.add_heading("Section 1", 1) doc.add_paragraph("Section content here.") - -# Add a table -table = doc.add_table(rows=2, cols=2) -table.cell(0, 0).text = "Header 1" -table.cell(0, 1).text = "Header 2" -table.cell(1, 0).text = "Data 1" -table.cell(1, 1).text = "Data 2" - -# Add page break -doc.add_page_break() - -# Save doc.save("output.docx") ``` ### With docx-js (for complex formatting) -For advanced formatting needs (precise spacing, complex table styling, detailed TOC), use **docx-js** (JavaScript/TypeScript). +For advanced formatting, read [`docx-js.md`](docx-js.md) for syntax and best practices. -**Workflow:** -1. **MANDATORY - READ ENTIRE FILE**: Read [`docx-js.md`](docx-js.md) (~350 lines) for syntax, critical formatting rules, and best practices. -2. Create a JavaScript/TypeScript file using Document, Paragraph, TextRun components -3. Export as .docx using Packer.toBuffer() +## Parallel Processing -## Editing an existing Word document +**Reading in parallel**: Safe. Multiple agents can read simultaneously. + +**Editing in parallel**: NOT safe for the same document. MCP maintains document state - concurrent edits will conflict. Edit documents sequentially. + +## Python Library API (Direct Usage) Use the **docx_editor** Python library for all editing operations. It handles tracked changes, comments, and revisions with a simple API. @@ -608,37 +687,19 @@ If unsure, ask the user: "Should I use Opus (best), Sonnet (recommended) or Haik ## Converting Documents to Images -To visually analyze Word documents, convert them to images: - ```bash -# Step 1: Convert DOCX to PDF +# DOCX to PDF soffice --headless --convert-to pdf document.docx -# Step 2: Convert PDF pages to JPEG images +# PDF to images pdftoppm -jpeg -r 150 document.pdf page # Creates: page-1.jpg, page-2.jpg, etc. ``` -Options for pdftoppm: -- `-r 150`: Resolution in DPI (adjust for quality/size) -- `-jpeg` or `-png`: Output format -- `-f N`: First page to convert -- `-l N`: Last page to convert - -## Code Style Guidelines - -When generating code for DOCX operations: -- Write concise code -- Avoid verbose variable names and redundant operations -- Avoid unnecessary print statements - ## Dependencies -Required dependencies (install if not available): - -- **docx_editor**: `pip install docx-editor` (for track changes, comments, revisions) -- **python-docx**: `pip install python-docx` (for reading structure and creating documents) -- **pandoc**: `sudo apt-get install pandoc` (for text extraction to markdown) -- **docx** (npm): `npm install -g docx` (optional, for complex document formatting) +- **MCP server**: Installed with plugin (auto-configured) +- **python-docx**: `pip install python-docx` (for reading/creating) +- **pandoc**: `sudo apt-get install pandoc` (for text extraction) - **LibreOffice**: `sudo apt-get install libreoffice` (for PDF conversion) - **Poppler**: `sudo apt-get install poppler-utils` (for pdftoppm) diff --git a/tests/test_mcp/__init__.py b/tests/test_mcp/__init__.py new file mode 100644 index 0000000..b3a9cca --- /dev/null +++ b/tests/test_mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for docx_editor_mcp package.""" diff --git a/tests/test_mcp/test_cache.py b/tests/test_mcp/test_cache.py new file mode 100644 index 0000000..759d8d2 --- /dev/null +++ b/tests/test_mcp/test_cache.py @@ -0,0 +1,371 @@ +"""Tests for DocumentCache following TDD.""" + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + + +class TestPathNormalization: + """Test path normalization (Task 1.3).""" + + def test_normalize_expands_tilde(self): + """Path with ~ expands to home directory.""" + from docx_editor_mcp.cache import normalize_path + + result = normalize_path("~/docs/test.docx") + assert result.startswith(str(Path.home())) + assert result.endswith("docs/test.docx") + + def test_normalize_resolves_relative_path(self): + """Relative path resolves to absolute.""" + from docx_editor_mcp.cache import normalize_path + + result = normalize_path("test.docx") + assert os.path.isabs(result) + + def test_normalize_resolves_symlinks(self, tmp_path): + """Symlinks resolve to real path.""" + from docx_editor_mcp.cache import normalize_path + + # Create a real file and a symlink + real_file = tmp_path / "real.docx" + real_file.touch() + symlink = tmp_path / "link.docx" + symlink.symlink_to(real_file) + + result = normalize_path(str(symlink)) + assert result == str(real_file.resolve()) + + +class TestCachedDocument: + """Test CachedDocument class (Task 1.2, 1.4).""" + + def test_cached_document_stores_document_and_path(self): + """CachedDocument stores document instance and normalized path.""" + from docx_editor_mcp.cache import CachedDocument + + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + + assert cached.document is mock_doc + assert cached.path == "/path/to/doc.docx" + assert cached.author == "Tester" + + def test_cached_document_tracks_mtime(self, tmp_path): + """CachedDocument stores file mtime on creation.""" + from docx_editor_mcp.cache import CachedDocument + + # Create a real file to get mtime + test_file = tmp_path / "test.docx" + test_file.touch() + expected_mtime = test_file.stat().st_mtime + + mock_doc = MagicMock() + cached = CachedDocument(path=str(test_file), document=mock_doc, author="Tester") + + assert cached.mtime == expected_mtime + + def test_cached_document_tracks_last_access(self): + """CachedDocument tracks last access time.""" + from docx_editor_mcp.cache import CachedDocument + + mock_doc = MagicMock() + before = time.time() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + after = time.time() + + assert before <= cached.last_access <= after + + def test_cached_document_touch_updates_last_access(self): + """touch() updates last_access time.""" + from docx_editor_mcp.cache import CachedDocument + + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + old_access = cached.last_access + + time.sleep(0.01) # Small delay to ensure different timestamp + cached.touch() + + assert cached.last_access > old_access + + def test_cached_document_dirty_flag(self): + """CachedDocument tracks dirty state.""" + from docx_editor_mcp.cache import CachedDocument + + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + + assert cached.dirty is False + cached.mark_dirty() + assert cached.dirty is True + cached.clear_dirty() + assert cached.dirty is False + + def test_cached_document_detects_external_changes(self, tmp_path): + """has_external_changes() detects when file mtime changed.""" + from docx_editor_mcp.cache import CachedDocument + + test_file = tmp_path / "test.docx" + test_file.touch() + + mock_doc = MagicMock() + cached = CachedDocument(path=str(test_file), document=mock_doc, author="Tester") + + # No changes yet + assert cached.has_external_changes() is False + + # Simulate external modification + time.sleep(0.01) + test_file.write_text("modified") + + assert cached.has_external_changes() is True + + def test_cached_document_update_mtime(self, tmp_path): + """update_mtime() syncs cached mtime with file.""" + from docx_editor_mcp.cache import CachedDocument + + test_file = tmp_path / "test.docx" + test_file.touch() + + mock_doc = MagicMock() + cached = CachedDocument(path=str(test_file), document=mock_doc, author="Tester") + + # Modify file + time.sleep(0.01) + test_file.write_text("modified") + assert cached.has_external_changes() is True + + # Update mtime + cached.update_mtime() + assert cached.has_external_changes() is False + + +class TestDocumentCache: + """Test DocumentCache class (Task 1.2, 1.5).""" + + def test_cache_get_returns_none_for_missing(self): + """get() returns None for documents not in cache.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + result = cache.get("/nonexistent/path.docx") + + assert result is None + + def test_cache_put_and_get(self): + """put() adds document, get() retrieves it.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + + cache.put(cached) + result = cache.get("/path/to/doc.docx") + + assert result is cached + assert result.document is mock_doc + + def test_cache_normalizes_paths(self, tmp_path): + """Cache uses normalized paths for lookup.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + mock_doc = MagicMock() + + # Create real file for mtime + test_file = tmp_path / "doc.docx" + test_file.touch() + + # Put with absolute path + cached = CachedDocument(path=str(test_file), document=mock_doc, author="Tester") + cache.put(cached) + + # Get with different but equivalent path should work + # (This tests that paths are normalized on get as well) + result = cache.get(str(test_file)) + assert result is cached + + def test_cache_get_updates_last_access(self): + """get() updates last_access time of cached document.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + cache.put(cached) + + old_access = cached.last_access + time.sleep(0.01) + + cache.get("/path/to/doc.docx") + + assert cached.last_access > old_access + + def test_cache_remove(self): + """remove() removes document from cache.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + mock_doc = MagicMock() + cached = CachedDocument(path="/path/to/doc.docx", document=mock_doc, author="Tester") + + cache.put(cached) + assert cache.get("/path/to/doc.docx") is not None + + cache.remove("/path/to/doc.docx") + assert cache.get("/path/to/doc.docx") is None + + def test_cache_size(self): + """size property returns number of cached documents.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + assert cache.size == 0 + + cache.put(CachedDocument(path="/doc1.docx", document=MagicMock(), author="Tester")) + assert cache.size == 1 + + cache.put(CachedDocument(path="/doc2.docx", document=MagicMock(), author="Tester")) + assert cache.size == 2 + + def test_cache_lru_eviction(self, tmp_path): + """Cache evicts least recently used when full.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=2) + + # Create files for mtime + for i in range(3): + (tmp_path / f"doc{i}.docx").touch() + + # Add first two documents + doc1 = CachedDocument(path=str(tmp_path / "doc0.docx"), document=MagicMock(), author="Tester") + cache.put(doc1) + time.sleep(0.01) + + doc2 = CachedDocument(path=str(tmp_path / "doc1.docx"), document=MagicMock(), author="Tester") + cache.put(doc2) + time.sleep(0.01) + + # Access doc1 to make doc2 the LRU + cache.get(str(tmp_path / "doc0.docx")) + time.sleep(0.01) + + # Add third document - should evict doc2 (LRU) + doc3 = CachedDocument(path=str(tmp_path / "doc2.docx"), document=MagicMock(), author="Tester") + cache.put(doc3) + + assert cache.size == 2 + assert cache.get(str(tmp_path / "doc0.docx")) is not None # Still there + assert cache.get(str(tmp_path / "doc1.docx")) is None # Evicted + assert cache.get(str(tmp_path / "doc2.docx")) is not None # Added + + def test_cache_saves_dirty_on_eviction(self, tmp_path): + """Dirty documents are saved before eviction.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=1) + + # Create file + (tmp_path / "doc0.docx").touch() + (tmp_path / "doc1.docx").touch() + + mock_doc = MagicMock() + doc1 = CachedDocument(path=str(tmp_path / "doc0.docx"), document=mock_doc, author="Tester") + doc1.mark_dirty() + cache.put(doc1) + + # Add second document - should trigger eviction and save + doc2 = CachedDocument(path=str(tmp_path / "doc1.docx"), document=MagicMock(), author="Tester") + cache.put(doc2) + + # Verify save was called on the evicted document + mock_doc.save.assert_called_once() + + def test_cache_all_documents(self): + """all() returns all cached documents.""" + from docx_editor_mcp.cache import CachedDocument, DocumentCache + + cache = DocumentCache(max_documents=10) + doc1 = CachedDocument(path="/doc1.docx", document=MagicMock(), author="Tester") + doc2 = CachedDocument(path="/doc2.docx", document=MagicMock(), author="Tester") + + cache.put(doc1) + cache.put(doc2) + + all_docs = list(cache.all()) + assert len(all_docs) == 2 + assert doc1 in all_docs + assert doc2 in all_docs + + +class TestSessionAuthor: + """Test session author memory (Task 1.6).""" + + def test_get_author_explicit_takes_precedence(self): + """Explicit author parameter is used first.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + author, is_default = cache.get_author("Explicit Author") + + assert author == "Explicit Author" + assert is_default is False + + def test_get_author_remembers_session_author(self): + """Session author is remembered for subsequent calls.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + + # First call with explicit author + cache.get_author("Legal Team") + + # Second call without author uses session author + author, is_default = cache.get_author(None) + + assert author == "Legal Team" + assert is_default is False + + def test_get_author_uses_system_default_first_time(self): + """First call without author uses system username.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + + with patch("getpass.getuser", return_value="testuser"): + author, is_default = cache.get_author(None) + + assert author == "testuser" + assert is_default is True # Hints Claude to ask + + def test_get_author_fallback_to_reviewer(self): + """Falls back to 'Reviewer' if getpass.getuser() fails.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + + with patch("getpass.getuser", side_effect=Exception("No user")): + author, is_default = cache.get_author(None) + + assert author == "Reviewer" + assert is_default is True + + def test_session_author_updates_on_explicit(self): + """Explicit author updates session author.""" + from docx_editor_mcp.cache import DocumentCache + + cache = DocumentCache(max_documents=10) + + # Set initial session author + cache.get_author("First Author") + + # New explicit author + cache.get_author("Second Author") + + # Should use new session author + author, _ = cache.get_author(None) + assert author == "Second Author" diff --git a/tests/test_mcp/test_server.py b/tests/test_mcp/test_server.py new file mode 100644 index 0000000..66ea61c --- /dev/null +++ b/tests/test_mcp/test_server.py @@ -0,0 +1,221 @@ +"""Tests for MCP Server following TDD.""" + +import asyncio +import importlib.util +from unittest.mock import MagicMock + +import pytest + +_has_mcp = bool(importlib.util.find_spec("mcp")) + + +class TestMCPServer: + """Test MCP server initialization and lifecycle.""" + + def test_server_has_name(self): + """Server has correct name.""" + from docx_editor_mcp.server import create_server + + server = create_server() + assert server.name == "docx-editor" + + def test_server_has_cache(self): + """Server initializes with DocumentCache.""" + from docx_editor_mcp.server import create_server + + server = create_server() + assert server.cache is not None + assert server.cache.max_documents == 10 + + def test_server_cache_configurable(self): + """Server cache size is configurable.""" + from docx_editor_mcp.server import create_server + + server = create_server(max_documents=5) + assert server.cache.max_documents == 5 + + +class TestGracefulShutdown: + """Test graceful shutdown with dirty document saving (Task 2.2).""" + + def test_shutdown_saves_dirty_documents(self): + """Shutdown saves all dirty documents.""" + from docx_editor_mcp.cache import CachedDocument + from docx_editor_mcp.server import create_server + + server = create_server() + + # Add dirty documents to cache + mock_doc1 = MagicMock() + mock_doc2 = MagicMock() + + cached1 = CachedDocument(path="/doc1.docx", document=mock_doc1, author="Tester") + cached1.mark_dirty() + cached2 = CachedDocument(path="/doc2.docx", document=mock_doc2, author="Tester") + cached2.mark_dirty() + + server.cache.put(cached1) + server.cache.put(cached2) + + # Trigger shutdown + server.shutdown() + + # Both documents should be saved + mock_doc1.save.assert_called_once() + mock_doc2.save.assert_called_once() + + def test_shutdown_skips_clean_documents(self): + """Shutdown doesn't save clean documents.""" + from docx_editor_mcp.cache import CachedDocument + from docx_editor_mcp.server import create_server + + server = create_server() + + # Add clean document + mock_doc = MagicMock() + cached = CachedDocument(path="/doc.docx", document=mock_doc, author="Tester") + # Not marked dirty + server.cache.put(cached) + + server.shutdown() + + mock_doc.save.assert_not_called() + + def test_shutdown_continues_on_save_error(self): + """Shutdown continues even if a save fails (best-effort).""" + from docx_editor_mcp.cache import CachedDocument + from docx_editor_mcp.server import create_server + + server = create_server() + + # Add two dirty documents, first one fails to save + mock_doc1 = MagicMock() + mock_doc1.save.side_effect = Exception("Disk full") + mock_doc2 = MagicMock() + + cached1 = CachedDocument(path="/doc1.docx", document=mock_doc1, author="Tester") + cached1.mark_dirty() + cached2 = CachedDocument(path="/doc2.docx", document=mock_doc2, author="Tester") + cached2.mark_dirty() + + server.cache.put(cached1) + server.cache.put(cached2) + + # Should not raise, should continue to save doc2 + server.shutdown() + + mock_doc1.save.assert_called_once() + mock_doc2.save.assert_called_once() + + def test_shutdown_clears_cache(self): + """Shutdown clears the cache after saving.""" + from docx_editor_mcp.cache import CachedDocument + from docx_editor_mcp.server import create_server + + server = create_server() + + mock_doc = MagicMock() + cached = CachedDocument(path="/doc.docx", document=mock_doc, author="Tester") + server.cache.put(cached) + + assert server.cache.size == 1 + + server.shutdown() + + assert server.cache.size == 0 + + +@pytest.mark.skipif(not _has_mcp, reason="mcp package not installed") +class TestFastMCPApp: + """Test FastMCP application wiring.""" + + def test_create_mcp_app(self): + """_create_mcp_app returns a FastMCP instance.""" + from docx_editor_mcp.server import _create_mcp_app + + app = _create_mcp_app() + assert app is not None + + def test_app_has_name(self): + """App has correct server name.""" + from docx_editor_mcp.server import _create_mcp_app + + app = _create_mcp_app() + assert app.name == "docx-editor" + + def test_app_has_instructions(self): + """App has server instructions for Tool Search discoverability.""" + from docx_editor_mcp.server import SERVER_INSTRUCTIONS, _create_mcp_app + + app = _create_mcp_app() + assert app.instructions == SERVER_INSTRUCTIONS + + def test_all_tools_registered(self): + """All tools are registered on the app.""" + from docx_editor_mcp.server import _create_mcp_app + + app = _create_mcp_app() + tool_names = {t.name for t in app._tool_manager.list_tools()} + expected = { + # Lifecycle + "open_document", + "save_document", + "close_document", + "reload_document", + "force_save", + # Paragraphs & track changes + "list_paragraphs", + "replace_text", + "delete_text", + "insert_after", + "insert_before", + "batch_edit", + "rewrite_paragraph", + "batch_rewrite", + # Comments + "add_comment", + "list_comments", + "reply_to_comment", + "resolve_comment", + "delete_comment", + # Revisions + "list_revisions", + "accept_revision", + "reject_revision", + "accept_all", + "reject_all", + # Read & Exploration + "find_text", + "count_matches", + "get_visible_text", + "search_text", + "get_paragraph_text", + "get_document_info", + } + assert tool_names == expected + + def test_main_function_exists(self): + """main() function exists and is callable.""" + from docx_editor_mcp.server import main + + assert callable(main) + + +@pytest.mark.skipif(not _has_mcp, reason="mcp package not installed") +class TestLifespan: + """Test server lifespan context manager.""" + + def test_lifespan_creates_and_shuts_down_server(self): + """Lifespan yields a DocxMCPServer and shuts it down on exit.""" + from docx_editor_mcp.server import DocxMCPServer, server_lifespan + + async def _run(): + mock_mcp = MagicMock() + async with server_lifespan(mock_mcp) as server: + assert isinstance(server, DocxMCPServer) + assert server.cache is not None + assert server.cache.size == 0 + # After exit, shutdown was called (cache cleared) + assert server.cache.size == 0 + + asyncio.run(_run()) diff --git a/tests/test_mcp/test_tools.py b/tests/test_mcp/test_tools.py new file mode 100644 index 0000000..5d3eb4f --- /dev/null +++ b/tests/test_mcp/test_tools.py @@ -0,0 +1,807 @@ +"""Tests for MCP Tools following TDD.""" + +import os +import shutil +import time +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def server(): + """Create a fresh server instance.""" + from docx_editor_mcp.server import create_server + + return create_server() + + +@pytest.fixture +def mcp_temp_docx(simple_docx, tmp_path): + """Create a temporary copy of simple.docx for MCP tests.""" + dest = tmp_path / "mcp_test.docx" + shutil.copy(simple_docx, dest) + return dest + + +class TestOpenDocument: + """Test open_document tool.""" + + def test_open_document_loads_and_caches(self, server, mcp_temp_docx): + """open_document loads document and adds to cache.""" + from docx_editor_mcp.tools import open_document + + result = open_document(server, str(mcp_temp_docx), author="Tester") + + assert result["success"] is True + assert result["path"] == str(mcp_temp_docx) + assert result["author"] == "Tester" + assert server.cache.size == 1 + + def test_open_document_returns_cached(self, server, mcp_temp_docx): + """open_document returns cached document on second call.""" + from docx_editor_mcp.tools import open_document + + result1 = open_document(server, str(mcp_temp_docx), author="Tester") + result2 = open_document(server, str(mcp_temp_docx)) + + assert result1["success"] is True + assert result2["success"] is True + assert server.cache.size == 1 # Still just one + + def test_open_document_default_author_hints(self, server, mcp_temp_docx): + """open_document with no author uses default and hints.""" + from docx_editor_mcp.tools import open_document + + with patch("getpass.getuser", return_value="systemuser"): + result = open_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert result["author"] == "systemuser" + assert "system default" in result.get("hint", "").lower() + + def test_open_document_session_author(self, server, mcp_temp_docx, tmp_path): + """open_document remembers session author.""" + from docx_editor_mcp.tools import open_document + + # First doc with explicit author + open_document(server, str(mcp_temp_docx), author="Legal Team") + + # Second doc without author uses session author + doc2 = tmp_path / "test2.docx" + shutil.copy(mcp_temp_docx, doc2) + result = open_document(server, str(doc2)) + + assert result["author"] == "Legal Team" + assert "hint" not in result # No hint for session author + + def test_open_document_file_not_found(self, server): + """open_document returns error for missing file.""" + from docx_editor_mcp.tools import open_document + + result = open_document(server, "/nonexistent/file.docx") + + assert result["success"] is False + assert "not found" in result["error"].lower() + + +class TestSaveDocument: + """Test save_document tool.""" + + def test_save_document_saves_to_disk(self, server, mcp_temp_docx): + """save_document writes document to disk.""" + from docx_editor_mcp.tools import open_document, save_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + # Get mtime before save + mtime_before = os.path.getmtime(mcp_temp_docx) + time.sleep(0.01) + + result = save_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + # File should have been modified + mtime_after = os.path.getmtime(mcp_temp_docx) + assert mtime_after >= mtime_before + + def test_save_document_clears_dirty(self, server, mcp_temp_docx): + """save_document clears dirty flag.""" + from docx_editor_mcp.tools import open_document, save_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + # Mark dirty + cached = server.cache.get(str(mcp_temp_docx)) + cached.mark_dirty() + assert cached.dirty is True + + save_document(server, str(mcp_temp_docx)) + + assert cached.dirty is False + + def test_save_document_updates_mtime(self, server, mcp_temp_docx): + """save_document updates cached mtime.""" + from docx_editor_mcp.tools import open_document, save_document + + open_document(server, str(mcp_temp_docx), author="Tester") + cached = server.cache.get(str(mcp_temp_docx)) + old_mtime = cached.mtime + + time.sleep(0.01) + save_document(server, str(mcp_temp_docx)) + + assert cached.mtime >= old_mtime + + def test_save_document_not_open(self, server): + """save_document returns error if document not open.""" + from docx_editor_mcp.tools import save_document + + result = save_document(server, "/not/open.docx") + + assert result["success"] is False + assert "not open" in result["error"].lower() + + def test_save_document_external_change_blocked(self, server, mcp_temp_docx): + """save_document fails if file changed externally.""" + from docx_editor_mcp.tools import open_document, save_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + # Simulate external modification + time.sleep(0.01) + mcp_temp_docx.write_bytes(mcp_temp_docx.read_bytes() + b"modified") + + result = save_document(server, str(mcp_temp_docx)) + + assert result["success"] is False + assert "external" in result["error"].lower() + + +class TestCloseDocument: + """Test close_document tool.""" + + def test_close_document_removes_from_cache(self, server, mcp_temp_docx): + """close_document removes document from cache.""" + from docx_editor_mcp.tools import close_document, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + assert server.cache.size == 1 + + result = close_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert server.cache.size == 0 + + def test_close_document_warns_if_dirty(self, server, mcp_temp_docx): + """close_document warns if document has unsaved changes.""" + from docx_editor_mcp.tools import close_document, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + server.cache.get(str(mcp_temp_docx)).mark_dirty() + + result = close_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "unsaved" in result.get("warning", "").lower() + + def test_close_document_not_open(self, server): + """close_document returns error if not open.""" + from docx_editor_mcp.tools import close_document + + result = close_document(server, "/not/open.docx") + + assert result["success"] is False + assert "not open" in result["error"].lower() + + +class TestReloadDocument: + """Test reload_document tool.""" + + def test_reload_document_reloads_from_disk(self, server, mcp_temp_docx): + """reload_document reloads document from disk.""" + from docx_editor_mcp.tools import open_document, reload_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = reload_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert server.cache.size == 1 # Still cached + + def test_reload_document_discards_changes(self, server, mcp_temp_docx): + """reload_document discards unsaved changes.""" + from docx_editor_mcp.tools import open_document, reload_document + + open_document(server, str(mcp_temp_docx), author="Tester") + server.cache.get(str(mcp_temp_docx)).mark_dirty() + + result = reload_document(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "discarded" in result.get("warning", "").lower() + # New cached doc should not be dirty + assert server.cache.get(str(mcp_temp_docx)).dirty is False + + def test_reload_document_not_open(self, server): + """reload_document returns error if not open.""" + from docx_editor_mcp.tools import reload_document + + result = reload_document(server, "/not/open.docx") + + assert result["success"] is False + assert "not open" in result["error"].lower() + + +class TestForceSave: + """Test force_save tool.""" + + def test_force_save_overwrites_external(self, server, mcp_temp_docx): + """force_save saves even when external changes detected.""" + from docx_editor_mcp.tools import force_save, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + # Simulate external modification + time.sleep(0.01) + mcp_temp_docx.write_bytes(mcp_temp_docx.read_bytes() + b"external") + + result = force_save(server, str(mcp_temp_docx)) + + assert result["success"] is True + # mtime should be updated + cached = server.cache.get(str(mcp_temp_docx)) + assert not cached.has_external_changes() + + def test_force_save_not_open(self, server): + """force_save returns error if not open.""" + from docx_editor_mcp.tools import force_save + + result = force_save(server, "/not/open.docx") + + assert result["success"] is False + assert "not open" in result["error"].lower() + + +def _get_paragraph_ref(server, path, text): + """Helper to find the paragraph reference containing the given text.""" + from docx_editor_mcp.tools import list_paragraphs + + result = list_paragraphs(server, path) + assert result["success"] is True + for ref in result["paragraphs"]: + # ref looks like "P1#a7b2| The quick brown fox..." + if text in ref: + return ref.split("|")[0].strip() + raise AssertionError(f"No paragraph containing '{text}' found") + + +class TestTrackChangesTools: + """Test track changes tools (Task 3.2).""" + + def test_replace_text(self, server, mcp_temp_docx): + """replace_text replaces text with tracking.""" + from docx_editor_mcp.tools import open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + + assert result["success"] is True + assert "new_ref" in result + assert server.cache.get(str(mcp_temp_docx)).dirty is True + + def test_replace_text_not_found(self, server, mcp_temp_docx): + """replace_text returns error if text not found.""" + from docx_editor_mcp.tools import open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = replace_text(server, str(mcp_temp_docx), "nonexistent text xyz", "new", para) + + assert result["success"] is False + assert "not found" in result["error"].lower() + + def test_replace_text_with_occurrence(self, server, mcp_temp_docx): + """replace_text can target specific occurrence.""" + from docx_editor_mcp.tools import open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para, occurrence=0) + + # Should at least not crash + assert "success" in result + + def test_delete_text(self, server, mcp_temp_docx): + """delete_text marks text as deleted.""" + from docx_editor_mcp.tools import delete_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = delete_text(server, str(mcp_temp_docx), "quick brown fox", para) + + assert result["success"] is True + assert "new_ref" in result + assert server.cache.get(str(mcp_temp_docx)).dirty is True + + def test_insert_after(self, server, mcp_temp_docx): + """insert_after inserts text after anchor.""" + from docx_editor_mcp.tools import insert_after, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = insert_after(server, str(mcp_temp_docx), "quick brown fox", " World", para) + + assert result["success"] is True + assert "new_ref" in result + + def test_insert_before(self, server, mcp_temp_docx): + """insert_before inserts text before anchor.""" + from docx_editor_mcp.tools import insert_before, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = insert_before(server, str(mcp_temp_docx), "quick brown fox", "Say: ", para) + + assert result["success"] is True + assert "new_ref" in result + + +class TestBatchAndRewriteTools: + """Test batch_edit, rewrite_paragraph, and batch_rewrite tools.""" + + def test_batch_edit(self, server, mcp_temp_docx): + """batch_edit applies multiple edits atomically.""" + from docx_editor_mcp.tools import batch_edit, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = batch_edit( + server, + str(mcp_temp_docx), + [ + {"action": "replace", "find": "quick", "replace_with": "slow", "paragraph": para}, + ], + ) + + assert result["success"] is True + assert "new_refs" in result + assert len(result["new_refs"]) == 1 + + def test_batch_edit_invalid_operation(self, server, mcp_temp_docx): + """batch_edit returns error for invalid operations.""" + from docx_editor_mcp.tools import batch_edit, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = batch_edit( + server, + str(mcp_temp_docx), + [ + {"action": "replace"}, # missing required fields + ], + ) + + assert result["success"] is False + + def test_rewrite_paragraph(self, server, mcp_temp_docx): + """rewrite_paragraph rewrites with tracked changes.""" + from docx_editor_mcp.tools import open_document, rewrite_paragraph + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + result = rewrite_paragraph(server, str(mcp_temp_docx), para, "The slow red fox jumps over the lazy dog.") + + assert result["success"] is True + assert "new_ref" in result + assert server.cache.get(str(mcp_temp_docx)).dirty is True + + def test_batch_rewrite(self, server, mcp_temp_docx): + """batch_rewrite rewrites multiple paragraphs.""" + from docx_editor_mcp.tools import batch_rewrite, list_paragraphs, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + paras = list_paragraphs(server, str(mcp_temp_docx)) + ref = paras["paragraphs"][0].split("|")[0].strip() + + result = batch_rewrite(server, str(mcp_temp_docx), [[ref, "Completely new text."]]) + + assert result["success"] is True + assert "new_refs" in result + assert len(result["new_refs"]) == 1 + + +class TestCommentTools: + """Test comment tools (Task 3.3).""" + + def test_add_comment(self, server, mcp_temp_docx): + """add_comment adds comment to document.""" + from docx_editor_mcp.tools import add_comment, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = add_comment(server, str(mcp_temp_docx), "quick brown fox", "Please review") + + assert result["success"] is True + assert "comment_id" in result + assert server.cache.get(str(mcp_temp_docx)).dirty is True + + def test_list_comments(self, server, mcp_temp_docx): + """list_comments returns all comments.""" + from docx_editor_mcp.tools import add_comment, list_comments, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + add_comment(server, str(mcp_temp_docx), "quick brown fox", "Comment 1") + + result = list_comments(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "comments" in result + assert len(result["comments"]) >= 1 + + def test_list_comments_filter_author(self, server, mcp_temp_docx): + """list_comments can filter by author.""" + from docx_editor_mcp.tools import list_comments, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = list_comments(server, str(mcp_temp_docx), author="Tester") + + assert result["success"] is True + assert "comments" in result + + def test_reply_to_comment(self, server, mcp_temp_docx): + """reply_to_comment adds reply to existing comment.""" + from docx_editor_mcp.tools import add_comment, open_document, reply_to_comment + + open_document(server, str(mcp_temp_docx), author="Tester") + add_result = add_comment(server, str(mcp_temp_docx), "quick brown fox", "Original") + + result = reply_to_comment(server, str(mcp_temp_docx), add_result["comment_id"], "Reply text") + + assert result["success"] is True + assert "comment_id" in result + + def test_resolve_comment(self, server, mcp_temp_docx): + """resolve_comment marks comment as resolved.""" + from docx_editor_mcp.tools import add_comment, open_document, resolve_comment + + open_document(server, str(mcp_temp_docx), author="Tester") + add_result = add_comment(server, str(mcp_temp_docx), "quick brown fox", "To resolve") + + result = resolve_comment(server, str(mcp_temp_docx), add_result["comment_id"]) + + assert result["success"] is True + + def test_delete_comment(self, server, mcp_temp_docx): + """delete_comment removes comment.""" + from docx_editor_mcp.tools import add_comment, delete_comment, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + add_result = add_comment(server, str(mcp_temp_docx), "quick brown fox", "To delete") + + result = delete_comment(server, str(mcp_temp_docx), add_result["comment_id"]) + + assert result["success"] is True + + +class TestRevisionTools: + """Test revision tools (Task 3.4).""" + + def test_list_revisions(self, server, mcp_temp_docx): + """list_revisions returns all tracked changes.""" + from docx_editor_mcp.tools import list_revisions, open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + + result = list_revisions(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "revisions" in result + assert len(result["revisions"]) >= 1 + + def test_accept_revision(self, server, mcp_temp_docx): + """accept_revision accepts a specific revision.""" + from docx_editor_mcp.tools import ( + accept_revision, + list_revisions, + open_document, + replace_text, + ) + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + revisions = list_revisions(server, str(mcp_temp_docx)) + + if revisions["revisions"]: + rev_id = revisions["revisions"][0]["id"] + result = accept_revision(server, str(mcp_temp_docx), rev_id) + assert result["success"] is True + + def test_reject_revision(self, server, mcp_temp_docx): + """reject_revision rejects a specific revision.""" + from docx_editor_mcp.tools import ( + list_revisions, + open_document, + reject_revision, + replace_text, + ) + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + revisions = list_revisions(server, str(mcp_temp_docx)) + + if revisions["revisions"]: + rev_id = revisions["revisions"][0]["id"] + result = reject_revision(server, str(mcp_temp_docx), rev_id) + assert result["success"] is True + + def test_accept_all(self, server, mcp_temp_docx): + """accept_all accepts all revisions.""" + from docx_editor_mcp.tools import accept_all, open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + + result = accept_all(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "count" in result + + def test_reject_all(self, server, mcp_temp_docx): + """reject_all rejects all revisions.""" + from docx_editor_mcp.tools import open_document, reject_all, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + + result = reject_all(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "count" in result + + +class TestReadTools: + """Test read-only tools (Task 3.5).""" + + def test_find_text(self, server, mcp_temp_docx): + """find_text checks if text exists.""" + from docx_editor_mcp.tools import find_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = find_text(server, str(mcp_temp_docx), "quick brown fox") + + assert result["success"] is True + assert result["found"] is True + + def test_find_text_not_found(self, server, mcp_temp_docx): + """find_text returns found=False for missing text.""" + from docx_editor_mcp.tools import find_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = find_text(server, str(mcp_temp_docx), "nonexistent xyz") + + assert result["success"] is True + assert result["found"] is False + + def test_count_matches(self, server, mcp_temp_docx): + """count_matches returns occurrence count.""" + from docx_editor_mcp.tools import count_matches, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = count_matches(server, str(mcp_temp_docx), "quick brown fox") + + assert result["success"] is True + assert "count" in result + assert result["count"] >= 0 + + def test_get_visible_text(self, server, mcp_temp_docx): + """get_visible_text returns document text.""" + from docx_editor_mcp.tools import get_visible_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = get_visible_text(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "text" in result + assert isinstance(result["text"], str) + + +class TestExplorationTools: + """Test exploration tools for large document support.""" + + def test_search_text_found(self, server, mcp_temp_docx): + """search_text returns matches with context.""" + from docx_editor_mcp.tools import open_document, search_text + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = search_text(server, str(mcp_temp_docx), "quick brown fox") + + assert result["success"] is True + assert result["count"] >= 1 + match = result["matches"][0] + assert "paragraph_ref" in match + assert "context" in match + assert "quick brown fox" in match["context"] + + def test_search_text_not_found(self, server, mcp_temp_docx): + """search_text returns empty list for missing text.""" + from docx_editor_mcp.tools import open_document, search_text + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = search_text(server, str(mcp_temp_docx), "nonexistent xyz abc") + + assert result["success"] is True + assert result["count"] == 0 + assert result["matches"] == [] + + def test_search_text_context_chars(self, server, mcp_temp_docx): + """search_text respects context_chars parameter.""" + from docx_editor_mcp.tools import open_document, search_text + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = search_text(server, str(mcp_temp_docx), "quick brown fox", context_chars=5) + + assert result["success"] is True + assert result["count"] >= 1 + + def test_get_paragraph_text_valid(self, server, mcp_temp_docx): + """get_paragraph_text returns full text for valid refs.""" + from docx_editor_mcp.tools import get_paragraph_text, list_paragraphs, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + paras = list_paragraphs(server, str(mcp_temp_docx)) + ref = paras["paragraphs"][0].split("|")[0].strip() + + result = get_paragraph_text(server, str(mcp_temp_docx), [ref]) + + assert result["success"] is True + assert len(result["paragraphs"]) == 1 + assert result["paragraphs"][0]["error"] is None + assert isinstance(result["paragraphs"][0]["text"], str) + + def test_get_paragraph_text_invalid_ref(self, server, mcp_temp_docx): + """get_paragraph_text returns error for invalid refs.""" + from docx_editor_mcp.tools import get_paragraph_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = get_paragraph_text(server, str(mcp_temp_docx), ["P99#dead"]) + + assert result["success"] is True + assert result["paragraphs"][0]["error"] is not None + + def test_get_paragraph_text_multiple(self, server, mcp_temp_docx): + """get_paragraph_text handles multiple refs including invalid ones.""" + from docx_editor_mcp.tools import get_paragraph_text, list_paragraphs, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + paras = list_paragraphs(server, str(mcp_temp_docx)) + valid_ref = paras["paragraphs"][0].split("|")[0].strip() + + result = get_paragraph_text(server, str(mcp_temp_docx), [valid_ref, "P99#dead"]) + + assert result["success"] is True + assert len(result["paragraphs"]) == 2 + assert result["paragraphs"][0]["error"] is None + assert result["paragraphs"][1]["error"] is not None + + def test_get_document_info(self, server, mcp_temp_docx): + """get_document_info returns paragraph count, word count, headings.""" + from docx_editor_mcp.tools import get_document_info, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = get_document_info(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert "paragraph_count" in result + assert "word_count" in result + assert "headings" in result + assert result["paragraph_count"] > 0 + assert result["word_count"] > 0 + + def test_list_paragraphs_pagination(self, server, mcp_temp_docx): + """list_paragraphs supports start/limit pagination.""" + from docx_editor_mcp.tools import list_paragraphs, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + all_paras = list_paragraphs(server, str(mcp_temp_docx)) + total = all_paras["total"] + assert total > 0 + + # Get first 2 + page1 = list_paragraphs(server, str(mcp_temp_docx), start=0, limit=2) + assert page1["success"] is True + assert len(page1["paragraphs"]) <= 2 + assert page1["total"] == total + + def test_list_paragraphs_start_beyond_end(self, server, mcp_temp_docx): + """list_paragraphs with start beyond end returns empty.""" + from docx_editor_mcp.tools import list_paragraphs, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = list_paragraphs(server, str(mcp_temp_docx), start=9999, limit=10) + assert result["success"] is True + assert result["paragraphs"] == [] + + def test_get_visible_text_small_doc(self, server, mcp_temp_docx): + """get_visible_text returns full text for small docs.""" + from docx_editor_mcp.tools import get_visible_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = get_visible_text(server, str(mcp_temp_docx)) + + assert result["success"] is True + assert result["truncated"] is False + assert "hint" not in result + + def test_get_visible_text_truncation(self, server, mcp_temp_docx): + """get_visible_text truncates with hint when doc exceeds max_chars.""" + from docx_editor_mcp.tools import get_visible_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + # Use very small max_chars to force truncation + result = get_visible_text(server, str(mcp_temp_docx), max_chars=5) + + assert result["success"] is True + assert result["truncated"] is True + assert "hint" in result + assert len(result["text"]) == 5 + + def test_get_visible_text_no_limit(self, server, mcp_temp_docx): + """get_visible_text with max_chars=0 returns full text.""" + from docx_editor_mcp.tools import get_visible_text, open_document + + open_document(server, str(mcp_temp_docx), author="Tester") + + result = get_visible_text(server, str(mcp_temp_docx), max_chars=0) + + assert result["success"] is True + assert result["truncated"] is False + + +class TestExternalChangeDetection: + """Test external change detection across tools.""" + + def test_edit_blocked_on_external_change(self, server, mcp_temp_docx): + """Edit operations fail if file changed externally.""" + from docx_editor_mcp.tools import open_document, replace_text + + open_document(server, str(mcp_temp_docx), author="Tester") + para = _get_paragraph_ref(server, str(mcp_temp_docx), "quick brown fox") + + # Simulate external modification + time.sleep(0.01) + mcp_temp_docx.write_bytes(mcp_temp_docx.read_bytes() + b"external") + + result = replace_text(server, str(mcp_temp_docx), "quick brown fox", "Hi", para) + + assert result["success"] is False + assert "external" in result["error"].lower() diff --git a/uv.lock b/uv.lock index 5c53679..bb6cce4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,38 @@ version = 1 revision = 3 requires-python = ">=3.10, <4.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -43,6 +75,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -275,6 +389,66 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -301,6 +475,11 @@ dependencies = [ { name = "defusedxml" }, ] +[package.optional-dependencies] +mcp = [ + { name = "mcp" }, +] + [package.dev-dependencies] dev = [ { name = "mkdocs" }, @@ -316,7 +495,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "defusedxml", specifier = ">=0.7.1" }] +requires-dist = [ + { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, +] +provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ @@ -386,6 +569,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "identify" version = "2.6.16" @@ -425,6 +654,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "markdown" version = "3.10.1" @@ -519,6 +775,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -718,6 +999,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -727,6 +1164,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.20" @@ -810,6 +1261,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -886,6 +1377,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -901,6 +1406,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruff" version = "0.14.14" @@ -936,6 +1563,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -1060,6 +1713,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1095,6 +1760,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/9d/3b2631931649b1783f5024796ca8ad2b42a01a829b9ce1202d973cc7bce5/uv-0.9.26-py3-none-win_arm64.whl", hash = "sha256:344ff38749b6cd7b7dfdfb382536f168cafe917ae3a5aa78b7a63746ba2a905b", size = 22158123, upload-time = "2026-01-15T20:51:30.939Z" }, ] +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1"