From 0d9b0e1d8879941fcf388429ea8a85d98ca5cc91 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Sat, 25 Oct 2025 00:51:11 -0400 Subject: [PATCH] Copy the MCP server to the top level I want to experiment with using `uvx` from this location, and if it manages all the use cases correctly, we won't clone and copy the server code --- Server/Dockerfile | 15 + Server/README.md | 157 +++ Server/__init__.py | 0 Server/config.py | 47 + Server/models.py | 9 + Server/module_discovery.py | 55 ++ Server/port_discovery.py | 160 +++ Server/pyproject.toml | 40 + Server/pyrightconfig.json | 11 + Server/registry/__init__.py | 22 + Server/registry/resource_registry.py | 53 + Server/registry/tool_registry.py | 51 + Server/reload_sentinel.py | 9 + Server/resources/__init__.py | 53 + Server/resources/menu_items.py | 25 + Server/resources/tests.py | 31 + Server/server.py | 196 ++++ Server/server_version.txt | 1 + Server/telemetry.py | 513 ++++++++++ Server/telemetry_decorator.py | 164 +++ Server/test_telemetry.py | 161 +++ Server/tools/__init__.py | 52 + Server/tools/execute_menu_item.py | 25 + Server/tools/manage_asset.py | 92 ++ Server/tools/manage_editor.py | 74 ++ Server/tools/manage_gameobject.py | 208 ++++ Server/tools/manage_prefabs.py | 58 ++ Server/tools/manage_scene.py | 56 ++ Server/tools/manage_script.py | 552 +++++++++++ Server/tools/manage_shader.py | 60 ++ Server/tools/read_console.py | 101 ++ Server/tools/resource_tools.py | 406 ++++++++ Server/tools/run_tests.py | 74 ++ Server/tools/script_apply_edits.py | 966 ++++++++++++++++++ Server/unity_connection.py | 451 +++++++++ Server/uv.lock | 1370 ++++++++++++++++++++++++++ 36 files changed, 6318 insertions(+) create mode 100644 Server/Dockerfile create mode 100644 Server/README.md create mode 100644 Server/__init__.py create mode 100644 Server/config.py create mode 100644 Server/models.py create mode 100644 Server/module_discovery.py create mode 100644 Server/port_discovery.py create mode 100644 Server/pyproject.toml create mode 100644 Server/pyrightconfig.json create mode 100644 Server/registry/__init__.py create mode 100644 Server/registry/resource_registry.py create mode 100644 Server/registry/tool_registry.py create mode 100644 Server/reload_sentinel.py create mode 100644 Server/resources/__init__.py create mode 100644 Server/resources/menu_items.py create mode 100644 Server/resources/tests.py create mode 100644 Server/server.py create mode 100644 Server/server_version.txt create mode 100644 Server/telemetry.py create mode 100644 Server/telemetry_decorator.py create mode 100644 Server/test_telemetry.py create mode 100644 Server/tools/__init__.py create mode 100644 Server/tools/execute_menu_item.py create mode 100644 Server/tools/manage_asset.py create mode 100644 Server/tools/manage_editor.py create mode 100644 Server/tools/manage_gameobject.py create mode 100644 Server/tools/manage_prefabs.py create mode 100644 Server/tools/manage_scene.py create mode 100644 Server/tools/manage_script.py create mode 100644 Server/tools/manage_shader.py create mode 100644 Server/tools/read_console.py create mode 100644 Server/tools/resource_tools.py create mode 100644 Server/tools/run_tests.py create mode 100644 Server/tools/script_apply_edits.py create mode 100644 Server/unity_connection.py create mode 100644 Server/uv.lock diff --git a/Server/Dockerfile b/Server/Dockerfile new file mode 100644 index 00000000..7c2ec81d --- /dev/null +++ b/Server/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +RUN pip install uv + +COPY . /app + +RUN uv sync + +CMD ["uv", "run", "server.py"] diff --git a/Server/README.md b/Server/README.md new file mode 100644 index 00000000..ad751101 --- /dev/null +++ b/Server/README.md @@ -0,0 +1,157 @@ +# MCP for Unity Server + +[![MCP](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) +[![python](https://img.shields.io/badge/Python-3.11+-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![License](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) +[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) + +Model Context Protocol server for Unity Editor integration. Control Unity through natural language using AI assistants like Claude, Cursor, and more. + +**Maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp)** - This project is not affiliated with Unity Technologies. + +💬 **Join our community:** [Discord Server](https://discord.gg/y4p8KfzrN4) + +**Required:** Install the [Unity MCP Plugin](https://github.com/CoplayDev/unity-mcp?tab=readme-ov-file#-step-1-install-the-unity-package) to connect Unity Editor with this MCP server. + +--- + +## Installation + +### Option 1: Using uvx (Recommended) + +Run directly from GitHub without installation: + +```bash +uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server mcp-for-unity +``` + +**MCP Client Configuration:** + +```json +{ + "mcpServers": { + "UnityMCP": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server", + "mcp-for-unity" + ] + } + } +} +``` + +### Option 2: Using uv (Local Installation) + +For local development or custom installations: + +```bash +# Clone the repository +git clone https://github.com/CoplayDev/unity-mcp.git +cd unity-mcp/Server + +# Run with uv +uv run server.py +``` + +**MCP Client Configuration:** + +**Windows:** +```json +{ + "mcpServers": { + "UnityMCP": { + "command": "uv", + "args": [ + "run", + "--directory", + "C:\\path\\to\\unity-mcp\\Server", + "server.py" + ] + } + } +} +``` + +**macOS/Linux:** +```json +{ + "mcpServers": { + "UnityMCP": { + "command": "uv", + "args": [ + "run", + "--directory", + "/path/to/unity-mcp/Server", + "server.py" + ] + } + } +} +``` + +### Option 3: Using Docker + +```bash +docker build -t unity-mcp-server . +docker run unity-mcp-server +``` + +**MCP Client Configuration:** + +```json +{ + "mcpServers": { + "UnityMCP": { + "command": "docker", + "args": ["run", "-i", "unity-mcp-server"] + } + } +} +``` + +--- + +## Configuration + +The server connects to Unity Editor automatically when both are running. No additional configuration needed. + +**Environment Variables:** + +- `DISABLE_TELEMETRY=true` - Opt out of anonymous usage analytics +- `LOG_LEVEL=DEBUG` - Enable detailed logging (default: INFO) + +--- + +## Example Prompts + +Once connected, try these commands in your AI assistant: + +- "Create a 3D player controller with WASD movement" +- "Add a rotating cube to the scene with a red material" +- "Create a simple platformer level with obstacles" +- "Generate a shader that creates a holographic effect" +- "List all GameObjects in the current scene" + +--- + +## Documentation + +For complete documentation, troubleshooting, and advanced usage: + +📖 **[Full Documentation](https://github.com/CoplayDev/unity-mcp#readme)** + +--- + +## Requirements + +- **Python:** 3.11 or newer +- **Unity Editor:** 2021.3 LTS or newer +- **uv:** Python package manager ([Installation Guide](https://docs.astral.sh/uv/getting-started/installation/)) + +--- + +## License + +MIT License - See [LICENSE](https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE) diff --git a/Server/__init__.py b/Server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Server/config.py b/Server/config.py new file mode 100644 index 00000000..448d431b --- /dev/null +++ b/Server/config.py @@ -0,0 +1,47 @@ +""" +Configuration settings for the MCP for Unity Server. +This file contains all configurable parameters for the server. +""" + +from dataclasses import dataclass + + +@dataclass +class ServerConfig: + """Main configuration class for the MCP server.""" + + # Network settings + unity_host: str = "localhost" + unity_port: int = 6400 + mcp_port: int = 6500 + + # Connection settings + connection_timeout: float = 30.0 + buffer_size: int = 16 * 1024 * 1024 # 16MB buffer + # Framed receive behavior + # max seconds to wait while consuming heartbeats only + framed_receive_timeout: float = 2.0 + # cap heartbeat frames consumed before giving up + max_heartbeat_frames: int = 16 + + # Logging settings + log_level: str = "INFO" + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Server settings + max_retries: int = 5 + retry_delay: float = 0.25 + # Backoff hint returned to clients when Unity is reloading (milliseconds) + reload_retry_ms: int = 250 + # Number of polite retries when Unity reports reloading + # 40 × 250ms ≈ 10s default window + reload_max_retries: int = 40 + + # Telemetry settings + telemetry_enabled: bool = True + # Align with telemetry.py default Cloud Run endpoint + telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" + + +# Create a global config instance +config = ServerConfig() diff --git a/Server/models.py b/Server/models.py new file mode 100644 index 00000000..cf1d33da --- /dev/null +++ b/Server/models.py @@ -0,0 +1,9 @@ +from typing import Any +from pydantic import BaseModel + + +class MCPResponse(BaseModel): + success: bool + message: str | None = None + error: str | None = None + data: Any | None = None diff --git a/Server/module_discovery.py b/Server/module_discovery.py new file mode 100644 index 00000000..4350a998 --- /dev/null +++ b/Server/module_discovery.py @@ -0,0 +1,55 @@ +""" +Shared module discovery utilities for auto-registering tools and resources. +""" +import importlib +import logging +from pathlib import Path +import pkgutil +from typing import Generator + +logger = logging.getLogger("mcp-for-unity-server") + + +def discover_modules(base_dir: Path, package_name: str) -> Generator[str, None, None]: + """ + Discover and import all Python modules in a directory and its subdirectories. + + Args: + base_dir: The base directory to search for modules + package_name: The package name to use for relative imports (e.g., 'tools' or 'resources') + + Yields: + Full module names that were successfully imported + """ + # Discover modules in the top level + for _, module_name, _ in pkgutil.iter_modules([str(base_dir)]): + # Skip private modules and __init__ + if module_name.startswith('_'): + continue + + try: + full_module_name = f'.{module_name}' + importlib.import_module(full_module_name, package_name) + yield full_module_name + except Exception as e: + logger.warning(f"Failed to import module {module_name}: {e}") + + # Discover modules in subdirectories (one level deep) + for subdir in base_dir.iterdir(): + if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name.startswith('.'): + continue + + # Check if subdirectory contains Python modules + for _, module_name, _ in pkgutil.iter_modules([str(subdir)]): + # Skip private modules and __init__ + if module_name.startswith('_'): + continue + + try: + # Import as package.subdirname.modulename + full_module_name = f'.{subdir.name}.{module_name}' + importlib.import_module(full_module_name, package_name) + yield full_module_name + except Exception as e: + logger.warning( + f"Failed to import module {subdir.name}.{module_name}: {e}") diff --git a/Server/port_discovery.py b/Server/port_discovery.py new file mode 100644 index 00000000..c759e745 --- /dev/null +++ b/Server/port_discovery.py @@ -0,0 +1,160 @@ +""" +Port discovery utility for MCP for Unity Server. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a MCP for Unity listener + (quick socket connect + ping) before choosing it. +""" + +import glob +import json +import logging +from pathlib import Path +import socket +from typing import Optional, List + +logger = logging.getLogger("mcp-for-unity-server") + + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a MCP for Unity listener is on this port. + Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Check for Unity bridge welcome message format + if data and (b"WELCOME UNITY-MCP" in data or b'"message":"pong"' in data): + return True + except Exception: + return False + except Exception: + return False + return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) + for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. + + Returns: + Port number to connect to + """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: + try: + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info( + f"Using Unity port from {path.name}: {unity_port}") + return unity_port + except Exception as e: + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info( + f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + + # Fallback to default port + logger.info( + f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. + + Returns: + Port configuration dict or None if not found + """ + candidates = PortDiscovery.list_candidate_files() + if not candidates: + return None + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning( + f"Could not read port configuration {path}: {e}") + return None diff --git a/Server/pyproject.toml b/Server/pyproject.toml new file mode 100644 index 00000000..825c126b --- /dev/null +++ b/Server/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "MCPForUnityServer" +version = "6.3.0" +description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27.2", + "fastmcp>=2.12.5", + "mcp>=1.16.0", + "pydantic>=2.12.0", + "tomli>=2.3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23", +] + +[project.scripts] +mcp-for-unity = "server:main" + +[build-system] +requires = ["setuptools>=64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = [ + "config", + "models", + "module_discovery", + "port_discovery", + "reload_sentinel", + "server", + "telemetry", + "telemetry_decorator", + "unity_connection" +] +packages = ["tools", "resources", "registry"] diff --git a/Server/pyrightconfig.json b/Server/pyrightconfig.json new file mode 100644 index 00000000..4fdeb465 --- /dev/null +++ b/Server/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": "none", + "pythonVersion": "3.11", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.11" + } + ] +} diff --git a/Server/registry/__init__.py b/Server/registry/__init__.py new file mode 100644 index 00000000..179da96d --- /dev/null +++ b/Server/registry/__init__.py @@ -0,0 +1,22 @@ +""" +Registry package for MCP tool auto-discovery. +""" +from .tool_registry import ( + mcp_for_unity_tool, + get_registered_tools, + clear_tool_registry, +) +from .resource_registry import ( + mcp_for_unity_resource, + get_registered_resources, + clear_resource_registry, +) + +__all__ = [ + 'mcp_for_unity_tool', + 'get_registered_tools', + 'clear_tool_registry', + 'mcp_for_unity_resource', + 'get_registered_resources', + 'clear_resource_registry' +] diff --git a/Server/registry/resource_registry.py b/Server/registry/resource_registry.py new file mode 100644 index 00000000..5c8e4260 --- /dev/null +++ b/Server/registry/resource_registry.py @@ -0,0 +1,53 @@ +""" +Resource registry for auto-discovery of MCP resources. +""" +from typing import Callable, Any + +# Global registry to collect decorated resources +_resource_registry: list[dict[str, Any]] = [] + + +def mcp_for_unity_resource( + uri: str, + name: str | None = None, + description: str | None = None, + **kwargs +) -> Callable: + """ + Decorator for registering MCP resources in the server's resources directory. + + Resources are registered in the global resource registry. + + Args: + name: Resource name (defaults to function name) + description: Resource description + **kwargs: Additional arguments passed to @mcp.resource() + + Example: + @mcp_for_unity_resource("mcpforunity://resource", description="Gets something interesting") + async def my_custom_resource(ctx: Context, ...): + pass + """ + def decorator(func: Callable) -> Callable: + resource_name = name if name is not None else func.__name__ + _resource_registry.append({ + 'func': func, + 'uri': uri, + 'name': resource_name, + 'description': description, + 'kwargs': kwargs + }) + + return func + + return decorator + + +def get_registered_resources() -> list[dict[str, Any]]: + """Get all registered resources""" + return _resource_registry.copy() + + +def clear_resource_registry(): + """Clear the resource registry (useful for testing)""" + _resource_registry.clear() diff --git a/Server/registry/tool_registry.py b/Server/registry/tool_registry.py new file mode 100644 index 00000000..08526a5a --- /dev/null +++ b/Server/registry/tool_registry.py @@ -0,0 +1,51 @@ +""" +Tool registry for auto-discovery of MCP tools. +""" +from typing import Callable, Any + +# Global registry to collect decorated tools +_tool_registry: list[dict[str, Any]] = [] + + +def mcp_for_unity_tool( + name: str | None = None, + description: str | None = None, + **kwargs +) -> Callable: + """ + Decorator for registering MCP tools in the server's tools directory. + + Tools are registered in the global tool registry. + + Args: + name: Tool name (defaults to function name) + description: Tool description + **kwargs: Additional arguments passed to @mcp.tool() + + Example: + @mcp_for_unity_tool(description="Does something cool") + async def my_custom_tool(ctx: Context, ...): + pass + """ + def decorator(func: Callable) -> Callable: + tool_name = name if name is not None else func.__name__ + _tool_registry.append({ + 'func': func, + 'name': tool_name, + 'description': description, + 'kwargs': kwargs + }) + + return func + + return decorator + + +def get_registered_tools() -> list[dict[str, Any]]: + """Get all registered tools""" + return _tool_registry.copy() + + +def clear_tool_registry(): + """Clear the tool registry (useful for testing)""" + _tool_registry.clear() diff --git a/Server/reload_sentinel.py b/Server/reload_sentinel.py new file mode 100644 index 00000000..71e5f623 --- /dev/null +++ b/Server/reload_sentinel.py @@ -0,0 +1,9 @@ +""" +Deprecated: Sentinel flipping is handled inside Unity via the MCP menu +'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim. +All functions are no-ops to prevent accidental external writes. +""" + + +def flip_reload_sentinel(*args, **kwargs) -> str: + return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" diff --git a/Server/resources/__init__.py b/Server/resources/__init__.py new file mode 100644 index 00000000..a3577891 --- /dev/null +++ b/Server/resources/__init__.py @@ -0,0 +1,53 @@ +""" +MCP Resources package - Auto-discovers and registers all resources in this directory. +""" +import logging +from pathlib import Path + +from fastmcp import FastMCP +from telemetry_decorator import telemetry_resource + +from registry import get_registered_resources +from module_discovery import discover_modules + +logger = logging.getLogger("mcp-for-unity-server") + +# Export decorator for easy imports within tools +__all__ = ['register_all_resources'] + + +def register_all_resources(mcp: FastMCP): + """ + Auto-discover and register all resources in the resources/ directory. + + Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated + functions will be automatically registered. + """ + logger.info("Auto-discovering MCP for Unity Server resources...") + # Dynamic import of all modules in this directory + resources_dir = Path(__file__).parent + + # Discover and import all modules + list(discover_modules(resources_dir, __package__)) + + resources = get_registered_resources() + + if not resources: + logger.warning("No MCP resources registered!") + return + + for resource_info in resources: + func = resource_info['func'] + uri = resource_info['uri'] + resource_name = resource_info['name'] + description = resource_info['description'] + kwargs = resource_info['kwargs'] + + # Apply the @mcp.resource decorator and telemetry + wrapped = telemetry_resource(resource_name)(func) + wrapped = mcp.resource(uri=uri, name=resource_name, + description=description, **kwargs)(wrapped) + resource_info['func'] = wrapped + logger.debug(f"Registered resource: {resource_name} - {description}") + + logger.info(f"Registered {len(resources)} MCP resources") diff --git a/Server/resources/menu_items.py b/Server/resources/menu_items.py new file mode 100644 index 00000000..d3724659 --- /dev/null +++ b/Server/resources/menu_items.py @@ -0,0 +1,25 @@ +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class GetMenuItemsResponse(MCPResponse): + data: list[str] = [] + + +@mcp_for_unity_resource( + uri="mcpforunity://menu-items", + name="get_menu_items", + description="Provides a list of all menu items." +) +async def get_menu_items() -> GetMenuItemsResponse: + """Provides a list of all menu items.""" + # Later versions of FastMCP support these as query parameters + # See: https://gofastmcp.com/servers/resources#query-parameters + params = { + "refresh": True, + "search": "", + } + + response = await async_send_command_with_retry("get_menu_items", params) + return GetMenuItemsResponse(**response) if isinstance(response, dict) else response diff --git a/Server/resources/tests.py b/Server/resources/tests.py new file mode 100644 index 00000000..4268a143 --- /dev/null +++ b/Server/resources/tests.py @@ -0,0 +1,31 @@ +from typing import Annotated, Literal +from pydantic import BaseModel, Field + +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class TestItem(BaseModel): + name: Annotated[str, Field(description="The name of the test.")] + full_name: Annotated[str, Field(description="The full name of the test.")] + mode: Annotated[Literal["EditMode", "PlayMode"], + Field(description="The mode the test is for.")] + + +class GetTestsResponse(MCPResponse): + data: list[TestItem] = [] + + +@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.") +async def get_tests() -> GetTestsResponse: + """Provides a list of all tests.""" + response = await async_send_command_with_retry("get_tests", {}) + return GetTestsResponse(**response) if isinstance(response, dict) else response + + +@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.") +async def get_tests_for_mode(mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")]) -> GetTestsResponse: + """Provides a list of tests for a specific mode.""" + response = await async_send_command_with_retry("get_tests_for_mode", {"mode": mode}) + return GetTestsResponse(**response) if isinstance(response, dict) else response diff --git a/Server/server.py b/Server/server.py new file mode 100644 index 00000000..11053ac8 --- /dev/null +++ b/Server/server.py @@ -0,0 +1,196 @@ +from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType +from fastmcp import FastMCP +import logging +from logging.handlers import RotatingFileHandler +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any +from config import config +from tools import register_all_tools +from resources import register_all_resources +from unity_connection import get_unity_connection, UnityConnection +import time + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format, + stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio + force=True # Ensure our handler replaces any prior stdout handlers +) +logger = logging.getLogger("mcp-for-unity-server") + +# Also write logs to a rotating file so logs are available when launched via stdio +try: + import os as _os + _log_dir = _os.path.join(_os.path.expanduser( + "~/Library/Application Support/UnityMCP"), "Logs") + _os.makedirs(_log_dir, exist_ok=True) + _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") + _fh = RotatingFileHandler( + _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") + _fh.setFormatter(logging.Formatter(config.log_format)) + _fh.setLevel(getattr(logging, config.log_level)) + logger.addHandler(_fh) + # Also route telemetry logger to the same rotating file and normal level + try: + tlog = logging.getLogger("unity-mcp-telemetry") + tlog.setLevel(getattr(logging, config.log_level)) + tlog.addHandler(_fh) + except Exception: + # Never let logging setup break startup + pass +except Exception: + # Never let logging setup break startup + pass +# Quieten noisy third-party loggers to avoid clutter during stdio handshake +for noisy in ("httpx", "urllib3"): + try: + logging.getLogger(noisy).setLevel( + max(logging.WARNING, getattr(logging, config.log_level))) + except Exception: + pass + +# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels +# Ensure a slightly higher telemetry timeout unless explicitly overridden by env +try: + + # Ensure generous timeout unless explicitly overridden by env + if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): + os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" +except Exception: + pass + +# Global connection state +_unity_connection: UnityConnection = None + + +@asynccontextmanager +async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: + """Handle server startup and shutdown.""" + global _unity_connection + logger.info("MCP for Unity Server starting up") + + # Record server startup telemetry + start_time = time.time() + start_clk = time.perf_counter() + try: + from pathlib import Path + ver_path = Path(__file__).parent / "server_version.txt" + server_version = ver_path.read_text(encoding="utf-8").strip() + except Exception: + server_version = "unknown" + # Defer initial telemetry by 1s to avoid stdio handshake interference + import threading + + def _emit_startup(): + try: + record_telemetry(RecordType.STARTUP, { + "server_version": server_version, + "startup_time": start_time, + }) + record_milestone(MilestoneType.FIRST_STARTUP) + except Exception: + logger.debug("Deferred startup telemetry failed", exc_info=True) + threading.Timer(1.0, _emit_startup).start() + + try: + skip_connect = os.environ.get( + "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") + if skip_connect: + logger.info( + "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") + else: + _unity_connection = get_unity_connection() + logger.info("Connected to Unity on startup") + + # Record successful Unity connection (deferred) + import threading as _t + _t.Timer(1.0, lambda: record_telemetry( + RecordType.UNITY_CONNECTION, + { + "status": "connected", + "connection_time_ms": (time.perf_counter() - start_clk) * 1000, + } + )).start() + + except ConnectionError as e: + logger.warning("Could not connect to Unity on startup: %s", e) + _unity_connection = None + + # Record connection failure (deferred) + import threading as _t + _err_msg = str(e)[:200] + _t.Timer(1.0, lambda: record_telemetry( + RecordType.UNITY_CONNECTION, + { + "status": "failed", + "error": _err_msg, + "connection_time_ms": (time.perf_counter() - start_clk) * 1000, + } + )).start() + except Exception as e: + logger.warning( + "Unexpected error connecting to Unity on startup: %s", e) + _unity_connection = None + import threading as _t + _err_msg = str(e)[:200] + _t.Timer(1.0, lambda: record_telemetry( + RecordType.UNITY_CONNECTION, + { + "status": "failed", + "error": _err_msg, + "connection_time_ms": (time.perf_counter() - start_clk) * 1000, + } + )).start() + + try: + # Yield the connection object so it can be attached to the context + # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) + yield {"bridge": _unity_connection} + finally: + if _unity_connection: + _unity_connection.disconnect() + _unity_connection = None + logger.info("MCP for Unity Server shut down") + +# Initialize MCP server +mcp = FastMCP( + name="mcp-for-unity-server", + lifespan=server_lifespan, + instructions=""" +This server provides tools to interact with the Unity Game Engine Editor.\n\n +Available tools:\n +- `manage_editor`: Controls editor state and queries info.\n +- `execute_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n +- `read_console`: Reads or clears Unity console messages, with filtering options.\n +- `manage_scene`: Manages scenes.\n +- `manage_gameobject`: Manages GameObjects in the scene.\n +- `manage_script`: Manages C# script files.\n +- `manage_asset`: Manages prefabs and assets.\n +- `manage_shader`: Manages shaders.\n\n +- Tips:\n +- Create prefabs for reusable GameObjects.\n +- Always include a camera and main light in your scenes.\n +- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n +- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n +- Use `execute_menu_item` for interacting with Unity systems and third party tools like a user would.\n + +""" +) + +# Register all tools +register_all_tools(mcp) + +# Register all resources +register_all_resources(mcp) + + +def main(): + """Entry point for uvx and console scripts.""" + mcp.run(transport='stdio') + + +# Run the server +if __name__ == "__main__": + main() diff --git a/Server/server_version.txt b/Server/server_version.txt new file mode 100644 index 00000000..798e3899 --- /dev/null +++ b/Server/server_version.txt @@ -0,0 +1 @@ +6.3.0 diff --git a/Server/telemetry.py b/Server/telemetry.py new file mode 100644 index 00000000..a28e14fc --- /dev/null +++ b/Server/telemetry.py @@ -0,0 +1,513 @@ +""" +Privacy-focused, anonymous telemetry system for MCP for Unity +Inspired by Onyx's telemetry implementation with Unity-specific adaptations + +Fire-and-forget telemetry sender with a single background worker. +- No context/thread-local propagation to avoid re-entrancy into tool resolution. +- Small network timeouts to prevent stalls. +""" + +import contextlib +from dataclasses import dataclass +from enum import Enum +from importlib import import_module, metadata +import json +import logging +import os +from pathlib import Path +import platform +import queue +import sys +import threading +import time +from typing import Any +from urllib.parse import urlparse +import uuid + +import tomli + +try: + import httpx + HAS_HTTPX = True +except ImportError: + httpx = None # type: ignore + HAS_HTTPX = False + +logger = logging.getLogger("unity-mcp-telemetry") + + +def get_package_version() -> str: + """ + Get package version in different ways: + 1. First we try the installed metadata - this is because uvx is used on the asset store + 2. If that fails, we try to read from pyproject.toml - this is available for users who download via Git + Default is "unknown", but that should never happen + """ + try: + return metadata.version("MCPForUnityServer") + except Exception: + # Fallback for development: read from pyproject.toml + try: + pyproject_path = Path(__file__).parent / "pyproject.toml" + with open(pyproject_path, "rb") as f: + data = tomli.load(f) + return data["project"]["version"] + except Exception: + return "unknown" + + +MCP_VERSION = get_package_version() + + +class RecordType(str, Enum): + """Types of telemetry records we collect""" + VERSION = "version" + STARTUP = "startup" + USAGE = "usage" + LATENCY = "latency" + FAILURE = "failure" + RESOURCE_RETRIEVAL = "resource_retrieval" + TOOL_EXECUTION = "tool_execution" + UNITY_CONNECTION = "unity_connection" + CLIENT_CONNECTION = "client_connection" + + +class MilestoneType(str, Enum): + """Major user journey milestones""" + FIRST_STARTUP = "first_startup" + FIRST_TOOL_USAGE = "first_tool_usage" + FIRST_SCRIPT_CREATION = "first_script_creation" + FIRST_SCENE_MODIFICATION = "first_scene_modification" + MULTIPLE_SESSIONS = "multiple_sessions" + DAILY_ACTIVE_USER = "daily_active_user" + WEEKLY_ACTIVE_USER = "weekly_active_user" + + +@dataclass +class TelemetryRecord: + """Structure for telemetry data""" + record_type: RecordType + timestamp: float + customer_uuid: str + session_id: str + data: dict[str, Any] + milestone: MilestoneType | None = None + + +class TelemetryConfig: + """Telemetry configuration""" + + def __init__(self): + """ + Prefer config file, then allow env overrides + """ + server_config = None + for modname in ( + # Prefer plain module to respect test-time overrides and sys.path injection + "config", + "src.config", + "MCPForUnity.UnityMcpServer~.src.config", + "MCPForUnity.UnityMcpServer.src.config", + ): + try: + mod = import_module(modname) + server_config = getattr(mod, "config", None) + if server_config is not None: + break + except Exception: + continue + + # Determine enabled flag: config -> env DISABLE_* opt-out + cfg_enabled = True if server_config is None else bool( + getattr(server_config, "telemetry_enabled", True)) + self.enabled = cfg_enabled and not self._is_disabled() + + # Telemetry endpoint (Cloud Run default; override via env) + cfg_default = None if server_config is None else getattr( + server_config, "telemetry_endpoint", None) + default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" + self.default_endpoint = default_ep + # Prefer config default; allow explicit env override only when set + env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT") + if env_ep is not None and env_ep != "": + self.endpoint = self._validated_endpoint(env_ep, default_ep) + else: + # Validate config-provided default as well to enforce scheme/host rules + self.endpoint = self._validated_endpoint(default_ep, default_ep) + try: + logger.info( + "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", + self.endpoint, + default_ep, + os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "" + ) + except Exception: + pass + + # Local storage for UUID and milestones + self.data_dir = self._get_data_directory() + self.uuid_file = self.data_dir / "customer_uuid.txt" + self.milestones_file = self.data_dir / "milestones.json" + + # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT + try: + self.timeout = float(os.environ.get( + "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) + except Exception: + self.timeout = 1.5 + try: + logger.info("Telemetry timeout=%.2fs", self.timeout) + except Exception: + pass + + # Session tracking + self.session_id = str(uuid.uuid4()) + + def _is_disabled(self) -> bool: + """Check if telemetry is disabled via environment variables""" + disable_vars = [ + "DISABLE_TELEMETRY", + "UNITY_MCP_DISABLE_TELEMETRY", + "MCP_DISABLE_TELEMETRY" + ] + + for var in disable_vars: + if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): + return True + return False + + def _get_data_directory(self) -> Path: + """Get directory for storing telemetry data""" + if os.name == 'nt': # Windows + base_dir = Path(os.environ.get( + 'APPDATA', Path.home() / 'AppData' / 'Roaming')) + elif os.name == 'posix': # macOS/Linux + if 'darwin' in os.uname().sysname.lower(): # macOS + base_dir = Path.home() / 'Library' / 'Application Support' + else: # Linux + base_dir = Path(os.environ.get('XDG_DATA_HOME', + Path.home() / '.local' / 'share')) + else: + base_dir = Path.home() / '.unity-mcp' + + data_dir = base_dir / 'UnityMCP' + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + def _validated_endpoint(self, candidate: str, fallback: str) -> str: + """Validate telemetry endpoint URL scheme; allow only http/https. + Falls back to the provided default on error. + """ + try: + parsed = urlparse(candidate) + if parsed.scheme not in ("https", "http"): + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + # Basic sanity: require network location and path + if not parsed.netloc: + raise ValueError("Missing netloc in endpoint") + # Reject localhost/loopback endpoints in production to avoid accidental local overrides + host = parsed.hostname or "" + if host in ("localhost", "127.0.0.1", "::1"): + raise ValueError( + "Localhost endpoints are not allowed for telemetry") + return candidate + except Exception as e: + logger.debug( + f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}", + exc_info=True, + ) + return fallback + + +class TelemetryCollector: + """Main telemetry collection class""" + + def __init__(self): + self.config = TelemetryConfig() + self._customer_uuid: str | None = None + self._milestones: dict[str, dict[str, Any]] = {} + self._lock: threading.Lock = threading.Lock() + # Bounded queue with single background worker (records only; no context propagation) + self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) + # Load persistent data before starting worker so first events have UUID + self._load_persistent_data() + self._worker: threading.Thread = threading.Thread( + target=self._worker_loop, daemon=True) + self._worker.start() + + def _load_persistent_data(self): + """Load UUID and milestones from disk""" + # Load customer UUID + try: + if self.config.uuid_file.exists(): + self._customer_uuid = self.config.uuid_file.read_text( + encoding="utf-8").strip() or str(uuid.uuid4()) + else: + self._customer_uuid = str(uuid.uuid4()) + try: + self.config.uuid_file.write_text( + self._customer_uuid, encoding="utf-8") + if os.name == "posix": + os.chmod(self.config.uuid_file, 0o600) + except OSError as e: + logger.debug( + f"Failed to persist customer UUID: {e}", exc_info=True) + except OSError as e: + logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) + self._customer_uuid = str(uuid.uuid4()) + + # Load milestones (failure here must not affect UUID) + try: + if self.config.milestones_file.exists(): + content = self.config.milestones_file.read_text( + encoding="utf-8") + self._milestones = json.loads(content) or {} + if not isinstance(self._milestones, dict): + self._milestones = {} + except (OSError, json.JSONDecodeError, ValueError) as e: + logger.debug(f"Failed to load milestones: {e}", exc_info=True) + self._milestones = {} + + def _save_milestones(self): + """Save milestones to disk. Caller must hold self._lock.""" + try: + self.config.milestones_file.write_text( + json.dumps(self._milestones, indent=2), + encoding="utf-8", + ) + except OSError as e: + logger.warning(f"Failed to save milestones: {e}", exc_info=True) + + def record_milestone(self, milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool: + """Record a milestone event, returns True if this is the first occurrence""" + if not self.config.enabled: + return False + milestone_key = milestone.value + with self._lock: + if milestone_key in self._milestones: + return False # Already recorded + milestone_data = { + "timestamp": time.time(), + "data": data or {}, + } + self._milestones[milestone_key] = milestone_data + self._save_milestones() + + # Also send as telemetry record + self.record( + record_type=RecordType.USAGE, + data={"milestone": milestone_key, **(data or {})}, + milestone=milestone + ) + + return True + + def record(self, + record_type: RecordType, + data: dict[str, Any], + milestone: MilestoneType | None = None): + """Record a telemetry event (async, non-blocking)""" + if not self.config.enabled: + return + + # Allow fallback sender when httpx is unavailable (no early return) + + record = TelemetryRecord( + record_type=record_type, + timestamp=time.time(), + customer_uuid=self._customer_uuid or "unknown", + session_id=self.config.session_id, + data=data, + milestone=milestone + ) + # Enqueue for background worker (non-blocking). Drop on backpressure. + try: + self._queue.put_nowait(record) + except queue.Full: + logger.debug("Telemetry queue full; dropping %s", + record.record_type) + + def _worker_loop(self): + """Background worker that serializes telemetry sends.""" + while True: + rec = self._queue.get() + try: + # Run sender directly; do not reuse caller context/thread-locals + self._send_telemetry(rec) + except Exception: + logger.debug("Telemetry worker send failed", exc_info=True) + finally: + with contextlib.suppress(Exception): + self._queue.task_done() + + def _send_telemetry(self, record: TelemetryRecord): + """Send telemetry data to endpoint""" + try: + # System fingerprint (top-level remains concise; details stored in data JSON) + _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows' + _source = sys.platform # 'darwin' | 'linux' | 'win32' + _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})" + _python_version = platform.python_version() + + # Enrich data JSON so BigQuery stores detailed fields without schema change + enriched_data = dict(record.data or {}) + enriched_data.setdefault("platform_detail", _platform_detail) + enriched_data.setdefault("python_version", _python_version) + + payload = { + "record": record.record_type.value, + "timestamp": record.timestamp, + "customer_uuid": record.customer_uuid, + "session_id": record.session_id, + "data": enriched_data, + "version": MCP_VERSION, + "platform": _platform, + "source": _source, + } + + if record.milestone: + payload["milestone"] = record.milestone.value + + # Prefer httpx when available; otherwise fall back to urllib + if httpx: + with httpx.Client(timeout=self.config.timeout) as client: + # Re-validate endpoint at send time to handle dynamic changes + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) + response = client.post(endpoint, json=payload) + if 200 <= response.status_code < 300: + logger.debug(f"Telemetry sent: {record.record_type}") + else: + logger.warning( + f"Telemetry failed: HTTP {response.status_code}") + else: + import urllib.request + import urllib.error + data_bytes = json.dumps(payload).encode("utf-8") + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) + req = urllib.request.Request( + endpoint, + data=data_bytes, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: + if 200 <= resp.getcode() < 300: + logger.debug( + f"Telemetry sent (urllib): {record.record_type}") + else: + logger.warning( + f"Telemetry failed (urllib): HTTP {resp.getcode()}") + except urllib.error.URLError as ue: + logger.warning(f"Telemetry send failed (urllib): {ue}") + + except Exception as e: + # Never let telemetry errors interfere with app functionality + logger.debug(f"Telemetry send failed: {e}") + + +# Global telemetry instance +_telemetry_collector: TelemetryCollector | None = None + + +def get_telemetry() -> TelemetryCollector: + """Get the global telemetry collector instance""" + global _telemetry_collector + if _telemetry_collector is None: + _telemetry_collector = TelemetryCollector() + return _telemetry_collector + + +def record_telemetry(record_type: RecordType, + data: dict[str, Any], + milestone: MilestoneType | None = None): + """Convenience function to record telemetry""" + get_telemetry().record(record_type, data, milestone) + + +def record_milestone(milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool: + """Convenience function to record a milestone""" + return get_telemetry().record_milestone(milestone, data) + + +def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: str | None = None, sub_action: str | None = None): + """Record tool usage telemetry + + Args: + tool_name: Name of the tool invoked (e.g., 'manage_scene'). + success: Whether the tool completed successfully. + duration_ms: Execution duration in milliseconds. + error: Optional error message (truncated if present). + sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy'). + """ + data = { + "tool_name": tool_name, + "success": success, + "duration_ms": round(duration_ms, 2) + } + + if sub_action is not None: + try: + data["sub_action"] = str(sub_action) + except Exception: + # Ensure telemetry is never disruptive + data["sub_action"] = "unknown" + + if error: + data["error"] = str(error)[:200] # Limit error message length + + record_telemetry(RecordType.TOOL_EXECUTION, data) + + +def record_resource_usage(resource_name: str, success: bool, duration_ms: float, error: str | None = None): + """Record resource usage telemetry + + Args: + resource_name: Name of the resource invoked (e.g., 'get_tests'). + success: Whether the resource completed successfully. + duration_ms: Execution duration in milliseconds. + error: Optional error message (truncated if present). + """ + data = { + "resource_name": resource_name, + "success": success, + "duration_ms": round(duration_ms, 2) + } + + if error: + data["error"] = str(error)[:200] # Limit error message length + + record_telemetry(RecordType.RESOURCE_RETRIEVAL, data) + + +def record_latency(operation: str, duration_ms: float, metadata: dict[str, Any] | None = None): + """Record latency telemetry""" + data = { + "operation": operation, + "duration_ms": round(duration_ms, 2) + } + + if metadata: + data.update(metadata) + + record_telemetry(RecordType.LATENCY, data) + + +def record_failure(component: str, error: str, metadata: dict[str, Any] | None = None): + """Record failure telemetry""" + data = { + "component": component, + "error": str(error)[:500] # Limit error message length + } + + if metadata: + data.update(metadata) + + record_telemetry(RecordType.FAILURE, data) + + +def is_telemetry_enabled() -> bool: + """Check if telemetry is enabled""" + return get_telemetry().config.enabled diff --git a/Server/telemetry_decorator.py b/Server/telemetry_decorator.py new file mode 100644 index 00000000..446ead56 --- /dev/null +++ b/Server/telemetry_decorator.py @@ -0,0 +1,164 @@ +""" +Telemetry decorator for MCP for Unity tools +""" + +import functools +import inspect +import logging +import time +from typing import Callable, Any + +from telemetry import record_resource_usage, record_tool_usage, record_milestone, MilestoneType + +_log = logging.getLogger("unity-mcp-telemetry") +_decorator_log_count = 0 + + +def telemetry_tool(tool_name: str): + """Decorator to add telemetry tracking to MCP tools""" + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def _sync_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + # Extract sub-action (e.g., 'get_hierarchy') from bound args when available + sub_action = None + try: + sig = inspect.signature(func) + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + sub_action = bound.arguments.get("action") + except Exception: + sub_action = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info(f"telemetry_decorator sync: tool={tool_name}") + _decorator_log_count += 1 + result = func(*args, **kwargs) + success = True + action_val = sub_action or kwargs.get("action") + try: + if tool_name == "manage_script" and action_val == "create": + record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) + elif tool_name.startswith("manage_scene"): + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone(MilestoneType.FIRST_TOOL_USAGE) + except Exception: + _log.debug("milestone emit failed", exc_info=True) + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) + except Exception: + _log.debug("record_tool_usage failed", exc_info=True) + + @functools.wraps(func) + async def _async_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + # Extract sub-action (e.g., 'get_hierarchy') from bound args when available + sub_action = None + try: + sig = inspect.signature(func) + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + sub_action = bound.arguments.get("action") + except Exception: + sub_action = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info(f"telemetry_decorator async: tool={tool_name}") + _decorator_log_count += 1 + result = await func(*args, **kwargs) + success = True + action_val = sub_action or kwargs.get("action") + try: + if tool_name == "manage_script" and action_val == "create": + record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) + elif tool_name.startswith("manage_scene"): + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone(MilestoneType.FIRST_TOOL_USAGE) + except Exception: + _log.debug("milestone emit failed", exc_info=True) + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) + except Exception: + _log.debug("record_tool_usage failed", exc_info=True) + + return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper + return decorator + + +def telemetry_resource(resource_name: str): + """Decorator to add telemetry tracking to MCP resources""" + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def _sync_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info( + f"telemetry_decorator sync: resource={resource_name}") + _decorator_log_count += 1 + result = func(*args, **kwargs) + success = True + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_resource_usage(resource_name, success, + duration_ms, error) + except Exception: + _log.debug("record_resource_usage failed", exc_info=True) + + @functools.wraps(func) + async def _async_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info( + f"telemetry_decorator async: resource={resource_name}") + _decorator_log_count += 1 + result = await func(*args, **kwargs) + success = True + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_resource_usage(resource_name, success, + duration_ms, error) + except Exception: + _log.debug("record_resource_usage failed", exc_info=True) + + return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper + return decorator diff --git a/Server/test_telemetry.py b/Server/test_telemetry.py new file mode 100644 index 00000000..3e4b7ce7 --- /dev/null +++ b/Server/test_telemetry.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Test script for MCP for Unity Telemetry System +Run this to verify telemetry is working correctly +""" + +import os +from pathlib import Path +import sys + +# Add src to Python path for imports +sys.path.insert(0, str(Path(__file__).parent)) + + +def test_telemetry_basic(): + """Test basic telemetry functionality""" + # Avoid stdout noise in tests + + try: + from telemetry import ( + get_telemetry, record_telemetry, record_milestone, + RecordType, MilestoneType, is_telemetry_enabled + ) + pass + except ImportError as e: + # Silent failure path for tests + return False + + # Test telemetry enabled status + _ = is_telemetry_enabled() + + # Test basic record + try: + record_telemetry(RecordType.VERSION, { + "version": "3.0.2", + "test_run": True + }) + pass + except Exception as e: + # Silent failure path for tests + return False + + # Test milestone recording + try: + is_first = record_milestone(MilestoneType.FIRST_STARTUP, { + "test_mode": True + }) + _ = is_first + except Exception as e: + # Silent failure path for tests + return False + + # Test telemetry collector + try: + collector = get_telemetry() + _ = collector + except Exception as e: + # Silent failure path for tests + return False + + return True + + +def test_telemetry_disabled(): + """Test telemetry with disabled state""" + # Silent for tests + + # Set environment variable to disable telemetry + os.environ["DISABLE_TELEMETRY"] = "true" + + # Re-import to get fresh config + import importlib + import telemetry + importlib.reload(telemetry) + + from telemetry import is_telemetry_enabled, record_telemetry, RecordType + + _ = is_telemetry_enabled() + + if not is_telemetry_enabled(): + pass + + # Test that records are ignored when disabled + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + pass + + return True + else: + pass + return False + + +def test_data_storage(): + """Test data storage functionality""" + # Silent for tests + + try: + from telemetry import get_telemetry + + collector = get_telemetry() + data_dir = collector.config.data_dir + + _ = (data_dir, collector.config.uuid_file, + collector.config.milestones_file) + + # Check if files exist + if collector.config.uuid_file.exists(): + pass + else: + pass + + if collector.config.milestones_file.exists(): + pass + else: + pass + + return True + + except Exception as e: + # Silent failure path for tests + return False + + +def main(): + """Run all telemetry tests""" + # Silent runner for CI + + tests = [ + test_telemetry_basic, + test_data_storage, + test_telemetry_disabled, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + if test(): + passed += 1 + pass + else: + failed += 1 + pass + except Exception as e: + failed += 1 + pass + + _ = (passed, failed) + + if failed == 0: + pass + return True + else: + pass + return False + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/Server/tools/__init__.py b/Server/tools/__init__.py new file mode 100644 index 00000000..502cf45f --- /dev/null +++ b/Server/tools/__init__.py @@ -0,0 +1,52 @@ +""" +MCP Tools package - Auto-discovers and registers all tools in this directory. +""" +import logging +from pathlib import Path + +from fastmcp import FastMCP +from telemetry_decorator import telemetry_tool + +from registry import get_registered_tools +from module_discovery import discover_modules + +logger = logging.getLogger("mcp-for-unity-server") + +# Export decorator for easy imports within tools +__all__ = ['register_all_tools'] + + +def register_all_tools(mcp: FastMCP): + """ + Auto-discover and register all tools in the tools/ directory. + + Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated + functions will be automatically registered. + """ + logger.info("Auto-discovering MCP for Unity Server tools...") + # Dynamic import of all modules in this directory + tools_dir = Path(__file__).parent + + # Discover and import all modules + list(discover_modules(tools_dir, __package__)) + + tools = get_registered_tools() + + if not tools: + logger.warning("No MCP tools registered!") + return + + for tool_info in tools: + func = tool_info['func'] + tool_name = tool_info['name'] + description = tool_info['description'] + kwargs = tool_info['kwargs'] + + # Apply the @mcp.tool decorator and telemetry + wrapped = telemetry_tool(tool_name)(func) + wrapped = mcp.tool( + name=tool_name, description=description, **kwargs)(wrapped) + tool_info['func'] = wrapped + logger.debug(f"Registered tool: {tool_name} - {description}") + + logger.info(f"Registered {len(tools)} MCP tools") diff --git a/Server/tools/execute_menu_item.py b/Server/tools/execute_menu_item.py new file mode 100644 index 00000000..a1489c59 --- /dev/null +++ b/Server/tools/execute_menu_item.py @@ -0,0 +1,25 @@ +""" +Defines the execute_menu_item tool for executing and reading Unity Editor menu items. +""" +from typing import Annotated, Any + +from fastmcp import Context + +from models import MCPResponse +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Execute a Unity menu item by path." +) +async def execute_menu_item( + ctx: Context, + menu_path: Annotated[str, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, +) -> MCPResponse: + await ctx.info(f"Processing execute_menu_item: {menu_path}") + params_dict: dict[str, Any] = {"menuPath": menu_path} + params_dict = {k: v for k, v in params_dict.items() if v is not None} + result = await async_send_command_with_retry("execute_menu_item", params_dict) + return MCPResponse(**result) if isinstance(result, dict) else result diff --git a/Server/tools/manage_asset.py b/Server/tools/manage_asset.py new file mode 100644 index 00000000..a577e94d --- /dev/null +++ b/Server/tools/manage_asset.py @@ -0,0 +1,92 @@ +""" +Defines the manage_asset tool for interacting with Unity assets. +""" +import asyncio +import json +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Performs asset operations (import, create, modify, delete, etc.) in Unity." +) +async def manage_asset( + ctx: Context, + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], + path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], + asset_type: Annotated[str, + "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, + properties: Annotated[dict[str, Any], + "Dictionary of properties for 'create'/'modify'."] | None = None, + destination: Annotated[str, + "Target path for 'duplicate'/'move'."] | None = None, + generate_preview: Annotated[bool, + "Generate a preview/thumbnail for the asset when supported."] = False, + search_pattern: Annotated[str, + "Search pattern (e.g., '*.prefab')."] | None = None, + filter_type: Annotated[str, "Filter type for search"] | None = None, + filter_date_after: Annotated[str, + "Date after which to filter"] | None = None, + page_size: Annotated[int | float | str, "Page size for pagination"] | None = None, + page_number: Annotated[int | float | str, "Page number for pagination"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing manage_asset: {action}") + # Coerce 'properties' from JSON string to dict for client compatibility + if isinstance(properties, str): + try: + properties = json.loads(properties) + ctx.info("manage_asset: coerced properties from JSON string to dict") + except Exception as e: + ctx.warn(f"manage_asset: failed to parse properties JSON string: {e}") + # Leave properties as-is; Unity side may handle defaults + # Ensure properties is a dict if None + if properties is None: + properties = {} + + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + page_size = _coerce_int(page_size) + page_number = _coerce_int(page_number) + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } + + # Remove None values to avoid sending unnecessary nulls + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Get the current asyncio event loop + loop = asyncio.get_running_loop() + + # Use centralized async retry helper to avoid blocking the event loop + result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) + # Return the result obtained from Unity + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tools/manage_editor.py b/Server/tools/manage_editor.py new file mode 100644 index 00000000..f7911458 --- /dev/null +++ b/Server/tools/manage_editor.py @@ -0,0 +1,74 @@ +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from telemetry import is_telemetry_enabled, record_tool_usage +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted." +) +def manage_editor( + ctx: Context, + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], + wait_for_completion: Annotated[bool | str, + "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None, + tool_name: Annotated[str, + "Tool name when setting active tool"] | None = None, + tag_name: Annotated[str, + "Tag name when adding and removing tags"] | None = None, + layer_name: Annotated[str, + "Layer name when adding and removing layers"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_editor: {action}") + + # Coerce boolean parameters defensively to tolerate 'true'/'false' strings + def _coerce_bool(value, default=None): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): # common truthy strings + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + wait_for_completion = _coerce_bool(wait_for_completion) + + try: + # Diagnostics: quick telemetry checks + if action == "telemetry_status": + return {"success": True, "telemetry_enabled": is_telemetry_enabled()} + + if action == "telemetry_ping": + record_tool_usage("diagnostic_ping", True, 1.0, None) + return {"success": True, "message": "telemetry ping queued"} + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command using centralized retry helper + response = send_command_with_retry("manage_editor", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} diff --git a/Server/tools/manage_gameobject.py b/Server/tools/manage_gameobject.py new file mode 100644 index 00000000..794013b9 --- /dev/null +++ b/Server/tools/manage_gameobject.py @@ -0,0 +1,208 @@ +import json +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Manage GameObjects. For booleans, send true/false; if your client only sends strings, 'true'/'false' are accepted. Vectors may be [x,y,z] or a string like '[x,y,z]'. For 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." +) +def manage_gameobject( + ctx: Context, + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], + target: Annotated[str, + "GameObject identifier by name or path for modify/delete/component actions"] | None = None, + search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], + "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, + name: Annotated[str, + "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, + tag: Annotated[str, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, + parent: Annotated[str, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, + position: Annotated[list[float] | str, + "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, + rotation: Annotated[list[float] | str, + "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, + scale: Annotated[list[float] | str, + "Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, + components_to_add: Annotated[list[str], + "List of component names to add"] | None = None, + primitive_type: Annotated[str, + "Primitive type for 'create' action"] | None = None, + save_as_prefab: Annotated[bool | str, + "If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None, + prefab_path: Annotated[str, "Path for prefab creation"] | None = None, + prefab_folder: Annotated[str, + "Folder for prefab creation"] | None = None, + # --- Parameters for 'modify' --- + set_active: Annotated[bool | str, + "If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None, + layer: Annotated[str, "Layer name"] | None = None, + components_to_remove: Annotated[list[str], + "List of component names to remove"] | None = None, + component_properties: Annotated[dict[str, dict[str, Any]] | str, + """Dictionary of component names to their properties to set. For example: + `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject + `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component + Example set nested property: + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, + # --- Parameters for 'find' --- + search_term: Annotated[str, + "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, + find_all: Annotated[bool | str, + "If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None, + search_in_children: Annotated[bool | str, + "If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None, + search_inactive: Annotated[bool | str, + "If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None, + # -- Component Management Arguments -- + component_name: Annotated[str, + "Component name for 'add_component' and 'remove_component' actions"] | None = None, + # Controls whether serialization of private [SerializeField] fields is included + includeNonPublicSerialized: Annotated[bool | str, + "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_gameobject: {action}") + + # Coercers to tolerate stringified booleans and vectors + def _coerce_bool(value, default=None): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + def _coerce_vec(value, default=None): + if value is None: + return default + import math + def _to_vec3(parts): + try: + vec = [float(parts[0]), float(parts[1]), float(parts[2])] + except (ValueError, TypeError): + return default + return vec if all(math.isfinite(n) for n in vec) else default + if isinstance(value, list) and len(value) == 3: + return _to_vec3(value) + if isinstance(value, str): + s = value.strip() + # minimal tolerant parse for "[x,y,z]" or "x,y,z" + if s.startswith("[") and s.endswith("]"): + s = s[1:-1] + # support "x,y,z" and "x y z" + parts = [p.strip() for p in (s.split(",") if "," in s else s.split())] + if len(parts) == 3: + return _to_vec3(parts) + return default + + position = _coerce_vec(position, default=position) + rotation = _coerce_vec(rotation, default=rotation) + scale = _coerce_vec(scale, default=scale) + save_as_prefab = _coerce_bool(save_as_prefab) + set_active = _coerce_bool(set_active) + find_all = _coerce_bool(find_all) + search_in_children = _coerce_bool(search_in_children) + search_inactive = _coerce_bool(search_inactive) + includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) + + # Coerce 'component_properties' from JSON string to dict for client compatibility + if isinstance(component_properties, str): + try: + component_properties = json.loads(component_properties) + ctx.info("manage_gameobject: coerced component_properties from JSON string to dict") + except json.JSONDecodeError as e: + return {"success": False, "message": f"Invalid JSON in component_properties: {e}"} + # Ensure final type is a dict (object) if provided + if component_properties is not None and not isinstance(component_properties, dict): + return {"success": False, "message": "component_properties must be a JSON object (dict)."} + try: + # Map tag to search_term when search_method is by_tag for backward compatibility + if action == "find" and search_method == "by_tag" and tag is not None and search_term is None: + search_term = tag + + # Validate parameter usage to prevent silent failures + if action == "find": + if name is not None: + return { + "success": False, + "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" + } + if search_term is None: + return { + "success": False, + "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." + } + + if action in ["create", "modify"]: + if search_term is not None: + return { + "success": False, + "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." + } + + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "setActive": set_active, + "layer": layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized + } + params = {k: v for k, v in params.items() if v is not None} + + # --- Handle Prefab Path Logic --- + # Check if 'saveAsPrefab' is explicitly True in params + if action == "create" and params.get("saveAsPrefab"): + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefabFolder", None) + # -------------------------------- + + # Use centralized retry helper + response = send_command_with_retry("manage_gameobject", params) + + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/Server/tools/manage_prefabs.py b/Server/tools/manage_prefabs.py new file mode 100644 index 00000000..2540e9f2 --- /dev/null +++ b/Server/tools/manage_prefabs.py @@ -0,0 +1,58 @@ +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Bridge for prefab management commands (stage control and creation)." +) +def manage_prefabs( + ctx: Context, + action: Annotated[Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "create_from_gameobject", + ], "Manage prefabs (stage control and creation)."], + prefab_path: Annotated[str, + "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, + mode: Annotated[str, + "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, + save_before_close: Annotated[bool, + "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, + target: Annotated[str, + "Scene GameObject name required for create_from_gameobject"] | None = None, + allow_overwrite: Annotated[bool, + "Allow replacing an existing prefab at the same path"] | None = None, + search_inactive: Annotated[bool, + "Include inactive objects when resolving the target name"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_prefabs: {action}") + try: + params: dict[str, Any] = {"action": action} + + if prefab_path: + params["prefabPath"] = prefab_path + if mode: + params["mode"] = mode + if save_before_close is not None: + params["saveBeforeClose"] = bool(save_before_close) + if target: + params["target"] = target + if allow_overwrite is not None: + params["allowOverwrite"] = bool(allow_overwrite) + if search_inactive is not None: + params["searchInactive"] = bool(search_inactive) + response = send_command_with_retry("manage_prefabs", params) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Prefab operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + except Exception as exc: + return {"success": False, "message": f"Python error managing prefabs: {exc}"} diff --git a/Server/tools/manage_scene.py b/Server/tools/manage_scene.py new file mode 100644 index 00000000..50927ca9 --- /dev/null +++ b/Server/tools/manage_scene.py @@ -0,0 +1,56 @@ +from typing import Annotated, Literal, Any + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool(description="Manage Unity scenes. Tip: For broad client compatibility, pass build_index as a quoted string (e.g., '0').") +def manage_scene( + ctx: Context, + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], + name: Annotated[str, + "Scene name. Not required get_active/get_build_settings"] | None = None, + path: Annotated[str, + "Asset path for scene operations (default: 'Assets/')"] | None = None, + build_index: Annotated[int | str, + "Build index for load/build settings actions (accepts int or string, e.g., 0 or '0')"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_scene: {action}") + try: + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + coerced_build_index = _coerce_int(build_index, default=None) + + params = {"action": action} + if name: + params["name"] = name + if path: + params["path"] = path + if coerced_build_index is not None: + params["buildIndex"] = coerced_build_index + + # Use centralized retry helper + response = send_command_with_retry("manage_scene", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} diff --git a/Server/tools/manage_script.py b/Server/tools/manage_script.py new file mode 100644 index 00000000..6ed8cbca --- /dev/null +++ b/Server/tools/manage_script.py @@ -0,0 +1,552 @@ +import base64 +import os +from typing import Annotated, Any, Literal +from urllib.parse import urlparse, unquote + +from fastmcp import FastMCP, Context + +from registry import mcp_for_unity_tool +import unity_connection + + +def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str + if uri.startswith("unity://path/"): + raw_path = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) + else: + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) + parts = [p for p in norm.split("/") if p not in ("", ".")] + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None + + effective_path = assets_rel if assets_rel else norm + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) + return name, directory + + +@mcp_for_unity_tool(description=( + """Apply small text edits to a C# script identified by URI. + IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! + RECOMMENDED WORKFLOW: + 1. First call resources/read with start_line/line_count to verify exact content + 2. Count columns carefully (or use find_in_file to locate patterns) + 3. Apply your edit with precise coordinates + 4. Consider script_apply_edits with anchors for safer pattern-based replacements + Notes: + - For method/class operations, use script_apply_edits (safer, structured edits) + - For pattern-based replacements, consider anchor operations in script_apply_edits + - Lines, columns are 1-indexed + - Tabs count as 1 column""" +)) +def apply_text_edits( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], + precondition_sha256: Annotated[str, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, + strict: Annotated[bool, + "Optional strict flag, used to enforce strict mode"] | None = None, + options: Annotated[dict[str, Any], + "Optional options, used to pass additional options to the script editor"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing apply_text_edits: {uri}") + name, directory = _split_uri(uri) + + # Normalize common aliases/misuses for resilience: + # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} + # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} + # If normalization is required, read current contents to map indices -> 1-based line/col. + def _needs_normalization(arr: list[dict[str, Any]]) -> bool: + for e in arr or []: + if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): + return True + return False + + normalized_edits: list[dict[str, Any]] = [] + warnings: list[str] = [] + if _needs_normalization(edits): + # Read file to support index->line/col conversion when needed + read_resp = unity_connection.send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": directory, + }) + if not (isinstance(read_resp, dict) and read_resp.get("success")): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") + except Exception: + contents = contents or "" + + # Helper to map 0-based character index to 1-based line/col + def line_col_from_index(idx: int) -> tuple[int, int]: + if idx <= 0: + return 1, 1 + # Count lines up to idx and position within line + nl_count = contents.count("\n", 0, idx) + line = nl_count + 1 + last_nl = contents.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + for e in edits or []: + e2 = dict(e) + # Map text->newText if needed + if "newText" not in e2 and "text" in e2: + e2["newText"] = e2.pop("text") + + if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: + # Guard: explicit fields must be 1-based. + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} + # Normalize by clamping to 1 and warn + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + continue + + rng = e2.get("range") + if isinstance(rng, dict): + # LSP style: 0-based + s = rng.get("start", {}) + t = rng.get("end", {}) + e2["startLine"] = int(s.get("line", 0)) + 1 + e2["startCol"] = int(s.get("character", 0)) + 1 + e2["endLine"] = int(t.get("line", 0)) + 1 + e2["endCol"] = int(t.get("character", 0)) + 1 + e2.pop("range", None) + normalized_edits.append(e2) + continue + if isinstance(rng, (list, tuple)) and len(rng) == 2: + try: + a = int(rng[0]) + b = int(rng[1]) + if b < a: + a, b = b, a + sl, sc = line_col_from_index(a) + el, ec = line_col_from_index(b) + e2["startLine"] = sl + e2["startCol"] = sc + e2["endLine"] = el + e2["endCol"] = ec + e2.pop("range", None) + normalized_edits.append(e2) + continue + except Exception: + pass + # Could not normalize this edit + return { + "success": False, + "code": "missing_field", + "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", + "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} + } + else: + # Even when edits appear already in explicit form, validate 1-based coordinates. + normalized_edits = [] + for e in edits or []: + e2 = dict(e) + has_all = all(k in e2 for k in ( + "startLine", "startCol", "endLine", "endCol")) + if has_all: + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + if "zero_based_explicit_fields_normalized" not in warnings: + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + + # Preflight: detect overlapping ranges among normalized line/col spans + def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: + return ( + int(e.get("startLine", 1)) if key_start else int( + e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int( + e.get("endCol", 1)), + ) + + def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: + return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) + + # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. + spans = [] + for e in normalized_edits or []: + try: + s = _pos_tuple(e, True) + t = _pos_tuple(e, False) + if s != t: + spans.append((s, t)) + except Exception: + # If coordinates missing or invalid, let the server validate later + pass + + if spans: + spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) + for i in range(1, len(spans_sorted)): + prev_end = spans_sorted[i-1][1] + curr_start = spans_sorted[i][0] + # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start + if not _le(prev_end, curr_start): + conflicts = [{ + "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, + "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, + "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, + "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, + }] + return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} + + # Note: Do not auto-compute precondition if missing; callers should supply it + # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and + # preserves existing call-count expectations in clients/tests. + + # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance + opts: dict[str, Any] = dict(options or {}) + try: + if len(normalized_edits) > 1 and "applyMode" not in opts: + opts["applyMode"] = "atomic" + except Exception: + pass + # Support optional debug preview for span-by-span simulation without write + if opts.get("debug_preview"): + try: + import difflib + # Apply locally to preview final result + lines = [] + # Build an indexable original from a read if we normalized from read; otherwise skip + prev = "" + # We cannot guarantee file contents here without a read; return normalized spans only + return { + "success": True, + "message": "Preview only (no write)", + "data": { + "normalizedEdits": normalized_edits, + "preview": True + } + } + except Exception as e: + return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} + + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": normalized_edits, + "precondition_sha256": precondition_sha256, + "options": opts, + } + params = {k: v for k, v in params.items() if v is not None} + resp = unity_connection.send_command_with_retry("manage_script", params) + if isinstance(resp, dict): + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", normalized_edits) + if warnings: + data.setdefault("warnings", warnings) + if resp.get("success") and (options or {}).get("force_sentinel_reload"): + # Optional: flip sentinel via menu if explicitly requested + try: + import threading + import time + import json + import glob + import os + + def _latest_status() -> dict | None: + try: + files = sorted(glob.glob(os.path.expanduser( + "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if not files: + return None + with open(files[0], "r") as f: + return json.loads(f.read()) + except Exception: + return None + + def _flip_async(): + try: + time.sleep(0.1) + st = _latest_status() + if st and st.get("reloading"): + return + unity_connection.send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + threading.Thread(target=_flip_async, daemon=True).start() + except Exception: + pass + return resp + return resp + return {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) +def create_script( + ctx: Context, + path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing create_script: {path}") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + # Local validation to avoid round-trips on obviously bad input + norm_path = os.path.normpath( + (path or "").replace("\\", "/")).replace("\\", "/") + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} + if ".." in norm_path.split("/") or norm_path.startswith("/"): + return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} + if not name: + return {"success": False, "code": "bad_path", "message": "path must include a script file name."} + if not norm_path.lower().endswith(".cs"): + return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} + params: dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents: + params["encodedContents"] = base64.b64encode( + contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = unity_connection.send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) +def delete_script( + ctx: Context, + uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + """Delete a C# script by URI.""" + ctx.info(f"Processing delete_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + params = {"action": "delete", "name": name, "path": directory} + resp = unity_connection.send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) +def validate_script( + ctx: Context, + uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + level: Annotated[Literal['basic', 'standard'], + "Validation level"] = "basic", + include_diagnostics: Annotated[bool, + "Include full diagnostics and summary"] = False +) -> dict[str, Any]: + ctx.info(f"Processing validate_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + if level not in ("basic", "standard"): + return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = unity_connection.send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + diags = resp.get("data", {}).get("diagnostics", []) or [] + warnings = sum(1 for d in diags if str( + d.get("severity", "")).lower() == "warning") + errors = sum(1 for d in diags if str( + d.get("severity", "")).lower() in ("error", "fatal")) + if include_diagnostics: + return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} + return {"success": True, "data": {"warnings": warnings, "errors": errors}} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) +def manage_script( + ctx: Context, + action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], + name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], + path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create", + "C# code for 'create'/'update'"] | None = None, + script_type: Annotated[str, "Script type (e.g., 'C#')", + "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_script: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents: + if action == 'create': + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + params = {k: v for k, v in params.items() if v is not None} + + response = unity_connection.send_command_with_retry("manage_script", params) + + if isinstance(response, dict): + if response.get("success"): + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response + + return {"success": False, "message": str(response)} + + except Exception as e: + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } + + +@mcp_for_unity_tool(description=( + """Get manage_script capabilities (supported ops, limits, and guards). + Returns: + - ops: list of supported structured ops + - text_ops: list of supported text ops + - max_edit_payload_bytes: server edit payload cap + - guards: header/using guard enabled flag""" +)) +def manage_script_capabilities(ctx: Context) -> dict[str, Any]: + ctx.info("Processing manage_script_capabilities") + try: + # Keep in sync with server/Editor ManageScript implementation + ops = [ + "replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" + ] + text_ops = ["replace_range", "regex_replace", "prepend", "append"] + # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback + max_edit_payload_bytes = 256 * 1024 + guards = {"using_guard": True} + extras = {"get_sha": True} + return {"success": True, "data": { + "ops": ops, + "text_ops": text_ops, + "max_edit_payload_bytes": max_edit_payload_bytes, + "guards": guards, + "extras": extras, + }} + except Exception as e: + return {"success": False, "error": f"capabilities error: {e}"} + + +@mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") +def get_sha( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + ctx.info(f"Processing get_sha: {uri}") + try: + name, directory = _split_uri(uri) + params = {"action": "get_sha", "name": name, "path": directory} + resp = unity_connection.send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + data = resp.get("data", {}) + minimal = {"sha256": data.get( + "sha256"), "lengthBytes": data.get("lengthBytes")} + return {"success": True, "data": minimal} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + except Exception as e: + return {"success": False, "message": f"get_sha error: {e}"} diff --git a/Server/tools/manage_shader.py b/Server/tools/manage_shader.py new file mode 100644 index 00000000..19b94550 --- /dev/null +++ b/Server/tools/manage_shader.py @@ -0,0 +1,60 @@ +import base64 +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Manages shader scripts in Unity (create, read, update, delete)." +) +def manage_shader( + ctx: Context, + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], + name: Annotated[str, "Shader name (no .cs extension)"], + path: Annotated[str, "Asset path (default: \"Assets/\")"], + contents: Annotated[str, + "Shader code for 'create'/'update'"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_shader: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command via centralized retry helper + response = send_command_with_retry("manage_shader", params) + + # Process response from Unity + if isinstance(response, dict) and response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing shader: {str(e)}"} diff --git a/Server/tools/read_console.py b/Server/tools/read_console.py new file mode 100644 index 00000000..d922982c --- /dev/null +++ b/Server/tools/read_console.py @@ -0,0 +1,101 @@ +""" +Defines the read_console tool for accessing Unity Editor console messages. +""" +from typing import Annotated, Any, Literal + +from fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')." +) +def read_console( + ctx: Context, + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None, + types: Annotated[list[Literal['error', 'warning', + 'log', 'all']], "Message types to get"] | None = None, + count: Annotated[int | str, "Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None, + filter_text: Annotated[str, "Text filter for messages"] | None = None, + since_timestamp: Annotated[str, + "Get messages after this timestamp (ISO 8601)"] | None = None, + format: Annotated[Literal['plain', 'detailed', + 'json'], "Output format"] | None = None, + include_stacktrace: Annotated[bool | str, + "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing read_console: {action}") + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + # Coerce booleans defensively (strings like 'true'/'false') + def _coerce_bool(value, default=None): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + include_stacktrace = _coerce_bool(include_stacktrace, True) + + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() + + # Coerce count defensively (string/float -> int) + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + count = _coerce_int(count) + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types, + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if isinstance(format, str) else format, + "includeStacktrace": include_stacktrace + } + + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() + if v is not None or k == 'count'} + + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None + + # Use centralized retry helper + resp = send_command_with_retry("read_console", params_dict) + if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: + # Strip stacktrace fields from returned lines if present + try: + lines = resp.get("data", {}).get("lines", []) + for line in lines: + if isinstance(line, dict) and "stacktrace" in line: + line.pop("stacktrace", None) + except Exception: + pass + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/Server/tools/resource_tools.py b/Server/tools/resource_tools.py new file mode 100644 index 00000000..d84bf7be --- /dev/null +++ b/Server/tools/resource_tools.py @@ -0,0 +1,406 @@ +""" +Resource wrapper tools so clients that do not expose MCP resources primitives +can still list and read files via normal tools. These call into the same +safe path logic (re-implemented here to avoid importing server.py). +""" +import fnmatch +import hashlib +import os +from pathlib import Path +import re +from typing import Annotated, Any +from urllib.parse import urlparse, unquote + +from fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: + """Safely coerce various inputs (str/float/etc.) to an int. + Returns default on failure; clamps to minimum when provided. + """ + if value is None: + return default + try: + # Avoid treating booleans as ints implicitly + if isinstance(value, bool): + return default + if isinstance(value, int): + result = int(value) + else: + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + # Allow "10.0" or similar inputs + result = int(float(s)) + if minimum is not None and result < minimum: + return minimum + return result + except Exception: + return default + + +def _resolve_project_root(override: str | None) -> Path: + # 1) Explicit override + if override: + pr = Path(override).expanduser().resolve() + if (pr / "Assets").exists(): + return pr + # 2) Environment + env = os.environ.get("UNITY_PROJECT_ROOT") + if env: + env_path = Path(env).expanduser() + # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir + pr = (Path.cwd( + ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() + if (pr / "Assets").exists(): + return pr + # 3) Ask Unity via manage_editor.get_project_root + try: + resp = send_command_with_retry( + "manage_editor", {"action": "get_project_root"}) + if isinstance(resp, dict) and resp.get("success"): + pr = Path(resp.get("data", {}).get( + "projectRoot", "")).expanduser().resolve() + if pr and (pr / "Assets").exists(): + return pr + except Exception: + pass + + # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) + cur = Path.cwd().resolve() + for _ in range(6): + if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): + return cur + if cur.parent == cur: + break + cur = cur.parent + # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings + try: + import os as _os + root = Path.cwd().resolve() + max_depth = 3 + for dirpath, dirnames, _ in _os.walk(root): + rel = Path(dirpath).resolve() + try: + depth = len(rel.relative_to(root).parts) + except Exception: + # Unrelated mount/permission edge; skip deeper traversal + dirnames[:] = [] + continue + if depth > max_depth: + # Prune deeper traversal + dirnames[:] = [] + continue + if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): + return rel + except Exception: + pass + # 6) Fallback: CWD + return Path.cwd().resolve() + + +def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: + raw: str | None = None + if uri.startswith("unity://path/"): + raw = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + raw = unquote(parsed.path or "") + # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. + try: + import os as _os + if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): + raw = raw[1:] + # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share + if _os.name == "nt" and parsed.netloc: + raw = f"//{parsed.netloc}{raw}" + except Exception: + pass + elif uri.startswith("Assets/"): + raw = uri + if raw is None: + return None + # Normalize separators early + raw = raw.replace("\\", "/") + p = (project / raw).resolve() + try: + p.relative_to(project) + except ValueError: + return None + return p + + +@mcp_for_unity_tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) +async def list_resources( + ctx: Context, + pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", + under: Annotated[str, + "Folder under project root, default is Assets"] = "Assets", + limit: Annotated[int, "Page limit"] = 200, + project_root: Annotated[str, "Project path"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing list_resources: {pattern}") + try: + project = _resolve_project_root(project_root) + base = (project / under).resolve() + try: + base.relative_to(project) + except ValueError: + return {"success": False, "error": "Base path must be under project root"} + # Enforce listing only under Assets + try: + base.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Listing is restricted to Assets/"} + + matches: list[str] = [] + limit_int = _coerce_int(limit, default=200, minimum=1) + for p in base.rglob("*"): + if not p.is_file(): + continue + # Resolve symlinks and ensure the real path stays under project/Assets + try: + rp = p.resolve() + rp.relative_to(project / "Assets") + except Exception: + continue + # Enforce .cs extension regardless of provided pattern + if p.suffix.lower() != ".cs": + continue + if pattern and not fnmatch.fnmatch(p.name, pattern): + continue + rel = p.relative_to(project).as_posix() + matches.append(f"unity://path/{rel}") + if len(matches) >= max(1, limit_int): + break + + # Always include the canonical spec resource so NL clients can discover it + if "unity://spec/script-edits" not in matches: + matches.append("unity://spec/script-edits") + + return {"success": True, "data": {"uris": matches, "count": len(matches)}} + except Exception as e: + return {"success": False, "error": str(e)} + + +@mcp_for_unity_tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) +async def read_resource( + ctx: Context, + uri: Annotated[str, "The resource URI to read under Assets/"], + start_line: Annotated[int | float | str, + "The starting line number (0-based)"] | None = None, + line_count: Annotated[int | float | str, + "The number of lines to read"] | None = None, + head_bytes: Annotated[int | float | str, + "The number of bytes to read from the start of the file"] | None = None, + tail_lines: Annotated[int | float | str, + "The number of lines to read from the end of the file"] | None = None, + project_root: Annotated[str, + "The project root directory"] | None = None, + request: Annotated[str, "The request ID"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing read_resource: {uri}") + try: + # Serve the canonical spec directly when requested (allow bare or with scheme) + if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): + spec_json = ( + '{\n' + ' "name": "MCP for Unity - Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "\u2190 server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} + + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + try: + p.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Read restricted to Assets/"} + # Natural-language convenience: request like "last 120 lines", "first 200 lines", + # "show 40 lines around MethodName", etc. + if request: + req = request.strip().lower() + m = re.search(r"last\s+(\d+)\s+lines", req) + if m: + tail_lines = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s+lines", req) + if m: + start_line = 1 + line_count = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s*bytes", req) + if m: + head_bytes = int(m.group(1)) + m = re.search( + r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + if m: + window = int(m.group(1)) + method = m.group(2) + # naive search for method header to get a line number + text_all = p.read_text(encoding="utf-8") + lines_all = text_all.splitlines() + pat = re.compile( + rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + hit_line = None + for i, line in enumerate(lines_all, start=1): + if pat.search(line): + hit_line = i + break + if hit_line: + half = max(1, window // 2) + start_line = max(1, hit_line - half) + line_count = window + + # Coerce numeric inputs defensively (string/float -> int) + start_line = _coerce_int(start_line) + line_count = _coerce_int(line_count) + head_bytes = _coerce_int(head_bytes, minimum=1) + tail_lines = _coerce_int(tail_lines, minimum=1) + + # Compute SHA over full file contents (metadata-only default) + full_bytes = p.read_bytes() + full_sha = hashlib.sha256(full_bytes).hexdigest() + + # Selection only when explicitly requested via windowing args or request text hints + selection_requested = bool(head_bytes or tail_lines or ( + start_line is not None and line_count is not None) or request) + if selection_requested: + # Mutually exclusive windowing options precedence: + # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text + if head_bytes and head_bytes > 0: + raw = full_bytes[: head_bytes] + text = raw.decode("utf-8", errors="replace") + else: + text = full_bytes.decode("utf-8", errors="replace") + if tail_lines is not None and tail_lines > 0: + lines = text.splitlines() + n = max(0, tail_lines) + text = "\n".join(lines[-n:]) + elif start_line is not None and line_count is not None and line_count >= 0: + lines = text.splitlines() + s = max(0, start_line - 1) + e = min(len(lines), s + line_count) + text = "\n".join(lines[s:e]) + return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + else: + # Default: metadata only + return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + except Exception as e: + return {"success": False, "error": str(e)} + + +@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") +async def find_in_file( + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + ignore_case: Annotated[bool | str, "Case-insensitive search (accepts true/false or 'true'/'false')"] | None = True, + project_root: Annotated[str, + "The project root directory"] | None = None, + max_results: Annotated[int, + "Cap results to avoid huge payloads"] = 200, +) -> dict[str, Any]: + ctx.info(f"Processing find_in_file: {uri}") + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + text = p.read_text(encoding="utf-8") + # Tolerant boolean coercion for clients that stringify booleans + def _coerce_bool(val, default=None): + if val is None: + return default + if isinstance(val, bool): + return val + if isinstance(val, str): + v = val.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(val) + ignore_case = _coerce_bool(ignore_case, default=True) + flags = re.MULTILINE + if ignore_case: + flags |= re.IGNORECASE + rx = re.compile(pattern, flags) + + results = [] + max_results_int = _coerce_int(max_results, default=200, minimum=1) + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + m = rx.search(line) + if m: + start_col = m.start() + 1 # 1-based + end_col = m.end() + 1 # 1-based, end exclusive + results.append({ + "startLine": i, + "startCol": start_col, + "endLine": i, + "endCol": end_col, + }) + if max_results_int and len(results) >= max_results_int: + break + + return {"success": True, "data": {"matches": results, "count": len(results)}} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/Server/tools/run_tests.py b/Server/tools/run_tests.py new file mode 100644 index 00000000..e70fd00c --- /dev/null +++ b/Server/tools/run_tests.py @@ -0,0 +1,74 @@ +"""Tool for executing Unity Test Runner suites.""" +from typing import Annotated, Literal, Any + +from fastmcp import Context +from pydantic import BaseModel, Field + +from models import MCPResponse +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +class RunTestsSummary(BaseModel): + total: int + passed: int + failed: int + skipped: int + durationSeconds: float + resultState: str + + +class RunTestsTestResult(BaseModel): + name: str + fullName: str + state: str + durationSeconds: float + message: str | None = None + stackTrace: str | None = None + output: str | None = None + + +class RunTestsResult(BaseModel): + mode: str + summary: RunTestsSummary + results: list[RunTestsTestResult] + + +class RunTestsResponse(MCPResponse): + data: RunTestsResult | None = None + + +@mcp_for_unity_tool(description="Runs Unity tests for the specified mode") +async def run_tests( + ctx: Context, + mode: Annotated[Literal["edit", "play"], Field( + description="Unity test mode to run")] = "edit", + timeout_seconds: Annotated[str, Field( + description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, +) -> RunTestsResponse: + await ctx.info(f"Processing run_tests: mode={mode}") + + # Coerce timeout defensively (string/float -> int) + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + params: dict[str, Any] = {"mode": mode} + ts = _coerce_int(timeout_seconds) + if ts is not None: + params["timeoutSeconds"] = ts + + response = await async_send_command_with_retry("run_tests", params) + await ctx.info(f'Response {response}') + return RunTestsResponse(**response) if isinstance(response, dict) else response diff --git a/Server/tools/script_apply_edits.py b/Server/tools/script_apply_edits.py new file mode 100644 index 00000000..e339a754 --- /dev/null +++ b/Server/tools/script_apply_edits.py @@ -0,0 +1,966 @@ +import base64 +import hashlib +import re +from typing import Annotated, Any + +from fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith( + "\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE | ( + re.IGNORECASE if edit.get("ignore_case") else 0) + + # Find the best match using improved heuristics + match = _find_best_anchor_match( + anchor, text, flags, bool(edit.get("prefer_last", True))) + if not match: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = match.start() if position == "before" else match.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + max_line = len(lines) + 1 # 1-based, exclusive end + if (start_line < 1 or end_line < start_line or end_line > max_line + or start_col < 1 or end_col < 1): + raise RuntimeError("replace_range out of bounds") + + def index_of(line: int, col: int) -> int: + if line <= len(lines): + return sum(len(l) for l in lines[: line - 1]) + (col - 1) + return sum(len(l) for l in lines) + a = index_of(start_line, start_col) + b = index_of(end_line, end_col) + text = text[:a] + replacement + text[b:] + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + # Translate $n backrefs (our input) to Python \g + repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl_py, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): + """ + Find the best anchor match using improved heuristics. + + For patterns like \\s*}\\s*$ that are meant to find class-ending braces, + this function uses heuristics to choose the most semantically appropriate match: + + 1. If prefer_last=True, prefer the last match (common for class-end insertions) + 2. Use indentation levels to distinguish class vs method braces + 3. Consider context to avoid matches inside strings/comments + + Args: + pattern: Regex pattern to search for + text: Text to search in + flags: Regex flags + prefer_last: If True, prefer the last match over the first + + Returns: + Match object of the best match, or None if no match found + """ + + # Find all matches + matches = list(re.finditer(pattern, text, flags)) + if not matches: + return None + + # If only one match, return it + if len(matches) == 1: + return matches[0] + + # For patterns that look like they're trying to match closing braces at end of lines + is_closing_brace_pattern = '}' in pattern and ( + '$' in pattern or pattern.endswith(r'\s*')) + + if is_closing_brace_pattern and prefer_last: + # Use heuristics to find the best closing brace match + return _find_best_closing_brace_match(matches, text) + + # Default behavior: use last match if prefer_last, otherwise first match + return matches[-1] if prefer_last else matches[0] + + +def _find_best_closing_brace_match(matches, text: str): + """ + Find the best closing brace match using C# structure heuristics. + + Enhanced heuristics for scope-aware matching: + 1. Prefer matches with lower indentation (likely class-level) + 2. Prefer matches closer to end of file + 3. Avoid matches that seem to be inside method bodies + 4. For #endregion patterns, ensure class-level context + 5. Validate insertion point is at appropriate scope + + Args: + matches: List of regex match objects + text: The full text being searched + + Returns: + The best match object + """ + if not matches: + return None + + scored_matches = [] + lines = text.splitlines() + + for match in matches: + score = 0 + start_pos = match.start() + + # Find which line this match is on + lines_before = text[:start_pos].count('\n') + line_num = lines_before + + if line_num < len(lines): + line_content = lines[line_num] + + # Calculate indentation level (lower is better for class braces) + indentation = len(line_content) - len(line_content.lstrip()) + + # Prefer lower indentation (class braces are typically less indented than method braces) + # Max 20 points for indentation=0 + score += max(0, 20 - indentation) + + # Prefer matches closer to end of file (class closing braces are typically at the end) + distance_from_end = len(lines) - line_num + # More points for being closer to end + score += max(0, 10 - distance_from_end) + + # Look at surrounding context to avoid method braces + context_start = max(0, line_num - 3) + context_end = min(len(lines), line_num + 2) + context_lines = lines[context_start:context_end] + + # Penalize if this looks like it's inside a method (has method-like patterns above) + for context_line in context_lines: + if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): + score -= 5 # Penalty for being near method signatures + + # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) + if indentation <= 4 and distance_from_end <= 3: + score += 15 # Bonus for likely class-ending brace + + scored_matches.append((score, match)) + + # Return the match with the highest score + scored_matches.sort(key=lambda x: x[0], reverse=True) + best_match = scored_matches[0][1] + + return best_match + + +def _infer_class_name(script_name: str) -> str: + # Default to script name as class name (common Unity pattern) + return (script_name or "").strip() + + +def _extract_code_after(keyword: str, request: str) -> str: + # Deprecated with NL removal; retained as no-op for compatibility + idx = request.lower().find(keyword) + if idx >= 0: + return request[idx + len(keyword):].strip() + return "" +# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services + + +def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: + """Best-effort normalization of script "name" and "path". + + Accepts any of: + - name = "SmartReach", path = "Assets/Scripts/Interaction" + - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" + - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" + - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) + - name or path using uri prefixes: unity://path/..., file://... + - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" + + Returns (name_without_extension, directory_path_under_Assets). + """ + n = (name or "").strip() + p = (path or "").strip() + + def strip_prefix(s: str) -> str: + if s.startswith("unity://path/"): + return s[len("unity://path/"):] + if s.startswith("file://"): + return s[len("file://"):] + return s + + def collapse_duplicate_tail(s: str) -> str: + # Collapse trailing "/X.cs/X.cs" to "/X.cs" + parts = s.split("/") + if len(parts) >= 2 and parts[-1] == parts[-2]: + parts = parts[:-1] + return "/".join(parts) + + # Prefer a full path if provided in either field + candidate = "" + for v in (n, p): + v2 = strip_prefix(v) + if v2.endswith(".cs") or v2.startswith("Assets/"): + candidate = v2 + break + + if candidate: + candidate = collapse_duplicate_tail(candidate) + # If a directory was passed in path and file in name, join them + if not candidate.endswith(".cs") and n.endswith(".cs"): + v2 = strip_prefix(n) + candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) + if candidate.endswith(".cs"): + parts = candidate.split("/") + file_name = parts[-1] + dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" + base = file_name[:- + 3] if file_name.lower().endswith(".cs") else file_name + return base, dir_path + + # Fall back: remove extension from name if present and return given path + base_name = n[:-3] if n.lower().endswith(".cs") else n + return base_name, (p or "Assets") + + +def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: + if not isinstance(resp, dict): + return resp + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", edits) + if routing: + data["routing"] = routing + return resp + + +def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, + normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": False, + "code": code, "message": message} + data: dict[str, Any] = {} + if expected: + data["expected"] = expected + if rewrite: + data["rewrite_suggestion"] = rewrite + if normalized is not None: + data["normalizedEdits"] = normalized + if routing: + data["routing"] = routing + if extra: + data.update(extra) + if data: + payload["data"] = data + return payload + +# Natural-language parsing removed; clients should send structured edits. + + +@mcp_for_unity_tool(name="script_apply_edits", description=( + """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. + Best practices: + - Prefer anchor_* ops for pattern-based insert/replace near stable markers + - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) + - Avoid whole-file regex deletes; validators will guard unbalanced braces + - For tail insertions, prefer anchor/regex_replace on final brace (class closing) + - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits + Canonical fields (use these exact keys): + - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace + - className: string (defaults to 'name' if omitted on method/class ops) + - methodName: string (required for replace_method, delete_method) + - replacement: string (required for replace_method, insert_method) + - position: start | end | after | before (insert_method only) + - afterMethodName / beforeMethodName: string (required when position='after'/'before') + - anchor: regex string (for anchor_* ops) + - text: string (for anchor_insert/anchor_replace) + Examples: + 1) Replace a method: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "replace_method", + "className": "SmartReach", + "methodName": "HasTarget", + "replacement": "public bool HasTarget(){ return currentTarget!=null; }" + } + ], + "options": {"validate": "standard", "refresh": "immediate"} + } + "2) Insert a method after another: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "insert_method", + "className": "SmartReach", + "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", + "position": "after", + "afterMethodName": "GetCurrentTarget" + } + ], + } + ]""" +)) +def script_apply_edits( + ctx: Context, + name: Annotated[str, "Name of the script to edit"], + path: Annotated[str, "Path to the script to edit under Assets/ directory"], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + options: Annotated[dict[str, Any], + "Options for the script edit"] | None = None, + script_type: Annotated[str, + "Type of the script to edit"] = "MonoBehaviour", + namespace: Annotated[str, + "Namespace of the script to edit"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing script_apply_edits: {name}") + # Normalize locator first so downstream calls target the correct script file. + name, path = _normalize_script_locator(name, path) + # Normalize unsupported or aliased ops to known structured/text paths + + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: + # Unwrap single-key wrappers like {"replace_method": {...}} + for wrapper_key in ( + "replace_method", "insert_method", "delete_method", + "replace_class", "delete_class", + "anchor_insert", "anchor_replace", "anchor_delete", + ): + if wrapper_key in edit and isinstance(edit[wrapper_key], dict): + inner = dict(edit[wrapper_key]) + inner["op"] = wrapper_key + edit = inner + break + + e = dict(edit) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + if op: + e["op"] = op + + # Common field aliases + if "class_name" in e and "className" not in e: + e["className"] = e.pop("class_name") + if "class" in e and "className" not in e: + e["className"] = e.pop("class") + if "method_name" in e and "methodName" not in e: + e["methodName"] = e.pop("method_name") + # Some clients use a generic 'target' for method name + if "target" in e and "methodName" not in e: + e["methodName"] = e.pop("target") + if "method" in e and "methodName" not in e: + e["methodName"] = e.pop("method") + if "new_content" in e and "replacement" not in e: + e["replacement"] = e.pop("new_content") + if "newMethod" in e and "replacement" not in e: + e["replacement"] = e.pop("newMethod") + if "new_method" in e and "replacement" not in e: + e["replacement"] = e.pop("new_method") + if "content" in e and "replacement" not in e: + e["replacement"] = e.pop("content") + if "after" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after") + if "after_method" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after_method") + if "before" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before") + if "before_method" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before_method") + # anchor_method → before/after based on position (default after) + if "anchor_method" in e: + anchor = e.pop("anchor_method") + pos = (e.get("position") or "after").strip().lower() + if pos == "before" and "beforeMethodName" not in e: + e["beforeMethodName"] = anchor + elif "afterMethodName" not in e: + e["afterMethodName"] = anchor + if "anchorText" in e and "anchor" not in e: + e["anchor"] = e.pop("anchorText") + if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): + e["anchor"] = e.pop("pattern") + if "newText" in e and "text" not in e: + e["text"] = e.pop("newText") + + # CI compatibility (T‑A/T‑E): + # Accept method-anchored anchor_insert and upgrade to insert_method + # Example incoming shape: + # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} + if ( + e.get("op") == "anchor_insert" + and not e.get("anchor") + and (e.get("afterMethodName") or e.get("beforeMethodName")) + ): + e["op"] = "insert_method" + if "replacement" not in e: + e["replacement"] = e.get("text", "") + + # LSP-like range edit -> replace_range + if "range" in e and isinstance(e["range"], dict): + rng = e.pop("range") + start = rng.get("start", {}) + end = rng.get("end", {}) + # Convert 0-based to 1-based line/col + e["op"] = "replace_range" + e["startLine"] = int(start.get("line", 0)) + 1 + e["startCol"] = int(start.get("character", 0)) + 1 + e["endLine"] = int(end.get("line", 0)) + 1 + e["endCol"] = int(end.get("character", 0)) + 1 + if "newText" in edit and "text" not in e: + e["text"] = edit.get("newText", "") + return e + + normalized_edits: list[dict[str, Any]] = [] + for raw in edits or []: + e = _unwrap_and_alias(raw) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + + # Default className to script name if missing on structured method/class ops + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): + e["className"] = name + + # Map common aliases for text ops + if op in ("text_replace",): + e["op"] = "replace_range" + normalized_edits.append(e) + continue + if op in ("regex_delete",): + e["op"] = "regex_replace" + e.setdefault("text", "") + normalized_edits.append(e) + continue + if op == "regex_replace" and ("replacement" not in e): + if "text" in e: + e["replacement"] = e.get("text", "") + elif "insert" in e or "content" in e: + e["replacement"] = e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): + e["op"] = "anchor_delete" + normalized_edits.append(e) + continue + normalized_edits.append(e) + + edits = normalized_edits + normalized_for_echo = edits + + # Validate required fields and produce machine-parsable hints + def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: + return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) + + for e in edits or []: + op = e.get("op", "") + if op == "replace_method": + if not e.get("methodName"): + return error_with_hint( + "replace_method requires 'methodName'.", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].methodName": "HasTarget"} + ) + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "replace_method requires 'replacement' (inline or base64).", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].replacement": "public bool X(){ return true; }"} + ) + elif op == "insert_method": + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "insert_method requires a non-empty 'replacement'.", + {"op": "insert_method", "required": ["className", "replacement"], "position": { + "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} + ) + pos = (e.get("position") or "").lower() + if pos == "after" and not e.get("afterMethodName"): + return error_with_hint( + "insert_method with position='after' requires 'afterMethodName'.", + {"op": "insert_method", "position": { + "after_requires": "afterMethodName"}}, + {"edits[0].afterMethodName": "GetCurrentTarget"} + ) + if pos == "before" and not e.get("beforeMethodName"): + return error_with_hint( + "insert_method with position='before' requires 'beforeMethodName'.", + {"op": "insert_method", "position": { + "before_requires": "beforeMethodName"}}, + {"edits[0].beforeMethodName": "GetCurrentTarget"} + ) + elif op == "delete_method": + if not e.get("methodName"): + return error_with_hint( + "delete_method requires 'methodName'.", + {"op": "delete_method", "required": [ + "className", "methodName"]}, + {"edits[0].methodName": "PrintSeries"} + ) + elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): + if not e.get("anchor"): + return error_with_hint( + f"{op} requires 'anchor' (regex).", + {"op": op, "required": ["anchor"]}, + {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} + ) + if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): + return error_with_hint( + f"{op} requires 'text'.", + {"op": op, "required": ["anchor", "text"]}, + {"edits[0].text": "/* comment */\n"} + ) + + # Decide routing: structured vs text vs mixed + STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} + TEXT = {"prepend", "append", "replace_range", "regex_replace"} + ops_set = {(e.get("op") or "").lower() for e in edits or []} + all_struct = ops_set.issubset(STRUCT) + all_text = ops_set.issubset(TEXT) + mixed = not (all_struct or all_text) + + # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. + if all_struct: + opts2 = dict(options or {}) + # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused + opts2.setdefault("refresh", "immediate") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": opts2, + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") + + # 1) read from Unity + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data") or read_resp.get( + "result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # Optional preview/dry-run: apply locally and return diff without writing + preview = bool((options or {}).get("preview")) + + # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured + if mixed: + text_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in STRUCT] + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + for e in text_edits: + opx = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get( + "content") or e.get("replacement") or "" + if opx == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + try: + # Use improved anchor matching logic + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") + idx = m.start() if position == "before" else m.end() + # Normalize insertion to avoid jammed methods + text_field_norm = text_field + if not text_field_norm.startswith("\n"): + text_field_norm = "\n" + text_field_norm + if not text_field_norm.endswith("\n"): + text_field_norm = text_field_norm + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + # do not mutate base_text when building atomic spans + elif opx == "replace_range": + if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + elif opx == "regex_replace": + pattern = e.get("pattern") or "" + try: + regex_obj = re.compile(pattern, re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0)) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") + m = regex_obj.search(base_text) + if not m: + continue + # Expand $1, $2... in replacement using this match + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl = _expand_dollars(text_field) + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + # do not mutate base_text when building atomic spans + elif opx in ("prepend", "append"): + if opx == "prepend": + sl, sc = 1, 1 + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + # prepend can be applied atomically without local mutation + else: + # Insert at true EOF position (handles both \n and \r\n correctly) + eof_idx = len(base_text) + sl, sc = line_col_from_index(eof_idx) + new_text = ("\n" if not base_text.endswith( + "\n") else "") + text_field + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + # do not mutate base_text when building atomic spans + else: + return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + if at_edits: + params_text: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} + } + resp_text = send_command_with_retry( + "manage_script", params_text) + if not (isinstance(resp_text, dict) and resp_text.get("success")): + return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + # Optional sentinel reload removed (deprecated) + except Exception as e: + return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") + + if struct_edits: + opts2 = dict(options or {}) + # Prefer debounced background refresh unless explicitly overridden + opts2.setdefault("refresh", "debounced") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": struct_edits, + "options": opts2 + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") + + return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") + + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition + # so header guards and validation run on the C# side. + # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). + text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( + "mode") or "").strip().lower() for e in (edits or [])} + structured_kinds = {"replace_class", "delete_class", + "replace_method", "delete_method", "insert_method", "anchor_insert"} + if not text_ops.issubset(structured_kinds): + # Convert to apply_text_edits payload + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + # 1-based line/col against base buffer + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + import re as _re + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + # aliasing for text field + text_field = e.get("text") or e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + # Use improved anchor matching logic with helpful errors, honoring ignore_case + try: + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") + idx = m.start() if position == "before" else m.end() + # Normalize insertion newlines + if text_field and not text_field.startswith("\n"): + text_field = "\n" + text_field + if text_field and not text_field.endswith("\n"): + text_field = text_field + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": sl, + "endCol": sc, + "newText": text_field or "" + }) + # Do not mutate base buffer when building an atomic batch + elif op == "replace_range": + # Directly forward if already in line/col form + if "startLine" in e: + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + # If only indices provided, skip (we don't support index-based here) + return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") + elif op == "regex_replace": + pattern = e.get("pattern") or "" + repl = text_field + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + # Early compile for clearer error messages + try: + regex_obj = re.compile(pattern, flags) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") + # Use smart anchor matching for consistent behavior with anchor_insert + m = _find_best_anchor_match( + pattern, base_text, flags, prefer_last=True) + if not m: + continue + # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl_expanded = _expand_dollars(repl) + # Let C# side handle validation using Unity's built-in compiler services + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": el, + "endCol": ec, + "newText": repl_expanded + }) + # Do not mutate base buffer when building an atomic batch + else: + return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") + + if not at_edits: + return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + params: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": { + "refresh": (options or {}).get("refresh", "debounced"), + "validate": (options or {}).get("validate", "standard"), + "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) + } + } + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + resp if isinstance(resp, dict) else { + "success": False, "message": str(resp)}, + normalized_for_echo, + routing="text" + ) + except Exception as e: + return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") + + # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. + # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. + if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): + try: + preview_text = _apply_edits_locally(contents, edits) + import difflib + diff = list(difflib.unified_diff(contents.splitlines( + ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + if len(diff) > 800: + diff = diff[:800] + ["... (diff truncated) ..."] + if preview: + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") + except Exception as e: + return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") + # 2) apply edits locally (only if not text-ops) + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + # Short-circuit no-op edits to avoid false "applied" reports downstream + if new_contents == contents: + return _with_norm({ + "success": True, + "message": "No-op: contents unchanged", + "data": {"no_op": True, "evidence": {"reason": "identical_content"}} + }, normalized_for_echo, routing="text") + + if preview: + # Produce a compact unified diff limited to small context + import difflib + a = contents.splitlines() + b = new_contents.splitlines() + diff = list(difflib.unified_diff( + a, b, fromfile="before", tofile="after", n=3)) + # Limit diff size to keep responses small + if len(diff) > 2000: + diff = diff[:2000] + ["... (diff truncated) ..."] + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + + # 3) update to Unity + # Default refresh/validate for natural usage on text path as well + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "debounced") + + # Compute the SHA of the current file contents for the precondition + old_lines = contents.splitlines(keepends=True) + end_line = len(old_lines) + 1 # 1-based exclusive end + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + + # Apply a whole-file text edit rather than the deprecated 'update' action + params = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": [ + { + "startLine": 1, + "startCol": 1, + "endLine": end_line, + "endCol": 1, + "newText": new_contents, + } + ], + "precondition_sha256": sha, + "options": options or {"validate": "standard", "refresh": "debounced"}, + } + + write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict) and write_resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, + normalized_for_echo, + routing="text", + ) diff --git a/Server/unity_connection.py b/Server/unity_connection.py new file mode 100644 index 00000000..f0e06b76 --- /dev/null +++ b/Server/unity_connection.py @@ -0,0 +1,451 @@ +from config import config +import contextlib +from dataclasses import dataclass +import errno +import json +import logging +from pathlib import Path +from port_discovery import PortDiscovery +import random +import socket +import struct +import threading +import time +from typing import Any, Dict + +from models import MCPResponse + + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("mcp-for-unity-server") + +# Module-level lock to guard global connection initialization +_connection_lock = threading.Lock() + +# Maximum allowed framed payload size (64 MiB) +FRAMED_MAX = 64 * 1024 * 1024 + + +@dataclass +class UnityConnection: + """Manages the socket connection to the Unity Editor.""" + host: str = config.unity_host + port: int = None # Will be set dynamically + sock: socket.socket = None # Socket for Unity communication + use_framing: bool = False # Negotiated per-connection + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() + self._io_lock = threading.Lock() + self._conn_lock = threading.Lock() + + def connect(self) -> bool: + """Establish a connection to the Unity Editor.""" + if self.sock: + return True + with self._conn_lock: + if self.sock: + return True + try: + # Bounded connect to avoid indefinite blocking + connect_timeout = float( + getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) + self.sock = socket.create_connection( + (self.host, self.port), connect_timeout) + # Disable Nagle's algorithm to reduce small RPC latency + with contextlib.suppress(Exception): + self.sock.setsockopt( + socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + logger.debug(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + require_framing = getattr(config, "require_framing", True) + timeout = float(getattr(config, "handshake_timeout", 1.0)) + self.sock.settimeout(timeout) + buf = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and len(buf) < 512: + try: + chunk = self.sock.recv(256) + if not chunk: + break + buf.extend(chunk) + if b"\n" in buf: + break + except socket.timeout: + break + text = bytes(buf).decode('ascii', errors='ignore').strip() + + if 'FRAMING=1' in text: + self.use_framing = True + logger.debug( + 'MCP for Unity handshake received: FRAMING=1 (strict)') + else: + if require_framing: + # Best-effort plain-text advisory for legacy peers + with contextlib.suppress(Exception): + self.sock.sendall( + b'MCP for Unity requires FRAMING=1\n') + raise ConnectionError( + f'MCP for Unity requires FRAMING=1, got: {text!r}') + else: + self.use_framing = False + logger.warning( + 'MCP for Unity handshake missing FRAMING=1; proceeding in legacy mode by configuration') + finally: + self.sock.settimeout(config.connection_timeout) + return True + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + try: + if self.sock: + self.sock.close() + except Exception: + pass + self.sock = None + return False + + def disconnect(self): + """Close the connection to the Unity Editor.""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error disconnecting from Unity: {str(e)}") + finally: + self.sock = None + + def _read_exact(self, sock: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise ConnectionError( + "Connection closed before reading expected bytes") + data.extend(chunk) + return bytes(data) + + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + if self.use_framing: + try: + # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive + heartbeat_count = 0 + deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) + while True: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0: + # Heartbeat/no-op frame: consume and continue waiting for a data frame + logger.debug("Received heartbeat frame (length=0)") + heartbeat_count += 1 + if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: + # Treat as empty successful response to match C# server behavior + logger.debug( + "Heartbeat threshold reached; returning empty response") + return b"" + continue + if payload_len > FRAMED_MAX: + raise ValueError( + f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.debug( + f"Received framed response ({len(payload)} bytes)") + return payload + except socket.timeout as e: + logger.warning("Socket timeout during framed receive") + raise TimeoutError("Timeout receiving Unity response") from e + except Exception as e: + logger.error(f"Error during framed receive: {str(e)}") + raise + + chunks = [] + # Respect the socket's currently configured timeout + try: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception( + "Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far + data = b''.join(chunks) + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + \ + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info( + f"Received complete response ({len(data)} bytes)") + return data + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning( + f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise + + def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + # Defensive guard: catch empty/placeholder invocations early + if not command_type: + raise ValueError("MCP call missing command_type") + if params is None: + return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)") + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath( + '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely + try: + status = read_status_file() + if status and (status.get('reloading') or status.get('reason') == 'reloading'): + return MCPResponse( + success=False, + error="Unity domain reload in progress, please try again shortly", + data={"state": "reloading", "retry_after_ms": int( + config.reload_retry_ms)} + ) + except Exception: + pass + + for attempt in range(attempts + 1): + try: + # Ensure connected (handshake occurs within connect()) + if not self.sock and not self.connect(): + raise Exception("Could not connect to Unity") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps( + command, ensure_ascii=False).encode('utf-8') + + # Send/receive are serialized to protect the shared socket + with self._io_lock: + mode = 'framed' if self.use_framing else 'legacy' + with contextlib.suppress(Exception): + logger.debug( + "send %d bytes; mode=%s; head=%s", + len(payload), + mode, + (payload[:32]).decode('utf-8', 'ignore'), + ) + if self.use_framing: + header = struct.pack('>Q', len(payload)) + self.sock.sendall(header) + self.sock.sendall(payload) + else: + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout and ensure restoration + restore_timeout = None + if attempt > 0 and last_short_timeout is None: + restore_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + try: + response_data = self.receive_full_response(self.sock) + with contextlib.suppress(Exception): + logger.debug("recv %d bytes; mode=%s", + len(response_data), mode) + finally: + if restore_timeout is not None: + self.sock.settimeout(restore_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get( + 'message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) + except Exception as e: + logger.warning( + f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info( + f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # Heartbeat-aware, jittered backoff + status = read_status_file() + # Base exponential backoff + backoff = base_backoff * (2 ** attempt) + # Decorrelated jitter multiplier + jitter = random.uniform(0.1, 0.3) + + # Fast‑retry for transient socket failures + fast_error = isinstance( + e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + if not fast_error: + try: + err_no = getattr(e, 'errno', None) + fast_error = err_no in ( + errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + except Exception: + pass + + # Cap backoff depending on state + if status and status.get('reloading'): + cap = 0.8 + elif fast_error: + cap = 0.25 + else: + cap = 3.0 + + sleep_s = min(cap, jitter * (2 ** attempt)) + time.sleep(sleep_s) + continue + raise + + +# Global Unity connection +_unity_connection = None + + +def get_unity_connection() -> UnityConnection: + """Retrieve or establish a persistent Unity connection. + + Note: Do NOT ping on every retrieval to avoid connection storms. Rely on + send_command() exceptions to detect broken sockets and reconnect there. + """ + global _unity_connection + if _unity_connection is not None: + return _unity_connection + + # Double-checked locking to avoid concurrent socket creation + with _connection_lock: + if _unity_connection is not None: + return _unity_connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError( + "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + logger.info("Connected to Unity on startup") + return _unity_connection + + +# ----------------------------- +# Centralized retry helpers +# ----------------------------- + +def _is_reloading_response(resp: dict) -> bool: + """Return True if the Unity response indicates the editor is reloading.""" + if not isinstance(resp, dict): + return False + if resp.get("state") == "reloading": + return True + message_text = (resp.get("message") or resp.get("error") or "").lower() + return "reload" in message_text + + +def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Send a command via the shared connection, waiting politely through Unity reloads. + + Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the + structured failure if retries are exhausted. + """ + conn = get_unity_connection() + if max_retries is None: + max_retries = getattr(config, "reload_max_retries", 40) + if retry_ms is None: + retry_ms = getattr(config, "reload_retry_ms", 250) + + response = conn.send_command(command_type, params) + retries = 0 + while _is_reloading_response(response) and retries < max_retries: + delay_ms = int(response.get("retry_after_ms", retry_ms) + ) if isinstance(response, dict) else retry_ms + time.sleep(max(0.0, delay_ms / 1000.0)) + retries += 1 + response = conn.send_command(command_type, params) + return response + + +async def async_send_command_with_retry(command_type: str, params: dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> dict[str, Any] | MCPResponse: + """Async wrapper that runs the blocking retry helper in a thread pool.""" + try: + import asyncio # local import to avoid mandatory asyncio dependency for sync callers + if loop is None: + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + lambda: send_command_with_retry( + command_type, params, max_retries=max_retries, retry_ms=retry_ms), + ) + except Exception as e: + return MCPResponse(success=False, error=str(e)) diff --git a/Server/uv.lock b/Server/uv.lock new file mode 100644 index 00000000..901a0026 --- /dev/null +++ b/Server/uv.lock @@ -0,0 +1,1370 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "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 = "authlib" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, +] + +[[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/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 = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d1/2f2b99ec5ea54ac18baadfc4a011e2a1743c1eaae1e39838ca520dcf4811/cyclopts-4.0.0.tar.gz", hash = "sha256:0dae712085e91d32cc099ea3d78f305b0100a3998b1dec693be9feb0b1be101f", size = 143546, upload-time = "2025-10-20T18:33:01.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-core" }, + { name = "openapi-pydantic" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/a6/e3b46cd3e228635e0064c2648788b6f66a53bf0d0ddbf5fb44cca951f908/fastmcp-2.12.5.tar.gz", hash = "sha256:2dfd02e255705a4afe43d26caddbc864563036e233dbc6870f389ee523b39a6a", size = 7190263, upload-time = "2025-10-17T13:24:58.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/c1/9fb98c9649e15ea8cc691b4b09558b61dafb3dc0345f7322f8c4a8991ade/fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133", size = 329099, upload-time = "2025-10-17T13:24:57.518Z" }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[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 = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918, upload-time = "2025-10-02T16:58:20.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266, upload-time = "2025-10-02T16:58:19.039Z" }, +] + +[[package]] +name = "mcpforunityserver" +version = "6.2.5" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "tomli" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.12.5" }, + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", specifier = ">=1.16.0" }, + { name = "pydantic", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "tomli", specifier = ">=2.3.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "openapi-core" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "more-itertools" }, + { name = "openapi-schema-validator" }, + { name = "openapi-spec-validator" }, + { name = "parse" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/35/1acaa5f2fcc6e54eded34a2ec74b479439c4e469fc4e8d0e803fda0234db/openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3", size = 103264, upload-time = "2025-03-20T20:17:28.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6f/83ead0e2e30a90445ee4fc0135f43741aebc30cca5b43f20968b603e30b6/openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f", size = 106595, upload-time = "2025-03-20T20:17:26.77Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.0" +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/c3/da/b8a7ee04378a53f6fefefc0c5e05570a3ebfdfa0523a878bcd3b475683ee/pydantic-2.12.0.tar.gz", hash = "sha256:c1a077e6270dbfb37bfd8b498b3981e2bb18f68103720e51fa6c306a5a9af563", size = 814760, upload-time = "2025-10-07T15:58:03.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/9d/d5c855424e2e5b6b626fbc6ec514d8e655a600377ce283008b115abb7445/pydantic-2.12.0-py3-none-any.whl", hash = "sha256:f6a1da352d42790537e95e83a8bdfb91c7efbae63ffd0b86fa823899e807116f", size = 459730, upload-time = "2025-10-07T15:58:01.576Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/14/12b4a0d2b0b10d8e1d9a24ad94e7bbb43335eaf29c0c4e57860e8a30734a/pydantic_core-2.41.1.tar.gz", hash = "sha256:1ad375859a6d8c356b7704ec0f547a58e82ee80bb41baa811ad710e124bc8f2f", size = 454870, upload-time = "2025-10-07T10:50:45.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/a9/ec440f02e57beabdfd804725ef1e38ac1ba00c49854d298447562e119513/pydantic_core-2.41.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4f276a6134fe1fc1daa692642a3eaa2b7b858599c49a7610816388f5e37566a1", size = 2111456, upload-time = "2025-10-06T21:10:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f9/6bc15bacfd8dcfc073a1820a564516d9c12a435a9a332d4cbbfd48828ddd/pydantic_core-2.41.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07588570a805296ece009c59d9a679dc08fab72fb337365afb4f3a14cfbfc176", size = 1915012, upload-time = "2025-10-06T21:10:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/38/8a/d9edcdcdfe80bade17bed424284427c08bea892aaec11438fa52eaeaf79c/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28527e4b53400cd60ffbd9812ccb2b5135d042129716d71afd7e45bf42b855c0", size = 1973762, upload-time = "2025-10-06T21:10:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b3/ff225c6d49fba4279de04677c1c876fc3dc6562fd0c53e9bfd66f58c51a8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a1c935c9228bad738c8a41de06478770927baedf581d172494ab36a6b96575", size = 2065386, upload-time = "2025-10-06T21:10:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/183e8c0be4321314af3fd1ae6bfc7eafdd7a49bdea5da81c56044a207316/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:447ddf56e2b7d28d200d3e9eafa936fe40485744b5a824b67039937580b3cb20", size = 2252317, upload-time = "2025-10-06T21:10:15.719Z" }, + { url = "https://files.pythonhosted.org/packages/57/c5/aab61e94fd02f45c65f1f8c9ec38bb3b33fbf001a1837c74870e97462572/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63892ead40c1160ac860b5debcc95c95c5a0035e543a8b5a4eac70dd22e995f4", size = 2373405, upload-time = "2025-10-06T21:10:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/3aaa3bd1ea420a15acc42d7d3ccb3b0bbc5444ae2f9dbc1959f8173e16b8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a9543ca355e6df8fbe9c83e9faab707701e9103ae857ecb40f1c0cf8b0e94d", size = 2073794, upload-time = "2025-10-06T21:10:18.383Z" }, + { url = "https://files.pythonhosted.org/packages/58/bd/e3975cdebe03ec080ef881648de316c73f2a6be95c14fc4efb2f7bdd0d41/pydantic_core-2.41.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2611bdb694116c31e551ed82e20e39a90bea9b7ad9e54aaf2d045ad621aa7a1", size = 2194430, upload-time = "2025-10-06T21:10:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/6b7e7217f147d3b3105b57fb1caec3c4f667581affdfaab6d1d277e1f749/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fecc130893a9b5f7bfe230be1bb8c61fe66a19db8ab704f808cb25a82aad0bc9", size = 2154611, upload-time = "2025-10-06T21:10:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/239c2fe76bd8b7eef9ae2140d737368a3c6fea4fd27f8f6b4cde6baa3ce9/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:1e2df5f8344c99b6ea5219f00fdc8950b8e6f2c422fbc1cc122ec8641fac85a1", size = 2329809, upload-time = "2025-10-06T21:10:22.678Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/77a821a67ff0786f2f14856d6bd1348992f695ee90136a145d7a445c1ff6/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:35291331e9d8ed94c257bab6be1cb3a380b5eee570a2784bffc055e18040a2ea", size = 2327907, upload-time = "2025-10-06T21:10:24.447Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9a/b54512bb9df7f64c586b369328c30481229b70ca6a5fcbb90b715e15facf/pydantic_core-2.41.1-cp311-cp311-win32.whl", hash = "sha256:2876a095292668d753f1a868c4a57c4ac9f6acbd8edda8debe4218d5848cf42f", size = 1989964, upload-time = "2025-10-06T21:10:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/9d/72/63c9a4f1a5c950e65dd522d7dd67f167681f9d4f6ece3b80085a0329f08f/pydantic_core-2.41.1-cp311-cp311-win_amd64.whl", hash = "sha256:b92d6c628e9a338846a28dfe3fcdc1a3279388624597898b105e078cdfc59298", size = 2025158, upload-time = "2025-10-06T21:10:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/4e2706184209f61b50c231529257c12eb6bd9eb36e99ea1272e4815d2200/pydantic_core-2.41.1-cp311-cp311-win_arm64.whl", hash = "sha256:7d82ae99409eb69d507a89835488fb657faa03ff9968a9379567b0d2e2e56bc5", size = 1972297, upload-time = "2025-10-06T21:10:28.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bc/5f520319ee1c9e25010412fac4154a72e0a40d0a19eb00281b1f200c0947/pydantic_core-2.41.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:db2f82c0ccbce8f021ad304ce35cbe02aa2f95f215cac388eed542b03b4d5eb4", size = 2099300, upload-time = "2025-10-06T21:10:30.463Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/010cd64c5c3814fb6064786837ec12604be0dd46df3327cf8474e38abbbd/pydantic_core-2.41.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47694a31c710ced9205d5f1e7e8af3ca57cbb8a503d98cb9e33e27c97a501601", size = 1910179, upload-time = "2025-10-06T21:10:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2e/23fc2a8a93efad52df302fdade0a60f471ecc0c7aac889801ac24b4c07d6/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e9decce94daf47baf9e9d392f5f2557e783085f7c5e522011545d9d6858e00", size = 1957225, upload-time = "2025-10-06T21:10:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/6db08b2725b2432b9390844852e11d320281e5cea8a859c52c68001975fa/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab0adafdf2b89c8b84f847780a119437a0931eca469f7b44d356f2b426dd9741", size = 2053315, upload-time = "2025-10-06T21:10:34.87Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/4de44600f2d4514b44f3f3aeeda2e14931214b6b5bf52479339e801ce748/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5da98cc81873f39fd56882e1569c4677940fbc12bce6213fad1ead784192d7c8", size = 2224298, upload-time = "2025-10-06T21:10:36.233Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ae/dbe51187a7f35fc21b283c5250571a94e36373eb557c1cba9f29a9806dcf/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:209910e88afb01fd0fd403947b809ba8dba0e08a095e1f703294fda0a8fdca51", size = 2351797, upload-time = "2025-10-06T21:10:37.601Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a7/975585147457c2e9fb951c7c8dab56deeb6aa313f3aa72c2fc0df3f74a49/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365109d1165d78d98e33c5bfd815a9b5d7d070f578caefaabcc5771825b4ecb5", size = 2074921, upload-time = "2025-10-06T21:10:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/ea94d1d0c01dec1b7d236c7cec9103baab0021f42500975de3d42522104b/pydantic_core-2.41.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:706abf21e60a2857acdb09502bc853ee5bce732955e7b723b10311114f033115", size = 2187767, upload-time = "2025-10-06T21:10:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fe/694cf9fdd3a777a618c3afd210dba7b414cb8a72b1bd29b199c2e5765fee/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bf0bd5417acf7f6a7ec3b53f2109f587be176cb35f9cf016da87e6017437a72d", size = 2136062, upload-time = "2025-10-06T21:10:42.09Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/174aeabd89916fbd2988cc37b81a59e1186e952afd2a7ed92018c22f31ca/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2e71b1c6ceb9c78424ae9f63a07292fb769fb890a4e7efca5554c47f33a60ea5", size = 2317819, upload-time = "2025-10-06T21:10:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/e9aecafaebf53fc456314f72886068725d6fba66f11b013532dc21259343/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80745b9770b4a38c25015b517451c817799bfb9d6499b0d13d8227ec941cb513", size = 2312267, upload-time = "2025-10-06T21:10:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/35/2f/1c2e71d2a052f9bb2f2df5a6a05464a0eb800f9e8d9dd800202fe31219e1/pydantic_core-2.41.1-cp312-cp312-win32.whl", hash = "sha256:83b64d70520e7890453f1aa21d66fda44e7b35f1cfea95adf7b4289a51e2b479", size = 1990927, upload-time = "2025-10-06T21:10:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/b1/78/562998301ff2588b9c6dcc5cb21f52fa919d6e1decc75a35055feb973594/pydantic_core-2.41.1-cp312-cp312-win_amd64.whl", hash = "sha256:377defd66ee2003748ee93c52bcef2d14fde48fe28a0b156f88c3dbf9bc49a50", size = 2034703, upload-time = "2025-10-06T21:10:48.524Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/d95699ce5a5cdb44bb470bd818b848b9beadf51459fd4ea06667e8ede862/pydantic_core-2.41.1-cp312-cp312-win_arm64.whl", hash = "sha256:c95caff279d49c1d6cdfe2996e6c2ad712571d3b9caaa209a404426c326c4bde", size = 1972719, upload-time = "2025-10-06T21:10:50.256Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/6d54198536a90a37807d31a156642aae7a8e1263ed9fe6fc6245defe9332/pydantic_core-2.41.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70e790fce5f05204ef4403159857bfcd587779da78627b0babb3654f75361ebf", size = 2105825, upload-time = "2025-10-06T21:10:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/4784fd7b22ac9c8439db25bf98ffed6853d01e7e560a346e8af821776ccc/pydantic_core-2.41.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cebf1ca35f10930612d60bd0f78adfacee824c30a880e3534ba02c207cceceb", size = 1910126, upload-time = "2025-10-06T21:10:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/31eb0748059ba5bd0aa708fb4bab9fcb211461ddcf9e90702a6542f22d0d/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170406a37a5bc82c22c3274616bf6f17cc7df9c4a0a0a50449e559cb755db669", size = 1961472, upload-time = "2025-10-06T21:10:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/ab/91/946527792275b5c4c7dde4cfa3e81241bf6900e9fee74fb1ba43e0c0f1ab/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12d4257fc9187a0ccd41b8b327d6a4e57281ab75e11dda66a9148ef2e1fb712f", size = 2063230, upload-time = "2025-10-06T21:10:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/a35c5d7b414e5c0749f1d9f0d159ee2ef4bab313f499692896b918014ee3/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a75a33b4db105dd1c8d57839e17ee12db8d5ad18209e792fa325dbb4baeb00f4", size = 2229469, upload-time = "2025-10-06T21:10:59.409Z" }, + { url = "https://files.pythonhosted.org/packages/21/4d/8713737c689afa57ecfefe38db78259d4484c97aa494979e6a9d19662584/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a589f850803a74e0fcb16a72081cafb0d72a3cdda500106942b07e76b7bf62", size = 2347986, upload-time = "2025-10-06T21:11:00.847Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/929f9a3a5ed5cda767081494bacd32f783e707a690ce6eeb5e0730ec4986/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97939d6ea44763c456bd8a617ceada2c9b96bb5b8ab3dfa0d0827df7619014", size = 2072216, upload-time = "2025-10-06T21:11:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/26/55/a33f459d4f9cc8786d9db42795dbecc84fa724b290d7d71ddc3d7155d46a/pydantic_core-2.41.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae423c65c556f09569524b80ffd11babff61f33055ef9773d7c9fabc11ed8d", size = 2193047, upload-time = "2025-10-06T21:11:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/77/af/d5c6959f8b089f2185760a2779079e3c2c411bfc70ea6111f58367851629/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4dc703015fbf8764d6a8001c327a87f1823b7328d40b47ce6000c65918ad2b4f", size = 2140613, upload-time = "2025-10-06T21:11:05.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/e5/2c19bd2a14bffe7fabcf00efbfbd3ac430aaec5271b504a938ff019ac7be/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:968e4ffdfd35698a5fe659e5e44c508b53664870a8e61c8f9d24d3d145d30257", size = 2327641, upload-time = "2025-10-06T21:11:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/93/ef/e0870ccda798c54e6b100aff3c4d49df5458fd64217e860cb9c3b0a403f4/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fff2b76c8e172d34771cd4d4f0ade08072385310f214f823b5a6ad4006890d32", size = 2318229, upload-time = "2025-10-06T21:11:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4b/c3b991d95f5deb24d0bd52e47bcf716098fa1afe0ce2d4bd3125b38566ba/pydantic_core-2.41.1-cp313-cp313-win32.whl", hash = "sha256:a38a5263185407ceb599f2f035faf4589d57e73c7146d64f10577f6449e8171d", size = 1997911, upload-time = "2025-10-06T21:11:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/5c316fd62e01f8d6be1b7ee6b54273214e871772997dc2c95e204997a055/pydantic_core-2.41.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42ae7fd6760782c975897e1fdc810f483b021b32245b0105d40f6e7a3803e4b", size = 2034301, upload-time = "2025-10-06T21:11:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/29/41/902640cfd6a6523194123e2c3373c60f19006447f2fb06f76de4e8466c5b/pydantic_core-2.41.1-cp313-cp313-win_arm64.whl", hash = "sha256:ad4111acc63b7384e205c27a2f15e23ac0ee21a9d77ad6f2e9cb516ec90965fb", size = 1977238, upload-time = "2025-10-06T21:11:14.1Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/28b040e88c1b89d851278478842f0bdf39c7a05da9e850333c6c8cbe7dfa/pydantic_core-2.41.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:440d0df7415b50084a4ba9d870480c16c5f67c0d1d4d5119e3f70925533a0edc", size = 1875626, upload-time = "2025-10-06T21:11:15.69Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/b41dd3087505220bb58bc81be8c3e8cbc037f5710cd3c838f44f90bdd704/pydantic_core-2.41.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71eaa38d342099405dae6484216dcf1e8e4b0bebd9b44a4e08c9b43db6a2ab67", size = 2045708, upload-time = "2025-10-06T21:11:17.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b8/760f23754e40bf6c65b94a69b22c394c24058a0ef7e2aa471d2e39219c1a/pydantic_core-2.41.1-cp313-cp313t-win_amd64.whl", hash = "sha256:555ecf7e50f1161d3f693bc49f23c82cf6cdeafc71fa37a06120772a09a38795", size = 1997171, upload-time = "2025-10-06T21:11:18.822Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/cec246429ddfa2778d2d6301eca5362194dc8749ecb19e621f2f65b5090f/pydantic_core-2.41.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:05226894a26f6f27e1deb735d7308f74ef5fa3a6de3e0135bb66cdcaee88f64b", size = 2107836, upload-time = "2025-10-06T21:11:20.432Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/baba47f8d8b87081302498e610aefc37142ce6a1cc98b2ab6b931a162562/pydantic_core-2.41.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85ff7911c6c3e2fd8d3779c50925f6406d770ea58ea6dde9c230d35b52b16b4a", size = 1904449, upload-time = "2025-10-06T21:11:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/9a3d87cae2c75a5178334b10358d631bd094b916a00a5993382222dbfd92/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f1f642a205687d59b52dc1a9a607f45e588f5a2e9eeae05edd80c7a8c47674", size = 1961750, upload-time = "2025-10-06T21:11:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/27/42/a96c9d793a04cf2a9773bff98003bb154087b94f5530a2ce6063ecfec583/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df11c24e138876ace5ec6043e5cae925e34cf38af1a1b3d63589e8f7b5f5cdc4", size = 2063305, upload-time = "2025-10-06T21:11:26.556Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/028c4b7d157a005b1f52c086e2d4b0067886b213c86220c1153398dbdf8f/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f0bf7f5c8f7bf345c527e8a0d72d6b26eda99c1227b0c34e7e59e181260de31", size = 2228959, upload-time = "2025-10-06T21:11:28.426Z" }, + { url = "https://files.pythonhosted.org/packages/08/f7/ee64cda8fcc9ca3f4716e6357144f9ee71166775df582a1b6b738bf6da57/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82b887a711d341c2c47352375d73b029418f55b20bd7815446d175a70effa706", size = 2345421, upload-time = "2025-10-06T21:11:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/e8ec05f0f5ee7a3656973ad9cd3bc73204af99f6512c1a4562f6fb4b3f7d/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f1d5d6bbba484bdf220c72d8ecd0be460f4bd4c5e534a541bb2cd57589fb8b", size = 2065288, upload-time = "2025-10-06T21:11:32.019Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/d77a73ff24e2e4fcea64472f5e39b0402d836da9b08b5361a734d0153023/pydantic_core-2.41.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bf1917385ebe0f968dc5c6ab1375886d56992b93ddfe6bf52bff575d03662be", size = 2189759, upload-time = "2025-10-06T21:11:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/45/4a4ebaaae12a740552278d06fe71418c0f2869537a369a89c0e6723b341d/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f94f3ab188f44b9a73f7295663f3ecb8f2e2dd03a69c8f2ead50d37785ecb04", size = 2140747, upload-time = "2025-10-06T21:11:35.781Z" }, + { url = "https://files.pythonhosted.org/packages/da/6d/b727ce1022f143194a36593243ff244ed5a1eb3c9122296bf7e716aa37ba/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3925446673641d37c30bd84a9d597e49f72eacee8b43322c8999fa17d5ae5bc4", size = 2327416, upload-time = "2025-10-06T21:11:37.75Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/02df9d8506c427787059f87c6c7253435c6895e12472a652d9616ee0fc95/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:49bd51cc27adb980c7b97357ae036ce9b3c4d0bb406e84fbe16fb2d368b602a8", size = 2318138, upload-time = "2025-10-06T21:11:39.463Z" }, + { url = "https://files.pythonhosted.org/packages/98/67/0cf429a7d6802536941f430e6e3243f6d4b68f41eeea4b242372f1901794/pydantic_core-2.41.1-cp314-cp314-win32.whl", hash = "sha256:a31ca0cd0e4d12ea0df0077df2d487fc3eb9d7f96bbb13c3c5b88dcc21d05159", size = 1998429, upload-time = "2025-10-06T21:11:41.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/742fef93de5d085022d2302a6317a2b34dbfe15258e9396a535c8a100ae7/pydantic_core-2.41.1-cp314-cp314-win_amd64.whl", hash = "sha256:1b5c4374a152e10a22175d7790e644fbd8ff58418890e07e2073ff9d4414efae", size = 2028870, upload-time = "2025-10-06T21:11:43.66Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/cdd8ccb8555ef7720bd7715899bd6cfbe3c29198332710e1b61b8f5dd8b8/pydantic_core-2.41.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fee76d757639b493eb600fba668f1e17475af34c17dd61db7a47e824d464ca9", size = 1974275, upload-time = "2025-10-06T21:11:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, + { url = "https://files.pythonhosted.org/packages/16/89/d0afad37ba25f5801735af1472e650b86baad9fe807a42076508e4824a2a/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:68f2251559b8efa99041bb63571ec7cdd2d715ba74cc82b3bc9eff824ebc8bf0", size = 2124001, upload-time = "2025-10-07T10:49:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c4/08609134b34520568ddebb084d9ed0a2a3f5f52b45739e6e22cb3a7112eb/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:c7bc140c596097cb53b30546ca257dbe3f19282283190b1b5142928e5d5d3a20", size = 1941841, upload-time = "2025-10-07T10:49:56.248Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/94a4877094e5fe19a3f37e7e817772263e2c573c94f1e3fa2b1eee56ef3b/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2896510fce8f4725ec518f8b9d7f015a00db249d2fd40788f442af303480063d", size = 1961129, upload-time = "2025-10-07T10:49:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/a2/30/23a224d7e25260eb5f69783a63667453037e07eb91ff0e62dabaadd47128/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ced20e62cfa0f496ba68fa5d6c7ee71114ea67e2a5da3114d6450d7f4683572a", size = 2148770, upload-time = "2025-10-07T10:49:59.959Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3e/a51c5f5d37b9288ba30683d6e96f10fa8f1defad1623ff09f1020973b577/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b04fa9ed049461a7398138c604b00550bc89e3e1151d84b81ad6dc93e39c4c06", size = 2115344, upload-time = "2025-10-07T10:50:02.466Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/389504c9e0600ef4502cd5238396b527afe6ef8981a6a15cd1814fc7b434/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b3b7d9cfbfdc43c80a16638c6dc2768e3956e73031fca64e8e1a3ae744d1faeb", size = 1927994, upload-time = "2025-10-07T10:50:04.379Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9c/5111c6b128861cb792a4c082677e90dac4f2e090bb2e2fe06aa5b2d39027/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec83fc6abef04c7f9bec616e2d76ee9a6a4ae2a359b10c21d0f680e24a247ca", size = 1959394, upload-time = "2025-10-07T10:50:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/14/3f/cfec8b9a0c48ce5d64409ec5e1903cb0b7363da38f14b41de2fcb3712700/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6771a2d9f83c4038dfad5970a3eef215940682b2175e32bcc817bdc639019b28", size = 2147365, upload-time = "2025-10-07T10:50:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6c/fa3e45c2b054a1e627a89a364917f12cbe3abc3e91b9004edaae16e7b3c5/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:af2385d3f98243fb733862f806c5bb9122e5fba05b373e3af40e3c82d711cef1", size = 2112094, upload-time = "2025-10-07T10:50:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/7eebc38b4658cc8e6902d0befc26388e4c2a5f2e179c561eeb43e1922c7b/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6550617a0c2115be56f90c31a5370261d8ce9dbf051c3ed53b51172dd34da696", size = 1935300, upload-time = "2025-10-07T10:50:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/9fe640194a1717a464ab861d43595c268830f98cb1e2705aa134b3544b70/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc17b6ecf4983d298686014c92ebc955a9f9baf9f57dad4065e7906e7bee6222", size = 1970417, upload-time = "2025-10-07T10:50:29.573Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ad/f4cdfaf483b78ee65362363e73b6b40c48e067078d7b146e8816d5945ad6/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42ae9352cf211f08b04ea110563d6b1e415878eea5b4c70f6bdb17dca3b932d2", size = 2190745, upload-time = "2025-10-07T10:50:31.48Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/18f416d40a10f44e9387497ba449f40fdb1478c61ba05c4b6bdb82300362/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e82947de92068b0a21681a13dd2102387197092fbe7defcfb8453e0913866506", size = 2150888, upload-time = "2025-10-07T10:50:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/42/30/134c8a921630d8a88d6f905a562495a6421e959a23c19b0f49b660801d67/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e244c37d5471c9acdcd282890c6c4c83747b77238bfa19429b8473586c907656", size = 2324489, upload-time = "2025-10-07T10:50:36.48Z" }, + { url = "https://files.pythonhosted.org/packages/9c/48/a9263aeaebdec81e941198525b43edb3b44f27cfa4cb8005b8d3eb8dec72/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1e798b4b304a995110d41ec93653e57975620ccb2842ba9420037985e7d7284e", size = 2322763, upload-time = "2025-10-07T10:50:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/755d2bd2593f701c5839fc084e9c2c5e2418f460383ad04e3b5d0befc3ca/pydantic_core-2.41.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f1fc716c0eb1663c59699b024428ad5ec2bcc6b928527b8fe28de6cb89f47efb", size = 2144046, upload-time = "2025-10-07T10:50:40.686Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +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/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +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 = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, + { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, + { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376, upload-time = "2024-12-25T09:09:30.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120, upload-time = "2024-12-25T09:09:26.761Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102, upload-time = "2025-03-08T10:55:34.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995, upload-time = "2025-03-08T10:55:32.662Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7175d764718d5183caf8d0867a4f0190d5d4a45cea49/werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4", size = 806453, upload-time = "2024-11-01T16:40:45.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, +]