From c6f6fe7429707e67409f2fba0446014d79513726 Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 14:19:50 -0800 Subject: [PATCH 01/23] Add mcp server --- mcp-server/README.md | 103 ++++ mcp-server/polaris_mcp/__init__.py | 24 + mcp-server/polaris_mcp/authorization.py | 136 ++++++ mcp-server/polaris_mcp/base.py | 55 +++ mcp-server/polaris_mcp/rest.py | 303 ++++++++++++ mcp-server/polaris_mcp/server.py | 438 ++++++++++++++++++ mcp-server/polaris_mcp/tools/__init__.py | 38 ++ mcp-server/polaris_mcp/tools/catalog.py | 204 ++++++++ mcp-server/polaris_mcp/tools/catalog_role.py | 245 ++++++++++ mcp-server/polaris_mcp/tools/namespace.py | 303 ++++++++++++ mcp-server/polaris_mcp/tools/policy.py | 377 +++++++++++++++ mcp-server/polaris_mcp/tools/principal.py | 295 ++++++++++++ .../polaris_mcp/tools/principal_role.py | 255 ++++++++++ mcp-server/polaris_mcp/tools/table.py | 258 +++++++++++ mcp-server/pyproject.toml | 48 ++ 15 files changed, 3082 insertions(+) create mode 100644 mcp-server/README.md create mode 100644 mcp-server/polaris_mcp/__init__.py create mode 100644 mcp-server/polaris_mcp/authorization.py create mode 100644 mcp-server/polaris_mcp/base.py create mode 100644 mcp-server/polaris_mcp/rest.py create mode 100644 mcp-server/polaris_mcp/server.py create mode 100644 mcp-server/polaris_mcp/tools/__init__.py create mode 100644 mcp-server/polaris_mcp/tools/catalog.py create mode 100644 mcp-server/polaris_mcp/tools/catalog_role.py create mode 100644 mcp-server/polaris_mcp/tools/namespace.py create mode 100644 mcp-server/polaris_mcp/tools/policy.py create mode 100644 mcp-server/polaris_mcp/tools/principal.py create mode 100644 mcp-server/polaris_mcp/tools/principal_role.py create mode 100644 mcp-server/polaris_mcp/tools/table.py create mode 100644 mcp-server/pyproject.toml diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000..11cb7dc5 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,103 @@ + + +# Apache Polaris MCP Server (Python) + +This package provides a Python implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for Apache Polaris. It wraps the Polaris REST APIs so MCP-compatible clients (IDEs, agents, chat applications) can issue structured requests via JSON-RPC on stdin/stdout. + +The implementation is built on top of [FastMCP](https://gofastmcp.com) for streamlined server registration and transport handling. + +## Installation + +From the repository root: + +```bash +cd client/python-mcp +uv sync +``` + +## Running + +Launch the MCP server (which reads from stdin and writes to stdout): + +```bash +uv run polaris-mcp +``` + +Example interaction: + +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"manual","version":"0"}}} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +``` + +For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. + +### Claude Desktop configuration + +```json +{ + "mcpServers": { + "polaris": { + "command": "uv", + "args": [ + "--directory", + "/path/to/polaris/client/python-mcp", + "run", + "polaris-mcp" + ], + "env": { + "POLARIS_BASE_URL": "http://localhost:8181/", + "POLARIS_CLIENT_ID": "root", + "POLARIS_CLIENT_SECRET": "s3cr3t", + "POLARIS_TOKEN_SCOPE": "PRINCIPAL_ROLE:ALL" + } + } + } +} +``` + +Please note: `--directory` specifies a local directory. It is not needed when we pull `polaris-mcp` from PyPI package. + +## Configuration + +| Variable | Description | Default | +|----------------------------------------------------------------|----------------------------------------------------------|--------------------------------------------------| +| `POLARIS_BASE_URL` | Base URL for all Polaris REST calls. | `http://localhost:8181/` | +| `POLARIS_API_TOKEN` / `POLARIS_BEARER_TOKEN` / `POLARIS_TOKEN` | Static bearer token (if supplied, overrides other auth). | _unset_ | +| `POLARIS_CLIENT_ID` | OAuth client id for client-credential flow. | _unset_ | +| `POLARIS_CLIENT_SECRET` | OAuth client secret. | _unset_ | +| `POLARIS_TOKEN_SCOPE` | OAuth scope string. | _unset_ | +| `POLARIS_TOKEN_URL` | Optional override for the token endpoint URL. | `${POLARIS_BASE_URL}api/catalog/v1/oauth/tokens` | + +When OAuth variables are supplied, the server automatically acquires and refreshes tokens using the client credentials flow; otherwise a static bearer token is used if provided. + +## Tools + +The server exposes the following MCP tools: + +* `polaris-iceberg-table` — Table operations (`list`, `get`, `create`, `update`, `delete`). +* `polaris-namespace-request` — Namespace lifecycle management. +* `polaris-policy` — Policy lifecycle management and mappings. +* `polaris-catalog-request` — Catalog lifecycle management. +* `polaris-principal-request` — Principal lifecycle helpers. +* `polaris-principal-role-request` — Principal role lifecycle and catalog-role assignments. +* `polaris-catalog-role-request` — Catalog role and grant management. + +Each tool returns both a human-readable transcript of the HTTP exchange and structured metadata under `result.meta`. diff --git a/mcp-server/polaris_mcp/__init__.py b/mcp-server/polaris_mcp/__init__.py new file mode 100644 index 00000000..db666ab2 --- /dev/null +++ b/mcp-server/polaris_mcp/__init__.py @@ -0,0 +1,24 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Polaris Model Context Protocol server implementation.""" + +from .server import create_server, main + +__all__ = ["create_server", "main"] diff --git a/mcp-server/polaris_mcp/authorization.py b/mcp-server/polaris_mcp/authorization.py new file mode 100644 index 00000000..2955d332 --- /dev/null +++ b/mcp-server/polaris_mcp/authorization.py @@ -0,0 +1,136 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Authorization helpers for the Polaris MCP server.""" + +from __future__ import annotations + +import json +import threading +import time +from abc import ABC, abstractmethod +from typing import Optional +from urllib.parse import urlencode + +import urllib3 + + +class AuthorizationProvider(ABC): + """Return Authorization header values for outgoing requests.""" + + @abstractmethod + def authorization_header(self) -> Optional[str]: + ... + + +class StaticAuthorizationProvider(AuthorizationProvider): + """Wrap a static bearer token.""" + + def __init__(self, token: Optional[str]) -> None: + value = (token or "").strip() + self._header = f"Bearer {value}" if value else None + + def authorization_header(self) -> Optional[str]: + return self._header + + +class ClientCredentialsAuthorizationProvider(AuthorizationProvider): + """Implements the OAuth client-credentials flow with caching.""" + + def __init__( + self, + token_endpoint: str, + client_id: str, + client_secret: str, + scope: Optional[str], + http: urllib3.PoolManager, + ) -> None: + self._token_endpoint = token_endpoint + self._client_id = client_id + self._client_secret = client_secret + self._scope = scope + self._http = http + self._lock = threading.Lock() + self._cached: Optional[tuple[str, float]] = None # (token, expires_at_epoch) + + def authorization_header(self) -> Optional[str]: + token = self._current_token() + return f"Bearer {token}" if token else None + + def _current_token(self) -> Optional[str]: + now = time.time() + cached = self._cached + if not cached or cached[1] - 60 <= now: + with self._lock: + cached = self._cached + if not cached or cached[1] - 60 <= time.time(): + self._cached = cached = self._fetch_token() + return cached[0] if cached else None + + def _fetch_token(self) -> tuple[str, float]: + payload = { + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + } + if self._scope: + payload["scope"] = self._scope + + encoded = urlencode(payload) + response = self._http.request( + "POST", + self._token_endpoint, + body=encoded, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=urllib3.Timeout(connect=20.0, read=20.0), + ) + + if response.status != 200: + raise RuntimeError( + f"OAuth token endpoint returned {response.status}: {response.data.decode('utf-8', errors='ignore')}" + ) + + try: + document = json.loads(response.data.decode("utf-8")) + except json.JSONDecodeError as error: + raise RuntimeError("OAuth token endpoint returned invalid JSON") from error + + token = document.get("access_token") + if not isinstance(token, str) or not token: + raise RuntimeError("OAuth token response missing access_token") + + expires_in = document.get("expires_in", 3600) + try: + ttl = float(expires_in) + except (TypeError, ValueError): + ttl = 3600.0 + ttl = max(ttl, 60.0) + expires_at = time.time() + ttl + return token, expires_at + + +class _NoneAuthorizationProvider(AuthorizationProvider): + def authorization_header(self) -> Optional[str]: + return None + + +def none() -> AuthorizationProvider: + """Return an AuthorizationProvider that never supplies a header.""" + + return _NoneAuthorizationProvider() diff --git a/mcp-server/polaris_mcp/base.py b/mcp-server/polaris_mcp/base.py new file mode 100644 index 00000000..4072e155 --- /dev/null +++ b/mcp-server/polaris_mcp/base.py @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Shared protocol definitions for the Polaris MCP server.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Protocol + + +JSONDict = Dict[str, Any] + + +@dataclass(frozen=True) +class ToolExecutionResult: + """Structured result returned from executing an MCP tool.""" + + text: str + is_error: bool + metadata: Optional[JSONDict] = None + + +class McpTool(Protocol): + """Protocol describing the minimal surface for MCP tools.""" + + @property + def name(self) -> str: # pragma: no cover - simple accessor + ... + + @property + def description(self) -> str: # pragma: no cover - simple accessor + ... + + def input_schema(self) -> JSONDict: + """Return a JSON schema describing the tool parameters.""" + + def call(self, arguments: Any) -> ToolExecutionResult: + """Execute the tool with the provided JSON arguments.""" diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py new file mode 100644 index 00000000..6db6ee2f --- /dev/null +++ b/mcp-server/polaris_mcp/rest.py @@ -0,0 +1,303 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""HTTP helper used by the Polaris MCP tools.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit + +import urllib3 + +from .authorization import AuthorizationProvider, none +from .base import JSONDict, ToolExecutionResult + + +DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0) + + +def _ensure_trailing_slash(url: str) -> str: + return url if url.endswith("/") else f"{url}/" + + +def _normalize_prefix(prefix: Optional[str]) -> str: + if not prefix: + return "" + trimmed = prefix.strip() + if trimmed.startswith("/"): + trimmed = trimmed[1:] + if trimmed and not trimmed.endswith("/"): + trimmed = f"{trimmed}/" + return trimmed + + +def _merge_headers(values: Optional[Dict[str, Any]]) -> Dict[str, str]: + headers: Dict[str, str] = {"Accept": "application/json"} + if not values: + return headers + + for name, raw_value in values.items(): + if not name or raw_value is None: + continue + if isinstance(raw_value, list): + flattened = [str(item) for item in raw_value if item is not None] + if not flattened: + continue + headers[name] = ", ".join(flattened) + else: + headers[name] = str(raw_value) + return headers + + +def _serialize_body(node: Any) -> Optional[str]: + if node is None: + return None + if isinstance(node, (str, bytes)): + return node.decode("utf-8") if isinstance(node, bytes) else node + return json.dumps(node) + + +def _pretty_body(raw: str) -> str: + if not raw.strip(): + return "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return raw + return json.dumps(parsed, indent=2) + + +def _headers_to_dict(headers: urllib3.response.HTTPHeaderDict) -> Dict[str, str]: + flattened: Dict[str, str] = {} + for key in headers: + values = headers.getlist(key) + flattened[key] = ", ".join(values) + return flattened + + +def _append_query(url: str, params: List[Tuple[str, str]]) -> str: + if not params: + return url + parsed = urlsplit(url) + extra_parts = [urlencode({k: v}) for k, v in params if v is not None] + existing = parsed.query + if existing: + query = "&".join([existing] + extra_parts) if extra_parts else existing + else: + query = "&".join(extra_parts) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + + +def _build_query(parameters: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]: + if not parameters: + return [] + entries: List[Tuple[str, str]] = [] + for key, value in parameters.items(): + if not key or value is None: + continue + if isinstance(value, list): + for item in value: + if item is not None: + entries.append((key, str(item))) + else: + entries.append((key, str(value))) + return entries + + +def _maybe_parse_json(text: Optional[str]) -> Tuple[Optional[Any], Optional[str]]: + if text is None: + return None, None + try: + return json.loads(text), None + except json.JSONDecodeError: + return None, text + + +class PolarisRestTool: + """Issues HTTP requests against the Polaris REST API and packages the response.""" + + def __init__( + self, + name: str, + description: str, + base_url: str, + default_path_prefix: str, + http: urllib3.PoolManager, + authorization_provider: Optional[AuthorizationProvider] = None, + ) -> None: + self._name = name + self._description = description + self._base_url = _ensure_trailing_slash(base_url) + self._path_prefix = _normalize_prefix(default_path_prefix) + self._http = http + self._authorization = authorization_provider or none() + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + def input_schema(self) -> JSONDict: + """Return the generic JSON schema shared by delegated tools.""" + return { + "type": "object", + "properties": { + "method": { + "type": "string", + "description": ( + "HTTP method, e.g. GET, POST, PUT, DELETE, PATCH, HEAD or OPTIONS. " + "Defaults to GET." + ), + }, + "path": { + "type": "string", + "description": ( + "Relative path under the Polaris base URL, such as " + "/api/management/v1/catalogs. Absolute URLs are also accepted." + ), + }, + "query": { + "type": "object", + "description": ( + "Optional query string parameters. Values can be strings or arrays of strings." + ), + "additionalProperties": { + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + }, + }, + "headers": { + "type": "object", + "description": ( + "Optional request headers. Accept and Authorization headers are supplied " + "automatically when omitted." + ), + "additionalProperties": { + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + }, + }, + "body": { + "type": ["object", "array", "string", "number", "boolean", "null"], + "description": ( + "Optional request body. Objects and arrays are serialized as JSON, strings " + "are sent as-is." + ), + }, + }, + "required": ["path"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + method = str(arguments.get("method", "GET") or "GET").strip().upper() or "GET" + path = self._require_path(arguments) + query_params = arguments.get("query") + headers_param = arguments.get("headers") + body_node = arguments.get("body") + + query = query_params if isinstance(query_params, dict) else None + headers = headers_param if isinstance(headers_param, dict) else None + + target_uri = self._resolve_target_uri(path, query) + + header_values = _merge_headers(headers) + if not any(name.lower() == "authorization" for name in header_values): + token = self._authorization.authorization_header() + if token: + header_values["Authorization"] = token + + body_text = _serialize_body(body_node) + if body_text is not None and not any(name.lower() == "content-type" for name in header_values): + header_values["Content-Type"] = "application/json" + + response = self._http.request( + method, + target_uri, + body=body_text.encode("utf-8") if body_text is not None else None, + headers=header_values, + timeout=DEFAULT_TIMEOUT, + ) + + response_body = response.data.decode("utf-8") if response.data else "" + rendered_body = _pretty_body(response_body) + + lines = [f"{method} {target_uri}", f"Status: {response.status}"] + for key, value in _headers_to_dict(response.headers).items(): + lines.append(f"{key}: {value}") + if rendered_body: + lines.append("") + lines.append(rendered_body) + message = "\n".join(lines) + + metadata: JSONDict = { + "method": method, + "url": target_uri, + "status": response.status, + "request": { + "method": method, + "url": target_uri, + "headers": dict(header_values), + }, + "response": { + "status": response.status, + "headers": _headers_to_dict(response.headers), + }, + } + + if body_text is not None: + parsed, fallback = _maybe_parse_json(body_text) + if parsed is not None: + metadata["request"]["body"] = parsed + elif fallback is not None: + metadata["request"]["bodyText"] = fallback + + if response_body.strip(): + parsed, fallback = _maybe_parse_json(response_body) + if parsed is not None: + metadata["response"]["body"] = parsed + elif fallback is not None: + metadata["response"]["bodyText"] = fallback + + is_error = response.status >= 400 + return ToolExecutionResult(message, is_error, metadata) + + def _require_path(self, args: Dict[str, Any]) -> str: + path = args.get("path") + if not isinstance(path, str) or not path.strip(): + raise ValueError("The 'path' argument must be provided and must not be empty.") + return path.strip() + + def _resolve_target_uri(self, path: str, query: Optional[Dict[str, Any]]) -> str: + if path.startswith(("http://", "https://")): + target = path + else: + relative = path[1:] if path.startswith("/") else path + if self._path_prefix: + relative = f"{self._path_prefix}{relative}" + target = urljoin(self._base_url, relative) + + params = _build_query(query) + return _append_query(target, params) diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py new file mode 100644 index 00000000..0a8b2ff1 --- /dev/null +++ b/mcp-server/polaris_mcp/server.py @@ -0,0 +1,438 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Entry point for the Polaris Model Context Protocol server.""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, MutableMapping, Sequence +from urllib.parse import urljoin + +import urllib3 +from fastmcp import FastMCP +from fastmcp.tools.tool import ToolResult as FastMcpToolResult +from importlib import metadata +from mcp.types import TextContent + +from .authorization import ( + AuthorizationProvider, + ClientCredentialsAuthorizationProvider, + StaticAuthorizationProvider, + none, +) +from .base import ToolExecutionResult +from .tools import ( + PolarisCatalogRoleTool, + PolarisCatalogTool, + PolarisNamespaceTool, + PolarisPolicyTool, + PolarisPrincipalRoleTool, + PolarisPrincipalTool, + PolarisTableTool, +) + +DEFAULT_BASE_URL = "http://localhost:8181/" +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "isError": {"type": "boolean"}, + "meta": {"type": "object"}, + }, + "required": ["isError"], + "additionalProperties": True, +} + + +def create_server() -> FastMCP: + """Construct a FastMCP server with Polaris tools.""" + + base_url = _resolve_base_url() + http = urllib3.PoolManager() + authorization_provider = _resolve_authorization_provider(base_url, http) + + table_tool = PolarisTableTool(base_url, http, authorization_provider) + namespace_tool = PolarisNamespaceTool(base_url, http, authorization_provider) + principal_tool = PolarisPrincipalTool(base_url, http, authorization_provider) + principal_role_tool = PolarisPrincipalRoleTool(base_url, http, authorization_provider) + catalog_role_tool = PolarisCatalogRoleTool(base_url, http, authorization_provider) + policy_tool = PolarisPolicyTool(base_url, http, authorization_provider) + catalog_tool = PolarisCatalogTool(base_url, http, authorization_provider) + + server_version = _resolve_package_version() + mcp = FastMCP( + name="polaris-mcp", + version=server_version, + ) + + @mcp.tool( + name=table_tool.name, + description=table_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_iceberg_table( + operation: str, + catalog: str, + namespace: str | Sequence[str], + table: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + table_tool, + required={ + "operation": operation, + "catalog": catalog, + "namespace": namespace, + }, + optional={ + "table": table, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=namespace_tool.name, + description=namespace_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_namespace_request( + operation: str, + catalog: str, + namespace: str | Sequence[str] | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + namespace_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "namespace": namespace, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=principal_tool.name, + description=principal_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_principal_request( + operation: str, + principal: str | None = None, + principalRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + principal_tool, + required={"operation": operation}, + optional={ + "principal": principal, + "principalRole": principalRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=principal_role_tool.name, + description=principal_role_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_principal_role_request( + operation: str, + principalRole: str | None = None, + catalog: str | None = None, + catalogRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + principal_role_tool, + required={"operation": operation}, + optional={ + "principalRole": principalRole, + "catalog": catalog, + "catalogRole": catalogRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=catalog_role_tool.name, + description=catalog_role_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_catalog_role_request( + operation: str, + catalog: str, + catalogRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + catalog_role_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "catalogRole": catalogRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=policy_tool.name, + description=policy_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_policy_request( + operation: str, + catalog: str, + namespace: str | Sequence[str] | None = None, + policy: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + policy_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "namespace": namespace, + "policy": policy, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=catalog_tool.name, + description=catalog_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_catalog_request( + operation: str, + catalog: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + catalog_tool, + required={"operation": operation}, + optional={ + "catalog": catalog, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + return mcp + + +def _call_tool( + tool: Any, + *, + required: Mapping[str, Any], + optional: Mapping[str, Any | None] | None = None, + transforms: Mapping[str, Any] | None = None, +) -> FastMcpToolResult: + arguments: MutableMapping[str, Any] = dict(required) + if optional: + for key, value in optional.items(): + if value is not None: + arguments[key] = value + if transforms: + for key, transform in transforms.items(): + if key in arguments and arguments[key] is not None: + arguments[key] = transform(arguments[key]) + return _to_tool_result(tool.call(arguments)) + + +def _to_tool_result(result: ToolExecutionResult) -> FastMcpToolResult: + structured = {"isError": result.is_error} + if result.metadata is not None: + structured["meta"] = result.metadata + return FastMcpToolResult( + content=[TextContent(type="text", text=result.text)], + structured_content=structured, + ) + + +def _copy_mapping( + mapping: Mapping[str, Any] | None, +) -> MutableMapping[str, Any] | None: + if mapping is None: + return None + copied: MutableMapping[str, Any] = {} + for key, value in mapping.items(): + if value is None: + continue + if isinstance(value, (list, tuple)): + copied[key] = [str(item) for item in value] + else: + copied[key] = value + return copied + + +def _coerce_body(body: Any) -> Any: + if isinstance(body, Mapping): + return dict(body) + return body + + +def _normalize_namespace(namespace: str | Sequence[str]) -> str | list[str]: + if isinstance(namespace, str): + return namespace + return [str(part) for part in namespace] + + +def _resolve_base_url() -> str: + for candidate in ( + os.getenv("POLARIS_BASE_URL"), + os.getenv("POLARIS_REST_BASE_URL"), + ): + if candidate and candidate.strip(): + return candidate.strip() + return DEFAULT_BASE_URL + + +def _resolve_authorization_provider( + base_url: str, http: urllib3.PoolManager +) -> AuthorizationProvider: + token = _resolve_token() + if token: + return StaticAuthorizationProvider(token) + + client_id = _first_non_blank( + os.getenv("POLARIS_CLIENT_ID"), + ) + client_secret = _first_non_blank( + os.getenv("POLARIS_CLIENT_SECRET"), + ) + + if client_id and client_secret: + scope = _first_non_blank(os.getenv("POLARIS_TOKEN_SCOPE")) + token_url = _first_non_blank(os.getenv("POLARIS_TOKEN_URL")) + endpoint = token_url or urljoin(base_url, "api/catalog/v1/oauth/tokens") + return ClientCredentialsAuthorizationProvider( + token_endpoint=endpoint, + client_id=client_id, + client_secret=client_secret, + scope=scope, + http=http, + ) + + return none() + + +def _resolve_token() -> str | None: + return _first_non_blank( + os.getenv("POLARIS_API_TOKEN"), + os.getenv("POLARIS_BEARER_TOKEN"), + os.getenv("POLARIS_TOKEN"), + ) + + +def _first_non_blank(*candidates: str | None) -> str | None: + for candidate in candidates: + if candidate and candidate.strip(): + return candidate.strip() + return None + + +def _resolve_package_version() -> str: + try: + return metadata.version("polaris-mcp") + except metadata.PackageNotFoundError: + return "dev" + + +def main() -> None: + """Script entry point.""" + + server = create_server() + server.run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/mcp-server/polaris_mcp/tools/__init__.py b/mcp-server/polaris_mcp/tools/__init__.py new file mode 100644 index 00000000..dd46d215 --- /dev/null +++ b/mcp-server/polaris_mcp/tools/__init__.py @@ -0,0 +1,38 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Tool definitions exposed by the Polaris MCP server.""" + +from .catalog import PolarisCatalogTool +from .catalog_role import PolarisCatalogRoleTool +from .namespace import PolarisNamespaceTool +from .policy import PolarisPolicyTool +from .principal import PolarisPrincipalTool +from .principal_role import PolarisPrincipalRoleTool +from .table import PolarisTableTool + +__all__ = [ + "PolarisCatalogRoleTool", + "PolarisCatalogTool", + "PolarisNamespaceTool", + "PolarisPolicyTool", + "PolarisPrincipalRoleTool", + "PolarisPrincipalTool", + "PolarisTableTool", +] diff --git a/mcp-server/polaris_mcp/tools/catalog.py b/mcp-server/polaris_mcp/tools/catalog.py new file mode 100644 index 00000000..72fca9e7 --- /dev/null +++ b/mcp-server/polaris_mcp/tools/catalog.py @@ -0,0 +1,204 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the +# License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +# + +"""Catalog MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisCatalogTool(McpTool): + """Interact with the Polaris management API for catalog lifecycle operations.""" + + TOOL_NAME = "polaris-catalog-request" + TOOL_DESCRIPTION = ( + "Interact with the Polaris management API for catalog lifecycle operations." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get"} + CREATE_ALIASES: Set[str] = {"create"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.catalog.delegate", + description="Internal delegate for catalog operations", + base_url=base_url, + default_path_prefix="api/management/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "update", "delete"], + "description": ( + "Catalog operation to execute. Supported values: list, get, create, update, delete." + ), + }, + "catalog": { + "type": "string", + "description": ( + "Catalog name (required for get, update, delete). Automatically appended to the path." + ), + }, + "query": { + "type": "object", + "description": "Optional query parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload for create/update. See polaris-management-service.yml." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = "catalogs" + elif normalized == "get": + catalog_name = self._require_text(arguments, "catalog") + delegate_args["method"] = "GET" + delegate_args["path"] = f"catalogs/{catalog_name}" + elif normalized == "create": + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a body matching CreateCatalogRequest." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = "catalogs" + delegate_args["body"] = copy.deepcopy(body) + elif normalized == "update": + catalog_name = self._require_text(arguments, "catalog") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Update operations require a body matching UpdateCatalogRequest." + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"catalogs/{catalog_name}" + delegate_args["body"] = copy.deepcopy(body) + elif normalized == "delete": + catalog_name = self._require_text(arguments, "catalog") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"catalogs/{catalog_name}" + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include catalog configuration in the body. " + "See CreateCatalogRequest in spec/polaris-management-service.yml." + ) + elif operation == "update": + hint = ( + "Update requests require the catalog name in the path and body matching UpdateCatalogRequest. " + "Ensure currentEntityVersion matches the latest catalog version." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py b/mcp-server/polaris_mcp/tools/catalog_role.py new file mode 100644 index 00000000..57310143 --- /dev/null +++ b/mcp-server/polaris_mcp/tools/catalog_role.py @@ -0,0 +1,245 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the +# License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Catalog role MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisCatalogRoleTool(McpTool): + """Manage catalog roles and grants via the Polaris management API.""" + + TOOL_NAME = "polaris-catalog-role-request" + TOOL_DESCRIPTION = "Manage catalog roles and grants via the Polaris management API." + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + LIST_PRINCIPAL_ROLES_ALIASES: Set[str] = {"list-principal-roles", "list-assigned-principal-roles"} + LIST_GRANTS_ALIASES: Set[str] = {"list-grants"} + ADD_GRANT_ALIASES: Set[str] = {"add-grant", "grant"} + REVOKE_GRANT_ALIASES: Set[str] = {"revoke-grant"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.catalogrole.delegate", + description="Internal delegate for catalog role operations", + base_url=base_url, + default_path_prefix="api/management/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "list-principal-roles", + "list-grants", + "add-grant", + "revoke-grant", + ], + "description": ( + "Catalog role operation (list, get, create, update, delete, list-principal-roles, " + "list-grants, add-grant, revoke-grant)." + ), + }, + "catalog": { + "type": "string", + "description": "Catalog name (required).", + }, + "catalogRole": { + "type": "string", + "description": "Catalog role name for role-specific operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body for create/update and grant operations. " + "See polaris-management-service.yml for schemas like CreateCatalogRoleRequest, AddGrantRequest." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._require_text(arguments, "catalog") + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + base_path = self._catalog_roles_base(catalog) + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = base_path + elif normalized == "create": + delegate_args["method"] = "POST" + delegate_args["path"] = base_path + delegate_args["body"] = self._require_object(arguments, "body", "CreateCatalogRoleRequest") + elif normalized == "get": + delegate_args["method"] = "GET" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + elif normalized == "update": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + delegate_args["body"] = self._require_object(arguments, "body", "UpdateCatalogRoleRequest") + elif normalized == "delete": + delegate_args["method"] = "DELETE" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + elif normalized == "list-principal-roles": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/principal-roles" + elif normalized == "list-grants": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + elif normalized == "add-grant": + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + delegate_args["body"] = self._require_object(arguments, "body", "AddGrantRequest") + elif normalized == "revoke-grant": + delegate_args["method"] = "POST" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + if isinstance(arguments.get("body"), dict): + delegate_args["body"] = copy.deepcopy(arguments["body"]) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _catalog_roles_base(self, catalog: str) -> str: + return f"catalogs/{catalog}/catalog-roles" + + def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> str: + role = self._require_text(arguments, "catalogRole") + return f"{base_path}/{role}" + + def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + node = arguments.get(field) + if not isinstance(node, dict): + raise ValueError(f"{description} payload (`{field}`) is required.") + return copy.deepcopy(node) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = "Create catalog role requires CreateCatalogRoleRequest body." + elif operation == "update": + hint = ( + "Update catalog role requires UpdateCatalogRoleRequest body with currentEntityVersion." + ) + elif operation == "add-grant": + hint = "Grant operations require AddGrantRequest body." + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.LIST_PRINCIPAL_ROLES_ALIASES: + return "list-principal-roles" + if operation in self.LIST_GRANTS_ALIASES: + return "list-grants" + if operation in self.ADD_GRANT_ALIASES: + return "add-grant" + if operation in self.REVOKE_GRANT_ALIASES: + return "revoke-grant" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py new file mode 100644 index 00000000..c2e7e586 --- /dev/null +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -0,0 +1,303 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Namespace MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisNamespaceTool(McpTool): + """Manage namespaces through the Polaris REST API.""" + + TOOL_NAME = "polaris-namespace-request" + TOOL_DESCRIPTION = ( + "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get", "load"} + EXISTS_ALIASES: Set[str] = {"exists", "head"} + CREATE_ALIASES: Set[str] = {"create"} + UPDATE_PROPS_ALIASES: Set[str] = {"update-properties", "set-properties", "properties-update"} + GET_PROPS_ALIASES: Set[str] = {"get-properties", "properties"} + DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.namespace.delegate", + description="Internal delegate for namespace operations", + base_url=base_url, + default_path_prefix="api/catalog/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "get", + "exists", + "create", + "update-properties", + "get-properties", + "delete", + ], + "description": ( + "Namespace operation to execute. Supported values: list, get, exists, create, " + "update-properties, get-properties, delete." + ), + }, + "catalog": { + "type": "string", + "description": "Catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace identifier. Provide as dot-delimited string (e.g. \"analytics.daily\") " + "or array of path components." + ), + }, + "query": { + "type": "object", + "description": "Optional query string parameters (for example page-size, page-token).", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload (required for create and update-properties). " + "See the Iceberg REST catalog specification for the expected schema." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args, catalog) + elif normalized == "get": + self._handle_get(arguments, delegate_args, catalog) + elif normalized == "exists": + self._handle_exists(arguments, delegate_args, catalog) + elif normalized == "create": + self._handle_create(arguments, delegate_args, catalog) + elif normalized == "update-properties": + self._handle_update_properties(arguments, delegate_args, catalog) + elif normalized == "get-properties": + self._handle_get_properties(arguments, delegate_args, catalog) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args, catalog) + else: # pragma: no cover - normalize guarantees cases + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict, catalog: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces" + + def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _handle_exists( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "HEAD" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _handle_create( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + body = arguments.get("body") + body_obj = copy.deepcopy(body) if isinstance(body, dict) else {} + + if "namespace" not in body_obj or body_obj.get("namespace") is None: + if "namespace" in arguments and arguments["namespace"] is not None: + namespace_parts = self._resolve_namespace_array(arguments) + body_obj["namespace"] = namespace_parts + else: + raise ValueError( + "Create operations require `body.namespace` or the `namespace` argument." + ) + + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces" + delegate_args["body"] = body_obj + + def _handle_update_properties( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "update-properties requires a body matching UpdateNamespacePropertiesRequest." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/properties" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_get_properties( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/properties" + + def _handle_delete( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 422): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include `namespace` (array of strings) in the body and optional `properties`. " + "See CreateNamespaceRequest in spec/iceberg-rest-catalog-open-api.yaml." + ) + elif operation == "update-properties": + hint = ( + "update-properties requests require `body` with `updates` and/or `removals`. " + "See UpdateNamespacePropertiesRequest in spec/iceberg-rest-catalog-open-api.yaml." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: + namespace = arguments.get("namespace") + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one component.") + parts: List[str] = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return parts + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip().split(".") + + def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: + parts = self._resolve_namespace_array(arguments) + joined = ".".join(parts) + return self._encode_segment(joined) + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.EXISTS_ALIASES: + return "exists" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_PROPS_ALIASES: + return "update-properties" + if operation in self.GET_PROPS_ALIASES: + return "get-properties" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") diff --git a/mcp-server/polaris_mcp/tools/policy.py b/mcp-server/polaris_mcp/tools/policy.py new file mode 100644 index 00000000..14338ccd --- /dev/null +++ b/mcp-server/polaris_mcp/tools/policy.py @@ -0,0 +1,377 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the +# License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Policy MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPolicyTool(McpTool): + """Expose Polaris policy endpoints via MCP.""" + + TOOL_NAME = "polaris-policy" + TOOL_DESCRIPTION = ( + "Manage Polaris policies (list, create, update, delete, attach, detach, applicable)." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get", "load", "fetch"} + CREATE_ALIASES: Set[str] = {"create"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"} + ATTACH_ALIASES: Set[str] = {"attach", "map"} + DETACH_ALIASES: Set[str] = {"detach", "unmap", "unattach"} + APPLICABLE_ALIASES: Set[str] = {"applicable", "applicable-policies"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.policy.delegate", + description="Internal delegate for policy operations", + base_url=base_url, + default_path_prefix="api/catalog/polaris/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "update", "delete", "attach", "detach", "applicable"], + "description": ( + "Policy operation to execute. Supported values: list, get, create, update, delete, attach, detach, applicable." + ), + }, + "catalog": { + "type": "string", + "description": "Polaris catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace that contains the target policies. Provide as a dot-delimited string " + '(e.g. "analytics.daily") or an array of strings.' + ), + }, + "policy": { + "type": "string", + "description": "Policy identifier for operations that target a specific policy.", + }, + "query": { + "type": "object", + "description": ( + "Optional query string parameters (for example page-size, policy-type, detach-all)." + ), + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional additional HTTP headers to include with the request.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload for create/update/attach/detach operations. " + "The structure must follow the corresponding Polaris REST schema." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + namespace: Optional[str] = None + if normalized != "applicable": + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + elif arguments.get("namespace") is not None: + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._require_namespace(namespace, "list") + self._handle_list(delegate_args, catalog, namespace) + elif normalized == "get": + self._require_namespace(namespace, "get") + self._handle_get(arguments, delegate_args, catalog, namespace) + elif normalized == "create": + self._require_namespace(namespace, "create") + self._handle_create(arguments, delegate_args, catalog, namespace) + elif normalized == "update": + self._require_namespace(namespace, "update") + self._handle_update(arguments, delegate_args, catalog, namespace) + elif normalized == "delete": + self._require_namespace(namespace, "delete") + self._handle_delete(arguments, delegate_args, catalog, namespace) + elif normalized == "attach": + self._require_namespace(namespace, "attach") + self._handle_attach(arguments, delegate_args, catalog, namespace) + elif normalized == "detach": + self._require_namespace(namespace, "detach") + self._handle_detach(arguments, delegate_args, catalog, namespace) + elif normalized == "applicable": + self._handle_applicable(delegate_args, catalog) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies" + + def _handle_get( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for get operations.") + ) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + + def _handle_create( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a request body that matches the CreatePolicyRequest schema." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_update( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Update operations require a request body that matches the UpdatePolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for update operations.") + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for delete operations.") + ) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + + def _handle_attach( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Attach operations require a request body that matches the AttachPolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for attach operations.") + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_detach( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Detach operations require a request body that matches the DetachPolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for detach operations.") + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_applicable(self, delegate_args: JSONDict, catalog: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/applicable-policies" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 404, 422): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include `name`, `type`, and optional `description`/`content` in the body. " + "See CreatePolicyRequest in spec/polaris-catalog-apis/policy-apis.yaml. " + "Common types include system.data-compaction, system.metadata-compaction, " + "system.orphan-file-removal, and system.snapshot-expiry. " + "Example: {\"name\":\"weekly_compaction\",\"type\":\"system.data-compaction\",\"content\":{...}}. " + "Reference schema: http://polaris.apache.org/schemas/policies/system/data-compaction/2025-02-03.json" + ) + elif operation == "update": + hint = ( + "Update requests require the policy name in the path and the body with `description`, " + "`content`, and `currentVersion`." + ) + elif operation == "attach": + hint = ( + "Attach requests require a body with `targetType`, `targetName`, and optional `parameters`. " + "Ensure the policy exists first (create it with operation=create) before attaching." + ) + elif operation == "detach": + hint = ( + "Detach requests require a body with `targetType`, `targetName`, and optional `parameters`." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.ATTACH_ALIASES: + return "attach" + if operation in self.DETACH_ALIASES: + return "detach" + if operation in self.APPLICABLE_ALIASES: + return "applicable" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + if message is None: + message = f"Missing required field: {field}" + raise ValueError(message) + return value.strip() + + def _require_namespace(self, namespace: Optional[str], operation: str) -> None: + if not namespace: + raise ValueError( + f"Namespace is required for {operation} operations. Provide `namespace` as a string or array." + ) + + def _resolve_namespace(self, namespace: Any) -> str: + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one element.") + parts = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return ".".join(parts) + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip() + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") diff --git a/mcp-server/polaris_mcp/tools/principal.py b/mcp-server/polaris_mcp/tools/principal.py new file mode 100644 index 00000000..a94585ba --- /dev/null +++ b/mcp-server/polaris_mcp/tools/principal.py @@ -0,0 +1,295 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Principal MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPrincipalTool(McpTool): + """Manage principals via the Polaris management API.""" + + TOOL_NAME = "polaris-principal-request" + TOOL_DESCRIPTION = ( + "Manage principals via the Polaris management API (list, get, create, update, delete, " + "rotate/reset credentials, role assignment)." + ) + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + ROTATE_ALIASES: Set[str] = {"rotate-credentials", "rotate"} + RESET_ALIASES: Set[str] = {"reset-credentials", "reset"} + LIST_ROLES_ALIASES: Set[str] = {"list-principal-roles", "list-roles"} + ASSIGN_ROLE_ALIASES: Set[str] = {"assign-principal-role", "assign-role"} + REVOKE_ROLE_ALIASES: Set[str] = {"revoke-principal-role", "revoke-role"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.principal.delegate", + description="Internal delegate for principal operations", + base_url=base_url, + default_path_prefix="api/management/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "rotate-credentials", + "reset-credentials", + "list-principal-roles", + "assign-principal-role", + "revoke-principal-role", + ], + "description": ( + "Principal operation to execute (list, get, create, update, delete, rotate-credentials, " + "reset-credentials, list-principal-roles, assign-principal-role, revoke-principal-role). " + "Optional query parameters (e.g. catalog context) can be supplied via `query`." + ), + }, + "principal": { + "type": "string", + "description": "Principal name for operations targeting a specific principal.", + }, + "principalRole": { + "type": "string", + "description": "Principal role name for assignment/revocation operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body payload. Required for create/update, grant/revoke operations. " + "See polaris-management-service.yml for schemas such as CreatePrincipalRequest." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args) + elif normalized == "create": + self._handle_create(arguments, delegate_args) + elif normalized == "get": + self._handle_get(arguments, delegate_args) + elif normalized == "update": + self._handle_update(arguments, delegate_args) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args) + elif normalized == "rotate-credentials": + self._handle_rotate(arguments, delegate_args) + elif normalized == "reset-credentials": + self._handle_reset(arguments, delegate_args) + elif normalized == "list-principal-roles": + self._handle_list_roles(arguments, delegate_args) + elif normalized == "assign-principal-role": + self._handle_assign_role(arguments, delegate_args) + elif normalized == "revoke-principal-role": + self._handle_revoke_role(arguments, delegate_args) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = "principals" + + def _handle_create(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError("Create principal requires a body matching CreatePrincipalRequest.") + delegate_args["method"] = "POST" + delegate_args["path"] = "principals" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "GET" + delegate_args["path"] = f"principals/{principal}" + + def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError("Update principal requires a body matching UpdatePrincipalRequest.") + delegate_args["method"] = "PUT" + delegate_args["path"] = f"principals/{principal}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"principals/{principal}" + + def _handle_rotate(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "POST" + delegate_args["path"] = f"principals/{principal}/rotate" + + def _handle_reset(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "POST" + delegate_args["path"] = f"principals/{principal}/reset" + if isinstance(arguments.get("body"), dict): + delegate_args["body"] = copy.deepcopy(arguments["body"]) + + def _handle_list_roles(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "GET" + delegate_args["path"] = f"principals/{principal}/principal-roles" + + def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "assign-principal-role requires a body matching GrantPrincipalRoleRequest." + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"principals/{principal}/principal-roles" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_revoke_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + role = self._require_text(arguments, "principalRole") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"principals/{principal}/principal-roles/{role}" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create principal requires a body matching CreatePrincipalRequest. " + "See spec/polaris-management-service.yml." + ) + elif operation == "update": + hint = ( + "Update principal requires `principal` and body matching UpdatePrincipalRequest with currentEntityVersion." + ) + elif operation == "assign-principal-role": + hint = ( + "Provide GrantPrincipalRoleRequest in the body (principalRoleName, catalogName, etc.)." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.ROTATE_ALIASES: + return "rotate-credentials" + if operation in self.RESET_ALIASES: + return "reset-credentials" + if operation in self.LIST_ROLES_ALIASES: + return "list-principal-roles" + if operation in self.ASSIGN_ROLE_ALIASES: + return "assign-principal-role" + if operation in self.REVOKE_ROLE_ALIASES: + return "revoke-principal-role" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/mcp-server/polaris_mcp/tools/principal_role.py b/mcp-server/polaris_mcp/tools/principal_role.py new file mode 100644 index 00000000..47d7cfcb --- /dev/null +++ b/mcp-server/polaris_mcp/tools/principal_role.py @@ -0,0 +1,255 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the +# License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +# + +"""Principal role MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPrincipalRoleTool(McpTool): + """Manage principal roles through the Polaris management API.""" + + TOOL_NAME = "polaris-principal-role-request" + TOOL_DESCRIPTION = ( + "Manage principal roles (list, get, create, update, delete) and their catalog-role assignments via the Polaris management API." + ) + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + LIST_PRINCIPALS_ALIASES: Set[str] = {"list-principals", "list-assignees"} + LIST_CATALOG_ROLES_ALIASES: Set[str] = {"list-catalog-roles", "list-mapped-catalog-roles"} + ASSIGN_CATALOG_ROLE_ALIASES: Set[str] = {"assign-catalog-role", "grant-catalog-role"} + REVOKE_CATALOG_ROLE_ALIASES: Set[str] = {"revoke-catalog-role", "remove-catalog-role"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.principalrole.delegate", + description="Internal delegate for principal role operations", + base_url=base_url, + default_path_prefix="api/management/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "list-principals", + "list-catalog-roles", + "assign-catalog-role", + "revoke-catalog-role", + ], + "description": ( + "Principal role operation to execute. Provide optional `catalog`/`catalogRole` when required " + "(e.g. assignment)." + ), + }, + "principalRole": { + "type": "string", + "description": "Principal role name (required for role-specific operations).", + }, + "catalog": { + "type": "string", + "description": "Catalog name for mapping operations to catalog roles.", + }, + "catalogRole": { + "type": "string", + "description": "Catalog role name for revoke operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body payload (required for create/update and assign operations). " + "See polaris-management-service.yml for request schemas." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = "principal-roles" + elif normalized == "create": + delegate_args["method"] = "POST" + delegate_args["path"] = "principal-roles" + delegate_args["body"] = self._require_object( + arguments, "body", "CreatePrincipalRoleRequest" + ) + elif normalized == "get": + delegate_args["method"] = "GET" + delegate_args["path"] = self._principal_role_path(arguments) + elif normalized == "update": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._principal_role_path(arguments) + delegate_args["body"] = self._require_object( + arguments, "body", "UpdatePrincipalRoleRequest" + ) + elif normalized == "delete": + delegate_args["method"] = "DELETE" + delegate_args["path"] = self._principal_role_path(arguments) + elif normalized == "list-principals": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._principal_role_path(arguments)}/principals" + elif normalized == "list-catalog-roles": + delegate_args["method"] = "GET" + delegate_args["path"] = self._principal_role_catalog_path(arguments) + elif normalized == "assign-catalog-role": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._principal_role_catalog_path(arguments) + delegate_args["body"] = self._require_object( + arguments, "body", "GrantCatalogRoleRequest" + ) + elif normalized == "revoke-catalog-role": + delegate_args["method"] = "DELETE" + catalog_role = self._require_text(arguments, "catalogRole") + delegate_args["path"] = ( + f"{self._principal_role_catalog_path(arguments)}/{catalog_role}" + ) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _principal_role_path(self, arguments: Dict[str, Any]) -> str: + role = self._require_text(arguments, "principalRole") + return f"principal-roles/{role}" + + def _principal_role_catalog_path(self, arguments: Dict[str, Any]) -> str: + catalog = self._require_text(arguments, "catalog") + return f"{self._principal_role_path(arguments)}/catalog-roles/{catalog}" + + def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + node = arguments.get(field) + if not isinstance(node, dict): + raise ValueError(f"{description} payload (`{field}`) is required.") + return copy.deepcopy(node) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = "Create principal role requires CreatePrincipalRoleRequest body." + elif operation == "update": + hint = ( + "Update principal role requires UpdatePrincipalRoleRequest body with currentEntityVersion." + ) + elif operation == "assign-catalog-role": + hint = "Provide GrantCatalogRoleRequest body when assigning catalog roles." + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.LIST_PRINCIPALS_ALIASES: + return "list-principals" + if operation in self.LIST_CATALOG_ROLES_ALIASES: + return "list-catalog-roles" + if operation in self.ASSIGN_CATALOG_ROLE_ALIASES: + return "assign-catalog-role" + if operation in self.REVOKE_CATALOG_ROLE_ALIASES: + return "revoke-catalog-role" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py new file mode 100644 index 00000000..865fdf50 --- /dev/null +++ b/mcp-server/polaris_mcp/tools/table.py @@ -0,0 +1,258 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Iceberg table MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisTableTool(McpTool): + """Expose Polaris table REST endpoints through MCP.""" + + TOOL_NAME = "polaris-iceberg-table" + TOOL_DESCRIPTION = ( + "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." + ) + + LIST_ALIASES: Set[str] = {"list", "ls"} + GET_ALIASES: Set[str] = {"get", "load", "fetch"} + CREATE_ALIASES: Set[str] = {"create"} + COMMIT_ALIASES: Set[str] = {"commit", "update"} + DELETE_ALIASES: Set[str] = {"delete", "drop"} + + def __init__( + self, + base_url: str, + http: urllib3.PoolManager, + authorization_provider: AuthorizationProvider, + ) -> None: + self._delegate = PolarisRestTool( + name="polaris.table.delegate", + description="Internal delegate for table operations", + base_url=base_url, + default_path_prefix="api/catalog/v1/", + http=http, + authorization_provider=authorization_provider, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "commit", "delete"], + "description": ( + "Table operation to execute. Supported values: list, get (synonyms: load, fetch), " + "create, commit (synonym: update), delete (synonym: drop)." + ), + }, + "catalog": { + "type": "string", + "description": "Polaris catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace that contains the target tables. Provide as a dot-delimited string " + '(e.g. "analytics.daily") or an array of strings.' + ), + }, + "table": { + "type": "string", + "description": ( + "Table identifier for operations that target a specific table (get, commit, delete)." + ), + }, + "query": { + "type": "object", + "description": "Optional query string parameters (for example page-size, page-token, include-drop).", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional additional HTTP headers to include with the request.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": "Optional request body payload for create or commit operations.", + }, + }, + "required": ["operation", "catalog", "namespace"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args, catalog, namespace) + elif normalized == "get": + self._handle_get(arguments, delegate_args, catalog, namespace) + elif normalized == "create": + self._handle_create(arguments, delegate_args, catalog, namespace) + elif normalized == "commit": + self._handle_commit(arguments, delegate_args, catalog, namespace) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args, catalog, namespace) + else: # pragma: no cover - defensive, normalize guarantees handled cases + raise ValueError(f"Unsupported operation: {operation}") + + return self._delegate.call(delegate_args) + + def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" + + def _handle_get( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for get operations.") + ) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + + def _handle_create( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a request body that matches the CreateTableRequest schema." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_commit( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Commit operations require a request body that matches the CommitTableRequest schema." + ) + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for commit operations.") + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for delete operations.") + ) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.COMMIT_ALIASES: + return "commit" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _resolve_namespace(self, namespace: Any) -> str: + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one element.") + parts = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return ".".join(parts) + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip() + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") + + def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + if message is None: + message = f"Missing required field: {field}" + raise ValueError(message) + return value.strip() diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 00000000..817cb37f --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,48 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[project] +name = "polaris-mcp" +version = "1.2.0" +description = "Apache Polaris Model Context Protocol server" +authors = [ + {name = "Apache Software Foundation", email = "dev@polaris.apache.org"} +] +readme = "README.md" +requires-python = ">=3.10,<4.0" +license = "Apache-2.0" +keywords = ["Apache Polaris", "Polaris", "Model Context Protocol"] +dependencies = [ + "fastmcp>=2.13.0.2", + "urllib3>=1.25.3,<3.0.0", +] + +[project.scripts] +polaris-mcp = "polaris_mcp.server:main" + +[project.urls] +homepage = "https://polaris.apache.org/" +repository = "https://github.com/apache/polaris/" + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["polaris_mcp"] From e74dbf7a700d01edb20dd0a16a0865fec56044bf Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 14:47:47 -0800 Subject: [PATCH 02/23] Add unit tests --- mcp-server/README.md | 9 ++ mcp-server/tests/test_server.py | 207 ++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 mcp-server/tests/test_server.py diff --git a/mcp-server/README.md b/mcp-server/README.md index 11cb7dc5..dc772d52 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -40,6 +40,15 @@ Launch the MCP server (which reads from stdin and writes to stdout): uv run polaris-mcp ``` +## Testing + +Install dependencies (one-time) and run the Python unit tests with `uv` so the locked environment is used: + +```bash +uv sync +uv run python -m unittest discover -s tests +``` + Example interaction: ```json diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py new file mode 100644 index 00000000..7bac3133 --- /dev/null +++ b/mcp-server/tests/test_server.py @@ -0,0 +1,207 @@ +"""Unit tests for ``polaris_mcp.server`` helpers.""" + +from __future__ import annotations + +import os +import unittest +from collections import UserDict +from importlib import metadata +from unittest import mock + +from polaris_mcp import server +from polaris_mcp.base import ToolExecutionResult + + +class ServerHelpersTest(unittest.TestCase): + def test_call_tool_merges_arguments_and_applies_transforms(self) -> None: + captured: dict[str, object] = {} + + class DummyTool: + def call(self, arguments: dict[str, object]) -> ToolExecutionResult: + captured["arguments"] = arguments + return ToolExecutionResult(text="done", is_error=False, metadata={"x": 1}) + + tool = DummyTool() + sentinel = object() + with mock.patch("polaris_mcp.server._to_tool_result", return_value=sentinel) as mock_to_result: + result = server._call_tool( + tool, + required={"operation": "GET", "catalog": "prod"}, + optional={ + "namespace": ("db", 1), + "table": None, + "query": {"limit": 10, "filter": None}, + }, + transforms={ + "namespace": server._normalize_namespace, + "query": server._copy_mapping, + }, + ) + + self.assertIs(result, sentinel) + self.assertEqual( + captured["arguments"], + { + "operation": "GET", + "catalog": "prod", + "namespace": ["db", "1"], + "query": {"limit": 10}, + }, + ) + mock_to_result.assert_called_once() + tool_result_arg = mock_to_result.call_args.args[0] + self.assertIsInstance(tool_result_arg, ToolExecutionResult) + self.assertEqual(tool_result_arg.text, "done") + + def test_copy_mapping_filters_none_and_normalizes_sequences(self) -> None: + source = {"a": "keep", "b": None, "c": ["one", 2], "d": ("x", 3)} + copied = server._copy_mapping(source) + + self.assertEqual( + copied, + { + "a": "keep", + "c": ["one", "2"], + "d": ["x", "3"], + }, + ) + self.assertIsNot(copied, source) + self.assertIsNone(server._copy_mapping(None)) + + def test_normalize_namespace_accepts_text_and_sequences(self) -> None: + self.assertEqual(server._normalize_namespace("analytics"), "analytics") + self.assertEqual(server._normalize_namespace(("db", 23)), ["db", "23"]) + + def test_resolve_base_url_prefers_env_vars(self) -> None: + with mock.patch.dict( + os.environ, + { + "POLARIS_BASE_URL": " https://primary/ ", + "POLARIS_REST_BASE_URL": "https://secondary/", + }, + clear=True, + ): + self.assertEqual(server._resolve_base_url(), "https://primary/") + + with mock.patch.dict( + os.environ, + {"POLARIS_REST_BASE_URL": "https://secondary/"}, + clear=True, + ): + self.assertEqual(server._resolve_base_url(), "https://secondary/") + + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(server._resolve_base_url(), server.DEFAULT_BASE_URL) + + def test_first_non_blank_returns_first_usable_value(self) -> None: + self.assertEqual(server._first_non_blank(None, " ", "\tvalue", "later"), "value") + self.assertIsNone(server._first_non_blank(None)) + + def test_resolve_token_checks_multiple_env_variables(self) -> None: + with mock.patch.dict( + os.environ, + { + "POLARIS_API_TOKEN": " ", + "POLARIS_BEARER_TOKEN": "token-b", + "POLARIS_TOKEN": "token-c", + }, + clear=True, + ): + self.assertEqual(server._resolve_token(), "token-b") + + def test_coerce_body_returns_plain_dict_for_mappings(self) -> None: + user_dict = UserDict({"a": 1}) + self.assertEqual(server._coerce_body(user_dict), {"a": 1}) + sequence = [1, 2] + self.assertIs(server._coerce_body(sequence), sequence) + + def test_to_tool_result_builds_fastmcp_payload_with_metadata(self) -> None: + execution = ToolExecutionResult(text="ok", is_error=True, metadata={"foo": "bar"}) + text_instance = object() + fast_instance = object() + with mock.patch( + "polaris_mcp.server.TextContent", return_value=text_instance + ) as mock_text, mock.patch( + "polaris_mcp.server.FastMcpToolResult", return_value=fast_instance + ) as mock_result: + output = server._to_tool_result(execution) + + self.assertIs(output, fast_instance) + mock_text.assert_called_once_with(type="text", text="ok") + mock_result.assert_called_once_with( + content=[text_instance], + structured_content={"isError": True, "meta": {"foo": "bar"}}, + ) + + def test_to_tool_result_omits_meta_when_not_provided(self) -> None: + execution = ToolExecutionResult(text="hello", is_error=False, metadata=None) + with mock.patch("polaris_mcp.server.TextContent") as mock_text, mock.patch( + "polaris_mcp.server.FastMcpToolResult" + ) as mock_result: + server._to_tool_result(execution) + + mock_text.assert_called_once_with(type="text", text="hello") + structured = mock_result.call_args.kwargs["structured_content"] + self.assertEqual(structured, {"isError": False}) + + def test_resolve_package_version_uses_metadata_and_handles_missing(self) -> None: + with mock.patch("polaris_mcp.server.metadata.version", return_value="2.0.0"): + self.assertEqual(server._resolve_package_version(), "2.0.0") + + with mock.patch( + "polaris_mcp.server.metadata.version", side_effect=metadata.PackageNotFoundError + ): + self.assertEqual(server._resolve_package_version(), "dev") + + +class AuthorizationProviderResolutionTest(unittest.TestCase): + def test_resolve_authorization_provider_uses_token_when_available(self) -> None: + fake_http = object() + with mock.patch("polaris_mcp.server._resolve_token", return_value="abc"), mock.patch.dict( + os.environ, {}, clear=True + ): + provider = server._resolve_authorization_provider("https://base/", fake_http) + + self.assertIsInstance(provider, server.StaticAuthorizationProvider) + self.assertEqual(provider.authorization_header(), "Bearer abc") + + def test_resolve_authorization_provider_uses_client_credentials(self) -> None: + fake_http = object() + fake_provider = object() + with mock.patch("polaris_mcp.server._resolve_token", return_value=None), mock.patch.dict( + os.environ, + { + "POLARIS_CLIENT_ID": " client ", + "POLARIS_CLIENT_SECRET": "secret", + "POLARIS_TOKEN_SCOPE": " scope ", + "POLARIS_TOKEN_URL": "https://oauth/token", + }, + clear=True, + ), mock.patch( + "polaris_mcp.server.ClientCredentialsAuthorizationProvider", return_value=fake_provider + ) as mock_factory: + provider = server._resolve_authorization_provider("https://base/", fake_http) + + self.assertIs(provider, fake_provider) + mock_factory.assert_called_once_with( + token_endpoint="https://oauth/token", + client_id="client", + client_secret="secret", + scope="scope", + http=fake_http, + ) + + def test_resolve_authorization_provider_falls_back_to_none(self) -> None: + fake_http = object() + sentinel = object() + with mock.patch("polaris_mcp.server._resolve_token", return_value=None), mock.patch.dict( + os.environ, {}, clear=True + ), mock.patch("polaris_mcp.server.none", return_value=sentinel) as mock_none: + provider = server._resolve_authorization_provider("https://base/", fake_http) + + self.assertIs(provider, sentinel) + mock_none.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() From 3f10793495006a0d1d5abe39bd389bd1362a34d6 Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 14:57:38 -0800 Subject: [PATCH 03/23] Add uv.lock --- mcp-server/uv.lock | 1452 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1452 insertions(+) create mode 100644 mcp-server/uv.lock diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock new file mode 100644 index 00000000..528073ed --- /dev/null +++ b/mcp-server/uv.lock @@ -0,0 +1,1452 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[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 = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/09/9003e5662691056e0e8b2e6f57c799e71875fac0be0e785d8cb11557cd2a/beartype-0.22.5.tar.gz", hash = "sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341", size = 1586256, upload-time = "2025-11-01T05:49:20.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/f6/073d19f7b571c08327fbba3f8e011578da67ab62a11f98911274ff80653f/beartype-0.22.5-py3-none-any.whl", hash = "sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0", size = 1321700, upload-time = "2025-11-01T05:49:18.436Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "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/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { 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.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[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'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { 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.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/ab/67bd1b010aaa09c6b627a535cbb0c6453920c290df83cbdccb0b097227d9/cyclopts-4.2.2.tar.gz", hash = "sha256:d5eb7d5ab688f8e86bc6fc7b8ab85951b2fe34aebcf1a48c4c9a6b43c14cb78b", size = 148736, upload-time = "2025-11-10T15:29:46.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/cc/53c8350d8ca53ada627f071c252e806b97c949e03b054af7d15e62309a83/cyclopts-4.2.2-py3-none-any.whl", hash = "sha256:2e001158ccb275723a4d820c65d114caa078073e61298f2a7c6112a8d3ba90c6", size = 184362, upload-time = "2025-11-10T15:29:44.984Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, +] + +[[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.13.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/74/584a152bcd174c99ddf3cfdd7e86ec4a6c696fb190a907c2a2ec9056bda2/fastmcp-2.13.0.2.tar.gz", hash = "sha256:d35386561b6f3cde195ba2b5892dc89b8919a721e6b39b98e7a16f9a7c0b8e8b", size = 7762083, upload-time = "2025-10-28T13:56:21.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/c6/95eacd687cfab64fec13bfb64e6c6e7da13d01ecd4cb7d7e991858a08119/fastmcp-2.13.0.2-py3-none-any.whl", hash = "sha256:eb381eb073a101aabbc0ac44b05e23fef0cd1619344b7703115c825c8755fa1c", size = 367511, upload-time = "2025-10-28T13:56:18.83Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[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 = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" }, +] + +[[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-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 = "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 = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "polaris-mcp" +version = "1.2.0" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "urllib3" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.13.0.2" }, + { name = "urllib3", specifier = ">=1.25.3,<3.0.0" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/35/65310a4818acec0f87a46e5565e341c5a96fc062a9a03495ad28828ff4d7/py_key_value_aio-0.2.8.tar.gz", hash = "sha256:c0cfbb0bd4e962a3fa1a9fa6db9ba9df812899bd9312fa6368aaea7b26008b36", size = 32853, upload-time = "2025-10-24T13:31:04.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/5a/e56747d87a97ad2aff0f3700d77f186f0704c90c2da03bfed9e113dae284/py_key_value_aio-0.2.8-py3-none-any.whl", hash = "sha256:561565547ce8162128fd2bd0b9d70ce04a5f4586da8500cce79a54dfac78c46a", size = 69200, upload-time = "2025-10-24T13:31:03.81Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/79/05a1f9280cfa0709479319cbfd2b1c5beb23d5034624f548c83fb65b0b61/py_key_value_shared-0.2.8.tar.gz", hash = "sha256:703b4d3c61af124f0d528ba85995c3c8d78f8bd3d2b217377bd3278598070cc1", size = 8216, upload-time = "2025-10-24T13:31:03.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7a/1726ceaa3343874f322dd83c9ec376ad81f533df8422b8b1e1233a59f8ce/py_key_value_shared-0.2.8-py3-none-any.whl", hash = "sha256:aff1bbfd46d065b2d67897d298642e80e5349eae588c6d11b48452b46b8d46ba", size = 14586, upload-time = "2025-10-24T13:31:02.838Z" }, +] + +[[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.4" +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/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[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 = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.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/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { 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 = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[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.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/f8/13bb772dc7cbf2c3c5b816febc34fa0cb2c64a08e0569869585684ce6631/rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a", size = 362820, upload-time = "2025-10-22T22:21:15.074Z" }, + { url = "https://files.pythonhosted.org/packages/84/91/6acce964aab32469c3dbe792cb041a752d64739c534e9c493c701ef0c032/rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207", size = 348499, upload-time = "2025-10-22T22:21:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/f1/93/c05bb1f4f5e0234db7c4917cb8dd5e2e0a9a7b26dc74b1b7bee3c9cfd477/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba", size = 379356, upload-time = "2025-10-22T22:21:19.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/37/e292da436f0773e319753c567263427cdf6c645d30b44f09463ff8216cda/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85", size = 390151, upload-time = "2025-10-22T22:21:21.569Z" }, + { url = "https://files.pythonhosted.org/packages/76/87/a4e3267131616e8faf10486dc00eaedf09bd61c87f01e5ef98e782ee06c9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d", size = 524831, upload-time = "2025-10-22T22:21:23.394Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c8/4a4ca76f0befae9515da3fad11038f0fce44f6bb60b21fe9d9364dd51fb0/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7", size = 404687, upload-time = "2025-10-22T22:21:25.201Z" }, + { url = "https://files.pythonhosted.org/packages/6a/65/118afe854424456beafbbebc6b34dcf6d72eae3a08b4632bc4220f8240d9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa", size = 382683, upload-time = "2025-10-22T22:21:26.536Z" }, + { url = "https://files.pythonhosted.org/packages/f7/bc/0625064041fb3a0c77ecc8878c0e8341b0ae27ad0f00cf8f2b57337a1e63/rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476", size = 398927, upload-time = "2025-10-22T22:21:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/5d/1a/fed7cf2f1ee8a5e4778f2054153f2cfcf517748875e2f5b21cf8907cd77d/rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04", size = 411590, upload-time = "2025-10-22T22:21:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/a8e0f67fa374a6c472dbb0afdaf1ef744724f165abb6899f20e2f1563137/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8", size = 559843, upload-time = "2025-10-22T22:21:30.917Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ea/e10353f6d7c105be09b8135b72787a65919971ae0330ad97d87e4e199880/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4", size = 584188, upload-time = "2025-10-22T22:21:32.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/b0/a19743e0763caf0c89f6fc6ba6fbd9a353b24ffb4256a492420c5517da5a/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457", size = 550052, upload-time = "2025-10-22T22:21:34.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ec2c004f6c7d6ab1e25dae875cdb1aee087c3ebed5b73712ed3000e3851a/rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e", size = 215110, upload-time = "2025-10-22T22:21:36.645Z" }, + { url = "https://files.pythonhosted.org/packages/6c/de/4ce8abf59674e17187023933547d2018363e8fc76ada4f1d4d22871ccb6e/rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8", size = 223850, upload-time = "2025-10-22T22:21:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/a6/34/058d0db5471c6be7bef82487ad5021ff8d1d1d27794be8730aad938649cf/rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296", size = 362344, upload-time = "2025-10-22T22:21:39.713Z" }, + { url = "https://files.pythonhosted.org/packages/5d/67/9503f0ec8c055a0782880f300c50a2b8e5e72eb1f94dfc2053da527444dd/rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27", size = 348440, upload-time = "2025-10-22T22:21:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/94223ee9b32332a41d75b6f94b37b4ce3e93878a556fc5f152cbd856a81f/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c", size = 379068, upload-time = "2025-10-22T22:21:42.593Z" }, + { url = "https://files.pythonhosted.org/packages/b4/25/54fd48f9f680cfc44e6a7f39a5fadf1d4a4a1fd0848076af4a43e79f998c/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205", size = 390518, upload-time = "2025-10-22T22:21:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/1b/85/ac258c9c27f2ccb1bd5d0697e53a82ebcf8088e3186d5d2bf8498ee7ed44/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95", size = 525319, upload-time = "2025-10-22T22:21:45.645Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/c6734774789566d46775f193964b76627cd5f42ecf246d257ce84d1912ed/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9", size = 404896, upload-time = "2025-10-22T22:21:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/14e37ce83202c632c89b0691185dca9532288ff9d390eacae3d2ff771bae/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2", size = 382862, upload-time = "2025-10-22T22:21:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/6a/83/f3642483ca971a54d60caa4449f9d6d4dbb56a53e0072d0deff51b38af74/rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0", size = 398848, upload-time = "2025-10-22T22:21:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/2d9c8b2f88e399b4cfe86efdf2935feaf0394e4f14ab30c6c5945d60af7d/rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e", size = 412030, upload-time = "2025-10-22T22:21:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/e1cec473d4bde6df1fd3738be8e82d64dd0600868e76e92dfeaebbc2d18f/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67", size = 559700, upload-time = "2025-10-22T22:21:54.123Z" }, + { url = "https://files.pythonhosted.org/packages/8d/be/73bb241c1649edbf14e98e9e78899c2c5e52bbe47cb64811f44d2cc11808/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d", size = 584581, upload-time = "2025-10-22T22:21:56.102Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/ffc6e9218cd1eb5c2c7dbd276c87cd10e8c2232c456b554169eb363381df/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6", size = 549981, upload-time = "2025-10-22T22:21:58.253Z" }, + { url = "https://files.pythonhosted.org/packages/5f/50/da8b6d33803a94df0149345ee33e5d91ed4d25fc6517de6a25587eae4133/rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c", size = 214729, upload-time = "2025-10-22T22:21:59.625Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/b0f48c4c320ee24c8c20df8b44acffb7353991ddf688af01eef5f93d7018/rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa", size = 223977, upload-time = "2025-10-22T22:22:01.092Z" }, + { url = "https://files.pythonhosted.org/packages/b4/21/c8e77a2ac66e2ec4e21f18a04b4e9a0417ecf8e61b5eaeaa9360a91713b4/rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120", size = 217326, upload-time = "2025-10-22T22:22:02.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, + { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, + { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, + { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, + { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, + { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, + { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, + { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, + { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, + { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, + { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, + { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, + { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, + { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, + { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, + { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, + { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bc/b43f2ea505f28119bd551ae75f70be0c803d2dbcd37c1b3734909e40620b/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16", size = 363913, upload-time = "2025-10-22T22:24:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/f2/db318195d324c89a2c57dc5195058cbadd71b20d220685c5bd1da79ee7fe/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d", size = 350452, upload-time = "2025-10-22T22:24:08.754Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/1391c819b8573a4898cedd6b6c5ec5bc370ce59e5d6bdcebe3c9c1db4588/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db", size = 380957, upload-time = "2025-10-22T22:24:10.826Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/e5de68ee7eb7248fce93269833d1b329a196d736aefb1a7481d1e99d1222/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7", size = 391919, upload-time = "2025-10-22T22:24:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4f/2376336112cbfeb122fd435d608ad8d5041b3aed176f85a3cb32c262eb80/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78", size = 528541, upload-time = "2025-10-22T22:24:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/68/53/5ae232e795853dd20da7225c5dd13a09c0a905b1a655e92bdf8d78a99fd9/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec", size = 405629, upload-time = "2025-10-22T22:24:16.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/351a3b852b683ca9b6b8b38ed9efb2347596973849ba6c3a0e99877c10aa/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72", size = 384123, upload-time = "2025-10-22T22:24:17.585Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/870804daa00202728cc91cb8e2385fa9f1f4eb49857c49cfce89e304eae6/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27", size = 400923, upload-time = "2025-10-22T22:24:19.512Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/3706b83c125fa2a0bccceac951de3f76631f6bd0ee4d02a0ed780712ef1b/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316", size = 413767, upload-time = "2025-10-22T22:24:21.316Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/ce43dbe62767432273ed2584cef71fef8411bddfb64125d4c19128015018/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912", size = 561530, upload-time = "2025-10-22T22:24:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/ffe77999ed8f81e30713dd38fd9ecaa161f28ec48bb80fa1cd9118399c27/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829", size = 585453, upload-time = "2025-10-22T22:24:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199, upload-time = "2025-10-22T22:24:26.54Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, +] + +[[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 = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[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.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 917d90221657b14302365a1b3df5bf636addcd9f Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 15:37:21 -0800 Subject: [PATCH 04/23] Refactor --- mcp-server/polaris_mcp/server.py | 1 + mcp-server/tests/test_server.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py index 0a8b2ff1..d5fb9064 100644 --- a/mcp-server/polaris_mcp/server.py +++ b/mcp-server/polaris_mcp/server.py @@ -355,6 +355,7 @@ def _copy_mapping( def _coerce_body(body: Any) -> Any: + """Return plain dicts for mapping objects so downstream JSON encoding succeeds.""" if isinstance(body, Mapping): return dict(body) return body diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py index 7bac3133..125c748d 100644 --- a/mcp-server/tests/test_server.py +++ b/mcp-server/tests/test_server.py @@ -90,6 +90,13 @@ def test_resolve_base_url_prefers_env_vars(self) -> None: ): self.assertEqual(server._resolve_base_url(), "https://secondary/") + with mock.patch.dict( + os.environ, + { "POLARIS_BASE_URL": " ","POLARIS_REST_BASE_URL": "https://secondary/"}, + clear=True, + ): + self.assertEqual(server._resolve_base_url(), "https://secondary/") + with mock.patch.dict(os.environ, {}, clear=True): self.assertEqual(server._resolve_base_url(), server.DEFAULT_BASE_URL) From 3ffdf8aad97a496ad6522c44f97e58a7609b9175 Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 16:19:58 -0800 Subject: [PATCH 05/23] Add url-encoding --- mcp-server/polaris_mcp/rest.py | 9 +++++++- mcp-server/polaris_mcp/tools/catalog.py | 8 +++---- mcp-server/polaris_mcp/tools/catalog_role.py | 7 +++--- mcp-server/polaris_mcp/tools/namespace.py | 10 +++------ mcp-server/polaris_mcp/tools/policy.py | 22 ++++++++----------- mcp-server/polaris_mcp/tools/principal.py | 20 ++++++++--------- .../polaris_mcp/tools/principal_role.py | 8 +++---- mcp-server/polaris_mcp/tools/table.py | 15 +++++-------- 8 files changed, 47 insertions(+), 52 deletions(-) diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py index 6db6ee2f..be4115da 100644 --- a/mcp-server/polaris_mcp/rest.py +++ b/mcp-server/polaris_mcp/rest.py @@ -24,7 +24,7 @@ import json from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit, quote import urllib3 @@ -35,6 +35,13 @@ DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0) +def encode_path_segment(value: str) -> str: + """URL-encode a string for safe use as an HTTP path component.""" + + # urllib encodes spaces as "+" by default; convert to %20 to keep literal path semantics. + return quote(value, safe="").replace("+", "%20") + + def _ensure_trailing_slash(url: str) -> str: return url if url.endswith("/") else f"{url}/" diff --git a/mcp-server/polaris_mcp/tools/catalog.py b/mcp-server/polaris_mcp/tools/catalog.py index 72fca9e7..817f70ee 100644 --- a/mcp-server/polaris_mcp/tools/catalog.py +++ b/mcp-server/polaris_mcp/tools/catalog.py @@ -27,7 +27,7 @@ from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisCatalogTool(McpTool): @@ -119,7 +119,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: delegate_args["method"] = "GET" delegate_args["path"] = "catalogs" elif normalized == "get": - catalog_name = self._require_text(arguments, "catalog") + catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) delegate_args["method"] = "GET" delegate_args["path"] = f"catalogs/{catalog_name}" elif normalized == "create": @@ -132,7 +132,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: delegate_args["path"] = "catalogs" delegate_args["body"] = copy.deepcopy(body) elif normalized == "update": - catalog_name = self._require_text(arguments, "catalog") + catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError( @@ -142,7 +142,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: delegate_args["path"] = f"catalogs/{catalog_name}" delegate_args["body"] = copy.deepcopy(body) elif normalized == "delete": - catalog_name = self._require_text(arguments, "catalog") + catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"catalogs/{catalog_name}" else: # pragma: no cover diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py b/mcp-server/polaris_mcp/tools/catalog_role.py index 57310143..d5bf29b9 100644 --- a/mcp-server/polaris_mcp/tools/catalog_role.py +++ b/mcp-server/polaris_mcp/tools/catalog_role.py @@ -23,12 +23,11 @@ import copy from typing import Any, Dict, Optional, Set - import urllib3 from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisCatalogRoleTool(McpTool): @@ -128,7 +127,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: operation = self._require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = self._require_text(arguments, "catalog") + catalog = encode_path_segment(self._require_text(arguments, "catalog")) delegate_args: JSONDict = {} self._copy_if_object(arguments.get("query"), delegate_args, "query") self._copy_if_object(arguments.get("headers"), delegate_args, "headers") @@ -177,7 +176,7 @@ def _catalog_roles_base(self, catalog: str) -> str: return f"catalogs/{catalog}/catalog-roles" def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> str: - role = self._require_text(arguments, "catalogRole") + role = encode_path_segment(self._require_text(arguments, "catalogRole")) return f"{base_path}/{role}" def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index c2e7e586..11fb7ee7 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -23,13 +23,12 @@ import copy from typing import Any, Dict, List, Optional, Set -from urllib.parse import quote import urllib3 from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisNamespaceTool(McpTool): @@ -133,7 +132,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: operation = self._require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = self._encode_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(self._require_text(arguments, "catalog")) delegate_args: JSONDict = {} self._copy_if_object(arguments.get("query"), delegate_args, "query") self._copy_if_object(arguments.get("headers"), delegate_args, "headers") @@ -270,7 +269,7 @@ def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: parts = self._resolve_namespace_array(arguments) joined = ".".join(parts) - return self._encode_segment(joined) + return encode_path_segment(joined) def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: if isinstance(source, dict): @@ -298,6 +297,3 @@ def _require_text(self, node: Dict[str, Any], field: str) -> str: if not isinstance(value, str) or not value.strip(): raise ValueError(f"Missing required field: {field}") return value.strip() - - def _encode_segment(self, value: str) -> str: - return quote(value, safe="").replace("+", "%20") diff --git a/mcp-server/polaris_mcp/tools/policy.py b/mcp-server/polaris_mcp/tools/policy.py index 14338ccd..e3c7a840 100644 --- a/mcp-server/polaris_mcp/tools/policy.py +++ b/mcp-server/polaris_mcp/tools/policy.py @@ -23,13 +23,12 @@ import copy from typing import Any, Dict, Optional, Set -from urllib.parse import quote import urllib3 from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisPolicyTool(McpTool): @@ -131,12 +130,12 @@ def call(self, arguments: Any) -> ToolExecutionResult: operation = self._require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = self._encode_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(self._require_text(arguments, "catalog")) namespace: Optional[str] = None if normalized != "applicable": - namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) elif arguments.get("namespace") is not None: - namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) delegate_args: JSONDict = {} self._copy_if_object(arguments.get("query"), delegate_args, "query") @@ -182,7 +181,7 @@ def _handle_get( catalog: str, namespace: str, ) -> None: - policy = self._encode_segment( + policy = encode_path_segment( self._require_text(arguments, "policy", "Policy name is required for get operations.") ) delegate_args["method"] = "GET" @@ -216,7 +215,7 @@ def _handle_update( raise ValueError( "Update operations require a request body that matches the UpdatePolicyRequest schema." ) - policy = self._encode_segment( + policy = encode_path_segment( self._require_text(arguments, "policy", "Policy name is required for update operations.") ) delegate_args["method"] = "PUT" @@ -230,7 +229,7 @@ def _handle_delete( catalog: str, namespace: str, ) -> None: - policy = self._encode_segment( + policy = encode_path_segment( self._require_text(arguments, "policy", "Policy name is required for delete operations.") ) delegate_args["method"] = "DELETE" @@ -248,7 +247,7 @@ def _handle_attach( raise ValueError( "Attach operations require a request body that matches the AttachPolicyRequest schema." ) - policy = self._encode_segment( + policy = encode_path_segment( self._require_text(arguments, "policy", "Policy name is required for attach operations.") ) delegate_args["method"] = "PUT" @@ -267,7 +266,7 @@ def _handle_detach( raise ValueError( "Detach operations require a request body that matches the DetachPolicyRequest schema." ) - policy = self._encode_segment( + policy = encode_path_segment( self._require_text(arguments, "policy", "Policy name is required for detach operations.") ) delegate_args["method"] = "POST" @@ -372,6 +371,3 @@ def _resolve_namespace(self, namespace: Any) -> str: if not isinstance(namespace, str) or not namespace.strip(): raise ValueError("Namespace must be a non-empty string.") return namespace.strip() - - def _encode_segment(self, value: str) -> str: - return quote(value, safe="").replace("+", "%20") diff --git a/mcp-server/polaris_mcp/tools/principal.py b/mcp-server/polaris_mcp/tools/principal.py index a94585ba..83c23373 100644 --- a/mcp-server/polaris_mcp/tools/principal.py +++ b/mcp-server/polaris_mcp/tools/principal.py @@ -28,7 +28,7 @@ from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisPrincipalTool(McpTool): @@ -177,12 +177,12 @@ def _handle_create(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> delegate_args["body"] = copy.deepcopy(body) def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}" def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError("Update principal requires a body matching UpdatePrincipalRequest.") @@ -191,29 +191,29 @@ def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> delegate_args["body"] = copy.deepcopy(body) def _handle_delete(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}" def _handle_rotate(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) delegate_args["method"] = "POST" delegate_args["path"] = f"principals/{principal}/rotate" def _handle_reset(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) delegate_args["method"] = "POST" delegate_args["path"] = f"principals/{principal}/reset" if isinstance(arguments.get("body"), dict): delegate_args["body"] = copy.deepcopy(arguments["body"]) def _handle_list_roles(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}/principal-roles" def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") + principal = encode_path_segment(self._require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError( @@ -224,8 +224,8 @@ def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict delegate_args["body"] = copy.deepcopy(body) def _handle_revoke_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = self._require_text(arguments, "principal") - role = self._require_text(arguments, "principalRole") + principal = encode_path_segment(self._require_text(arguments, "principal")) + role = encode_path_segment(self._require_text(arguments, "principalRole")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}/principal-roles/{role}" diff --git a/mcp-server/polaris_mcp/tools/principal_role.py b/mcp-server/polaris_mcp/tools/principal_role.py index 47d7cfcb..0d01ae67 100644 --- a/mcp-server/polaris_mcp/tools/principal_role.py +++ b/mcp-server/polaris_mcp/tools/principal_role.py @@ -27,7 +27,7 @@ from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisPrincipalRoleTool(McpTool): @@ -172,7 +172,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: ) elif normalized == "revoke-catalog-role": delegate_args["method"] = "DELETE" - catalog_role = self._require_text(arguments, "catalogRole") + catalog_role = encode_path_segment(self._require_text(arguments, "catalogRole")) delegate_args["path"] = ( f"{self._principal_role_catalog_path(arguments)}/{catalog_role}" ) @@ -183,11 +183,11 @@ def call(self, arguments: Any) -> ToolExecutionResult: return self._maybe_augment_error(raw, normalized) def _principal_role_path(self, arguments: Dict[str, Any]) -> str: - role = self._require_text(arguments, "principalRole") + role = encode_path_segment(self._require_text(arguments, "principalRole")) return f"principal-roles/{role}" def _principal_role_catalog_path(self, arguments: Dict[str, Any]) -> str: - catalog = self._require_text(arguments, "catalog") + catalog = encode_path_segment(self._require_text(arguments, "catalog")) return f"{self._principal_role_path(arguments)}/catalog-roles/{catalog}" def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index 865fdf50..24098ced 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -23,13 +23,12 @@ import copy from typing import Any, Dict, Optional, Set -from urllib.parse import quote import urllib3 from ..authorization import AuthorizationProvider from ..base import JSONDict, McpTool, ToolExecutionResult -from ..rest import PolarisRestTool +from ..rest import PolarisRestTool, encode_path_segment class PolarisTableTool(McpTool): @@ -126,8 +125,8 @@ def call(self, arguments: Any) -> ToolExecutionResult: operation = self._require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = self._encode_segment(self._require_text(arguments, "catalog")) - namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + catalog = encode_path_segment(self._require_text(arguments, "catalog")) + namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) delegate_args: JSONDict = {} self._copy_if_object(arguments.get("query"), delegate_args, "query") @@ -159,7 +158,7 @@ def _handle_get( catalog: str, namespace: str, ) -> None: - table = self._encode_segment( + table = encode_path_segment( self._require_text(arguments, "table", "Table name is required for get operations.") ) delegate_args["method"] = "GET" @@ -193,7 +192,7 @@ def _handle_commit( raise ValueError( "Commit operations require a request body that matches the CommitTableRequest schema." ) - table = self._encode_segment( + table = encode_path_segment( self._require_text(arguments, "table", "Table name is required for commit operations.") ) delegate_args["method"] = "POST" @@ -207,7 +206,7 @@ def _handle_delete( catalog: str, namespace: str, ) -> None: - table = self._encode_segment( + table = encode_path_segment( self._require_text(arguments, "table", "Table name is required for delete operations.") ) delegate_args["method"] = "DELETE" @@ -246,8 +245,6 @@ def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: if isinstance(source, dict): target[field] = copy.deepcopy(source) - def _encode_segment(self, value: str) -> str: - return quote(value, safe="").replace("+", "%20") def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: value = node.get(field) From f2ccbcf2e33103c560027d62cb0c2c576da9d2c7 Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 17:01:28 -0800 Subject: [PATCH 06/23] Add hint for table creation --- mcp-server/polaris_mcp/tools/table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index 24098ced..6d9b0015 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -174,7 +174,8 @@ def _handle_create( body = arguments.get("body") if not isinstance(body, dict): raise ValueError( - "Create operations require a request body that matches the CreateTableRequest schema." + "Create operations require a request body that matches the CreateTableRequest schema. See CreateTableRequest in " + "https://raw.githubusercontent.com/apache/polaris/apache-polaris-1.2.0-incubating/spec/generated/bundled-polaris-catalog-service.yaml" ) delegate_args["method"] = "POST" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" From efbda2c70cdf60dc948c33622d6b8d3d9f5cfc0f Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 17:35:29 -0800 Subject: [PATCH 07/23] Refactor --- mcp-server/polaris_mcp/base.py | 17 +++++++++ mcp-server/polaris_mcp/tools/catalog.py | 24 ++++--------- mcp-server/polaris_mcp/tools/catalog_role.py | 27 ++++---------- mcp-server/polaris_mcp/tools/namespace.py | 20 +++-------- mcp-server/polaris_mcp/tools/policy.py | 32 ++++++----------- mcp-server/polaris_mcp/tools/principal.py | 36 +++++++------------ .../polaris_mcp/tools/principal_role.py | 24 ++++--------- mcp-server/polaris_mcp/tools/table.py | 29 +++++---------- 8 files changed, 74 insertions(+), 135 deletions(-) diff --git a/mcp-server/polaris_mcp/base.py b/mcp-server/polaris_mcp/base.py index 4072e155..d8c2c6c9 100644 --- a/mcp-server/polaris_mcp/base.py +++ b/mcp-server/polaris_mcp/base.py @@ -21,6 +21,7 @@ from __future__ import annotations +import copy from dataclasses import dataclass from typing import Any, Dict, Optional, Protocol @@ -28,6 +29,22 @@ JSONDict = Dict[str, Any] +def copy_if_object(source: Any, target: Dict[str, Any], field: str) -> None: + """Deep copy dict-like values into target when present.""" + + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + +def require_text(node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: + """Return a trimmed string field, raising ValueError when missing or blank.""" + + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(message or f"Missing required field: {field}") + return value.strip() + + @dataclass(frozen=True) class ToolExecutionResult: """Structured result returned from executing an MCP tool.""" diff --git a/mcp-server/polaris_mcp/tools/catalog.py b/mcp-server/polaris_mcp/tools/catalog.py index 817f70ee..9c6f91d5 100644 --- a/mcp-server/polaris_mcp/tools/catalog.py +++ b/mcp-server/polaris_mcp/tools/catalog.py @@ -26,7 +26,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -108,18 +108,18 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": delegate_args["method"] = "GET" delegate_args["path"] = "catalogs" elif normalized == "get": - catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) + catalog_name = encode_path_segment(require_text(arguments, "catalog")) delegate_args["method"] = "GET" delegate_args["path"] = f"catalogs/{catalog_name}" elif normalized == "create": @@ -132,7 +132,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: delegate_args["path"] = "catalogs" delegate_args["body"] = copy.deepcopy(body) elif normalized == "update": - catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) + catalog_name = encode_path_segment(require_text(arguments, "catalog")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError( @@ -142,7 +142,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: delegate_args["path"] = f"catalogs/{catalog_name}" delegate_args["body"] = copy.deepcopy(body) elif normalized == "delete": - catalog_name = encode_path_segment(self._require_text(arguments, "catalog")) + catalog_name = encode_path_segment(require_text(arguments, "catalog")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"catalogs/{catalog_name}" else: # pragma: no cover @@ -192,13 +192,3 @@ def _normalize_operation(self, operation: str) -> str: if operation in self.DELETE_ALIASES: return "delete" raise ValueError(f"Unsupported operation: {operation}") - - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - def _require_text(self, node: Dict[str, Any], field: str) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Missing required field: {field}") - return value.strip() diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py b/mcp-server/polaris_mcp/tools/catalog_role.py index d5bf29b9..8a1fad82 100644 --- a/mcp-server/polaris_mcp/tools/catalog_role.py +++ b/mcp-server/polaris_mcp/tools/catalog_role.py @@ -26,7 +26,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -124,15 +124,15 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = encode_path_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(require_text(arguments, "catalog")) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") - base_path = self._catalog_roles_base(catalog) + base_path = f"catalogs/{catalog}/catalog-roles" if normalized == "list": delegate_args["method"] = "GET" @@ -172,11 +172,8 @@ def call(self, arguments: Any) -> ToolExecutionResult: raw = self._delegate.call(delegate_args) return self._maybe_augment_error(raw, normalized) - def _catalog_roles_base(self, catalog: str) -> str: - return f"catalogs/{catalog}/catalog-roles" - def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> str: - role = encode_path_segment(self._require_text(arguments, "catalogRole")) + role = encode_path_segment(require_text(arguments, "catalogRole")) return f"{base_path}/{role}" def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: @@ -232,13 +229,3 @@ def _normalize_operation(self, operation: str) -> str: if operation in self.REVOKE_GRANT_ALIASES: return "revoke-grant" raise ValueError(f"Unsupported operation: {operation}") - - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - def _require_text(self, node: Dict[str, Any], field: str) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Missing required field: {field}") - return value.strip() diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index 11fb7ee7..335861ec 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -27,7 +27,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -129,13 +129,13 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = encode_path_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(require_text(arguments, "catalog")) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": self._handle_list(delegate_args, catalog) @@ -271,10 +271,6 @@ def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: joined = ".".join(parts) return encode_path_segment(joined) - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - def _normalize_operation(self, operation: str) -> str: if operation in self.LIST_ALIASES: return "list" @@ -291,9 +287,3 @@ def _normalize_operation(self, operation: str) -> str: if operation in self.DELETE_ALIASES: return "delete" raise ValueError(f"Unsupported operation: {operation}") - - def _require_text(self, node: Dict[str, Any], field: str) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Missing required field: {field}") - return value.strip() diff --git a/mcp-server/polaris_mcp/tools/policy.py b/mcp-server/polaris_mcp/tools/policy.py index e3c7a840..9f59bfa8 100644 --- a/mcp-server/polaris_mcp/tools/policy.py +++ b/mcp-server/polaris_mcp/tools/policy.py @@ -27,7 +27,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -127,10 +127,10 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = encode_path_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(require_text(arguments, "catalog")) namespace: Optional[str] = None if normalized != "applicable": namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) @@ -138,8 +138,8 @@ def call(self, arguments: Any) -> ToolExecutionResult: namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": self._require_namespace(namespace, "list") @@ -182,7 +182,7 @@ def _handle_get( namespace: str, ) -> None: policy = encode_path_segment( - self._require_text(arguments, "policy", "Policy name is required for get operations.") + require_text(arguments, "policy", "Policy name is required for get operations.") ) delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -216,7 +216,7 @@ def _handle_update( "Update operations require a request body that matches the UpdatePolicyRequest schema." ) policy = encode_path_segment( - self._require_text(arguments, "policy", "Policy name is required for update operations.") + require_text(arguments, "policy", "Policy name is required for update operations.") ) delegate_args["method"] = "PUT" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -230,7 +230,7 @@ def _handle_delete( namespace: str, ) -> None: policy = encode_path_segment( - self._require_text(arguments, "policy", "Policy name is required for delete operations.") + require_text(arguments, "policy", "Policy name is required for delete operations.") ) delegate_args["method"] = "DELETE" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -248,7 +248,7 @@ def _handle_attach( "Attach operations require a request body that matches the AttachPolicyRequest schema." ) policy = encode_path_segment( - self._require_text(arguments, "policy", "Policy name is required for attach operations.") + require_text(arguments, "policy", "Policy name is required for attach operations.") ) delegate_args["method"] = "PUT" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" @@ -267,7 +267,7 @@ def _handle_detach( "Detach operations require a request body that matches the DetachPolicyRequest schema." ) policy = encode_path_segment( - self._require_text(arguments, "policy", "Policy name is required for detach operations.") + require_text(arguments, "policy", "Policy name is required for detach operations.") ) delegate_args["method"] = "POST" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" @@ -338,18 +338,6 @@ def _normalize_operation(self, operation: str) -> str: return "applicable" raise ValueError(f"Unsupported operation: {operation}") - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - if message is None: - message = f"Missing required field: {field}" - raise ValueError(message) - return value.strip() - def _require_namespace(self, namespace: Optional[str], operation: str) -> None: if not namespace: raise ValueError( diff --git a/mcp-server/polaris_mcp/tools/principal.py b/mcp-server/polaris_mcp/tools/principal.py index 83c23373..6db143ad 100644 --- a/mcp-server/polaris_mcp/tools/principal.py +++ b/mcp-server/polaris_mcp/tools/principal.py @@ -27,7 +27,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -131,12 +131,12 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": self._handle_list(delegate_args) @@ -177,12 +177,12 @@ def _handle_create(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> delegate_args["body"] = copy.deepcopy(body) def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}" def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError("Update principal requires a body matching UpdatePrincipalRequest.") @@ -191,29 +191,29 @@ def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> delegate_args["body"] = copy.deepcopy(body) def _handle_delete(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}" def _handle_rotate(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "POST" delegate_args["path"] = f"principals/{principal}/rotate" def _handle_reset(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "POST" delegate_args["path"] = f"principals/{principal}/reset" if isinstance(arguments.get("body"), dict): delegate_args["body"] = copy.deepcopy(arguments["body"]) def _handle_list_roles(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}/principal-roles" def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) + principal = encode_path_segment(require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): raise ValueError( @@ -224,8 +224,8 @@ def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict delegate_args["body"] = copy.deepcopy(body) def _handle_revoke_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: - principal = encode_path_segment(self._require_text(arguments, "principal")) - role = encode_path_segment(self._require_text(arguments, "principalRole")) + principal = encode_path_segment(require_text(arguments, "principal")) + role = encode_path_segment(require_text(arguments, "principalRole")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}/principal-roles/{role}" @@ -283,13 +283,3 @@ def _normalize_operation(self, operation: str) -> str: if operation in self.REVOKE_ROLE_ALIASES: return "revoke-principal-role" raise ValueError(f"Unsupported operation: {operation}") - - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - def _require_text(self, node: Dict[str, Any], field: str) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Missing required field: {field}") - return value.strip() diff --git a/mcp-server/polaris_mcp/tools/principal_role.py b/mcp-server/polaris_mcp/tools/principal_role.py index 0d01ae67..489114a5 100644 --- a/mcp-server/polaris_mcp/tools/principal_role.py +++ b/mcp-server/polaris_mcp/tools/principal_role.py @@ -26,7 +26,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -130,12 +130,12 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": delegate_args["method"] = "GET" @@ -172,7 +172,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: ) elif normalized == "revoke-catalog-role": delegate_args["method"] = "DELETE" - catalog_role = encode_path_segment(self._require_text(arguments, "catalogRole")) + catalog_role = encode_path_segment(require_text(arguments, "catalogRole")) delegate_args["path"] = ( f"{self._principal_role_catalog_path(arguments)}/{catalog_role}" ) @@ -183,11 +183,11 @@ def call(self, arguments: Any) -> ToolExecutionResult: return self._maybe_augment_error(raw, normalized) def _principal_role_path(self, arguments: Dict[str, Any]) -> str: - role = encode_path_segment(self._require_text(arguments, "principalRole")) + role = encode_path_segment(require_text(arguments, "principalRole")) return f"principal-roles/{role}" def _principal_role_catalog_path(self, arguments: Dict[str, Any]) -> str: - catalog = encode_path_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(require_text(arguments, "catalog")) return f"{self._principal_role_path(arguments)}/catalog-roles/{catalog}" def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: @@ -243,13 +243,3 @@ def _normalize_operation(self, operation: str) -> str: if operation in self.REVOKE_CATALOG_ROLE_ALIASES: return "revoke-catalog-role" raise ValueError(f"Unsupported operation: {operation}") - - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - def _require_text(self, node: Dict[str, Any], field: str) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Missing required field: {field}") - return value.strip() diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index 6d9b0015..41ca13a3 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -27,7 +27,7 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult +from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text from ..rest import PolarisRestTool, encode_path_segment @@ -122,15 +122,15 @@ def call(self, arguments: Any) -> ToolExecutionResult: if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object.") - operation = self._require_text(arguments, "operation").lower().strip() + operation = require_text(arguments, "operation").lower().strip() normalized = self._normalize_operation(operation) - catalog = encode_path_segment(self._require_text(arguments, "catalog")) + catalog = encode_path_segment(require_text(arguments, "catalog")) namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) delegate_args: JSONDict = {} - self._copy_if_object(arguments.get("query"), delegate_args, "query") - self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + copy_if_object(arguments.get("query"), delegate_args, "query") + copy_if_object(arguments.get("headers"), delegate_args, "headers") if normalized == "list": self._handle_list(delegate_args, catalog, namespace) @@ -159,7 +159,7 @@ def _handle_get( namespace: str, ) -> None: table = encode_path_segment( - self._require_text(arguments, "table", "Table name is required for get operations.") + require_text(arguments, "table", "Table name is required for get operations.") ) delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -194,7 +194,7 @@ def _handle_commit( "Commit operations require a request body that matches the CommitTableRequest schema." ) table = encode_path_segment( - self._require_text(arguments, "table", "Table name is required for commit operations.") + require_text(arguments, "table", "Table name is required for commit operations.") ) delegate_args["method"] = "POST" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -208,7 +208,7 @@ def _handle_delete( namespace: str, ) -> None: table = encode_path_segment( - self._require_text(arguments, "table", "Table name is required for delete operations.") + require_text(arguments, "table", "Table name is required for delete operations.") ) delegate_args["method"] = "DELETE" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -241,16 +241,3 @@ def _resolve_namespace(self, namespace: Any) -> str: if not isinstance(namespace, str) or not namespace.strip(): raise ValueError("Namespace must be a non-empty string.") return namespace.strip() - - def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: - if isinstance(source, dict): - target[field] = copy.deepcopy(source) - - - def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: - value = node.get(field) - if not isinstance(value, str) or not value.strip(): - if message is None: - message = f"Missing required field: {field}" - raise ValueError(message) - return value.strip() From 4a1eff45568c6fbb551d0970773c03b71d09f6fd Mon Sep 17 00:00:00 2001 From: Yufei Date: Mon, 10 Nov 2025 18:23:16 -0800 Subject: [PATCH 08/23] Add tests and fix the spliter --- mcp-server/polaris_mcp/tools/namespace.py | 29 +++- mcp-server/polaris_mcp/tools/table.py | 8 +- mcp-server/tests/test_namespace_tool.py | 84 ++++++++++ mcp-server/tests/test_table_tool.py | 184 ++++++++++++++++++++++ 4 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 mcp-server/tests/test_namespace_tool.py create mode 100644 mcp-server/tests/test_table_tool.py diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index 335861ec..d97233ea 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -22,6 +22,7 @@ from __future__ import annotations import copy +import string from typing import Any, Dict, List, Optional, Set import urllib3 @@ -38,6 +39,7 @@ class PolarisNamespaceTool(McpTool): TOOL_DESCRIPTION = ( "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." ) + NAMESPACE_DELIMITER = "\x1f" LIST_ALIASES: Set[str] = {"list"} GET_ALIASES: Set[str] = {"get", "load"} @@ -100,8 +102,8 @@ def input_schema(self) -> JSONDict: {"type": "array", "items": {"type": "string"}}, ], "description": ( - "Namespace identifier. Provide as dot-delimited string (e.g. \"analytics.daily\") " - "or array of path components." + "Namespace identifier. Provide as a string that uses the ASCII Unit Separator (0x1F) " + '(e.g. "analytics\\u001Fdaily") or as an array of path components.' ), }, "query": { @@ -258,17 +260,30 @@ def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: raise ValueError("Namespace array must contain at least one component.") parts: List[str] = [] for element in namespace: - if not isinstance(element, str) or not element.strip(): + if not isinstance(element, str): raise ValueError("Namespace array elements must be non-empty strings.") - parts.append(element.strip()) + candidate = element.strip(string.whitespace) + if not candidate: + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(candidate) return parts - if not isinstance(namespace, str) or not namespace.strip(): + if not isinstance(namespace, str): + raise ValueError("Namespace must be a non-empty string.") + trimmed = namespace.strip(string.whitespace) + if not trimmed: raise ValueError("Namespace must be a non-empty string.") - return namespace.strip().split(".") + raw_parts = trimmed.split(self.NAMESPACE_DELIMITER) + parts: List[str] = [] + for element in raw_parts: + candidate = element.strip(string.whitespace) + if not candidate: + raise ValueError("Namespace components must be non-empty strings.") + parts.append(candidate) + return parts def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: parts = self._resolve_namespace_array(arguments) - joined = ".".join(parts) + joined = self.NAMESPACE_DELIMITER.join(parts) return encode_path_segment(joined) def _normalize_operation(self, operation: str) -> str: diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index 41ca13a3..b8805c3d 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -38,6 +38,7 @@ class PolarisTableTool(McpTool): TOOL_DESCRIPTION = ( "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." ) + NAMESPACE_DELIMITER = "\x1f" LIST_ALIASES: Set[str] = {"list", "ls"} GET_ALIASES: Set[str] = {"get", "load", "fetch"} @@ -90,8 +91,9 @@ def input_schema(self) -> JSONDict: {"type": "array", "items": {"type": "string"}}, ], "description": ( - "Namespace that contains the target tables. Provide as a dot-delimited string " - '(e.g. "analytics.daily") or an array of strings.' + "Namespace that contains the target tables. Provide as a string that uses the ASCII Unit " + 'Separator (0x1F) between hierarchy levels (e.g. "analytics\\u001Fdaily") or as an array of ' + "strings." ), }, "table": { @@ -237,7 +239,7 @@ def _resolve_namespace(self, namespace: Any) -> str: if not isinstance(element, str) or not element.strip(): raise ValueError("Namespace array elements must be non-empty strings.") parts.append(element.strip()) - return ".".join(parts) + return self.NAMESPACE_DELIMITER.join(parts) if not isinstance(namespace, str) or not namespace.strip(): raise ValueError("Namespace must be a non-empty string.") return namespace.strip() diff --git a/mcp-server/tests/test_namespace_tool.py b/mcp-server/tests/test_namespace_tool.py new file mode 100644 index 00000000..5ca36341 --- /dev/null +++ b/mcp-server/tests/test_namespace_tool.py @@ -0,0 +1,84 @@ +"""Unit tests for ``polaris_mcp.tools.namespace``.""" + +from __future__ import annotations + +import unittest +from unittest import mock + +from polaris_mcp.base import ToolExecutionResult +from polaris_mcp.tools.namespace import PolarisNamespaceTool + + +class PolarisNamespaceToolTest(unittest.TestCase): + def _build_tool(self, mock_rest: mock.Mock) -> tuple[PolarisNamespaceTool, mock.Mock]: + delegate = mock.Mock() + delegate.call.return_value = ToolExecutionResult(text="done", is_error=False) + mock_rest.return_value = delegate + tool = PolarisNamespaceTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) + return tool, delegate + + @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") + def test_get_operation_encodes_namespace_with_unit_separator(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + + tool.call( + { + "operation": "get", + "catalog": "prod", + "namespace": [" analytics", "daily "], + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "GET") + self.assertEqual(payload["path"], "prod/namespaces/analytics%1Fdaily") + + @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") + def test_create_operation_infers_namespace_array_from_string(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + body = {"properties": {"owner": "analytics"}} + + tool.call( + { + "operation": "create", + "catalog": "prod", + "namespace": "analytics\x1fdaily", + "body": body, + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "POST") + self.assertEqual(payload["path"], "prod/namespaces") + self.assertEqual(payload["body"]["namespace"], ["analytics", "daily"]) + self.assertIsNot(payload["body"], body) + self.assertIsNot(payload["body"]["properties"], body["properties"]) + body["properties"]["owner"] = "changed" + self.assertEqual(payload["body"]["properties"]["owner"], "analytics") + + @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") + def test_namespace_string_with_empty_component_is_rejected(self, mock_rest: mock.Mock) -> None: + tool, _ = self._build_tool(mock_rest) + + with self.assertRaisesRegex(ValueError, "Namespace components must be non-empty strings"): + tool.call( + { + "operation": "get", + "catalog": "prod", + "namespace": "analytics\x1f ", + } + ) + with self.assertRaisesRegex(ValueError, "Namespace components must be non-empty strings"): + tool.call( + { + "operation": "get", + "catalog": "prod", + "namespace": " analytics\x1f", + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/mcp-server/tests/test_table_tool.py b/mcp-server/tests/test_table_tool.py new file mode 100644 index 00000000..b1bf9af0 --- /dev/null +++ b/mcp-server/tests/test_table_tool.py @@ -0,0 +1,184 @@ +"""Unit tests for ``polaris_mcp.tools.table``.""" + +from __future__ import annotations + +import unittest +from unittest import mock + +from polaris_mcp.base import ToolExecutionResult +from polaris_mcp.tools.table import PolarisTableTool + + +class PolarisTableToolOperationsTest(unittest.TestCase): + def _build_tool(self, mock_rest: mock.Mock) -> tuple[PolarisTableTool, mock.Mock]: + delegate = mock.Mock() + delegate.call.return_value = ToolExecutionResult(text="ok", is_error=False, metadata={"k": "v"}) + mock_rest.return_value = delegate + tool = PolarisTableTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) + return tool, delegate + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_list_operation_uses_get_and_copies_query_and_headers(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + arguments = { + "operation": "LS", + "catalog": "prod west", + "namespace": [" analytics", "daily "], + "query": {"page-size": "200"}, + "headers": {"Prefer": "return=representation"}, + } + + result = tool.call(arguments) + + self.assertIs(result, delegate.call.return_value) + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "GET") + self.assertEqual(payload["path"], "prod%20west/namespaces/analytics%1Fdaily/tables") + self.assertEqual(payload["query"], {"page-size": "200"}) + self.assertIsNot(payload["query"], arguments["query"]) + self.assertEqual(payload["headers"], {"Prefer": "return=representation"}) + self.assertIsNot(payload["headers"], arguments["headers"]) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_get_operation_accepts_alias_and_encodes_table(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + arguments = { + "operation": "fetch", + "catalog": "prod", + "namespace": [" core ", "sales"], + "table": "Daily Metrics", + } + + tool.call(arguments) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "GET") + self.assertEqual(payload["path"], "prod/namespaces/core%1Fsales/tables/Daily%20Metrics") + self.assertNotIn("body", payload) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_get_operation_requires_table_argument(self, mock_rest: mock.Mock) -> None: + tool, _ = self._build_tool(mock_rest) + + with self.assertRaisesRegex(ValueError, "Table name is required"): + tool.call({"operation": "get", "catalog": "prod", "namespace": "analytics"}) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_create_operation_deep_copies_request_body(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + body = {"table": "t1", "properties": {"schema-id": 1}} + tool.call( + { + "operation": "create", + "catalog": "prod", + "namespace": "analytics", + "body": body, + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "POST") + self.assertEqual(payload["path"], "prod/namespaces/analytics/tables") + self.assertEqual(payload["body"], {"table": "t1", "properties": {"schema-id": 1}}) + self.assertIsNot(payload["body"], body) + self.assertIsNot(payload["body"]["properties"], body["properties"]) + + body["properties"]["schema-id"] = 99 + self.assertEqual(payload["body"]["properties"]["schema-id"], 1) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_create_operation_requires_body(self, mock_rest: mock.Mock) -> None: + tool, _ = self._build_tool(mock_rest) + + with self.assertRaisesRegex(ValueError, "Create operations require"): + tool.call({"operation": "create", "catalog": "prod", "namespace": "analytics"}) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_commit_operation_requires_table_and_body(self, mock_rest: mock.Mock) -> None: + tool, _ = self._build_tool(mock_rest) + + with self.assertRaisesRegex(ValueError, "Table name is required"): + tool.call( + { + "operation": "commit", + "catalog": "prod", + "namespace": "analytics", + "body": {"changes": []}, + } + ) + + with self.assertRaisesRegex(ValueError, "Commit operations require"): + tool.call( + { + "operation": "commit", + "catalog": "prod", + "namespace": "analytics", + "table": "t1", + } + ) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_commit_operation_post_request_with_body_copy(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + body = {"changes": [{"type": "append", "snapshot-id": 5}]} + + tool.call( + { + "operation": "update", + "catalog": "prod", + "namespace": "analytics", + "table": "metrics", + "body": body, + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "POST") + self.assertEqual(payload["path"], "prod/namespaces/analytics/tables/metrics") + self.assertEqual( + payload["body"], {"changes": [{"type": "append", "snapshot-id": 5}]} + ) + self.assertIsNot(payload["body"], body) + self.assertIsNot(payload["body"]["changes"], body["changes"]) + + body["changes"][0]["snapshot-id"] = 42 + self.assertEqual(payload["body"]["changes"][0]["snapshot-id"], 5) + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_delete_operation_uses_alias_and_encodes_table(self, mock_rest: mock.Mock) -> None: + tool, delegate = self._build_tool(mock_rest) + + tool.call( + { + "operation": "drop", + "catalog": "prod", + "namespace": "analytics", + "table": "fact daily", + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + self.assertEqual(payload["method"], "DELETE") + self.assertEqual(payload["path"], "prod/namespaces/analytics/tables/fact%20daily") + + @mock.patch("polaris_mcp.tools.table.PolarisRestTool") + def test_namespace_validation_rejects_blank_values(self, mock_rest: mock.Mock) -> None: + tool, _ = self._build_tool(mock_rest) + + with self.assertRaisesRegex(ValueError, "Namespace must be provided"): + tool.call({"operation": "list", "catalog": "prod", "namespace": None}) + + with self.assertRaisesRegex(ValueError, "Namespace array must contain"): + tool.call({"operation": "list", "catalog": "prod", "namespace": []}) + + with self.assertRaisesRegex(ValueError, "Namespace array elements"): + tool.call({"operation": "list", "catalog": "prod", "namespace": ["ok", " "]}) + + +if __name__ == "__main__": + unittest.main() From 8daad6ddc7b734f3c43915921d985de4ab838dfa Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 15:07:51 -0800 Subject: [PATCH 09/23] A clear message to construct nested namespaces --- mcp-server/polaris_mcp/base.py | 1 + mcp-server/polaris_mcp/tools/namespace.py | 25 +++++++-------- mcp-server/polaris_mcp/tools/table.py | 39 ++++++++++++++--------- mcp-server/tests/test_namespace_tool.py | 24 +------------- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/mcp-server/polaris_mcp/base.py b/mcp-server/polaris_mcp/base.py index d8c2c6c9..7faf4009 100644 --- a/mcp-server/polaris_mcp/base.py +++ b/mcp-server/polaris_mcp/base.py @@ -28,6 +28,7 @@ JSONDict = Dict[str, Any] +NAMESPACE_PATH_DELIMITER = "\x1f" def copy_if_object(source: Any, target: Dict[str, Any], field: str) -> None: """Deep copy dict-like values into target when present.""" diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index d97233ea..ff6d9be8 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -28,7 +28,14 @@ import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from ..base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, + NAMESPACE_PATH_DELIMITER, +) from ..rest import PolarisRestTool, encode_path_segment @@ -39,7 +46,6 @@ class PolarisNamespaceTool(McpTool): TOOL_DESCRIPTION = ( "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." ) - NAMESPACE_DELIMITER = "\x1f" LIST_ALIASES: Set[str] = {"list"} GET_ALIASES: Set[str] = {"get", "load"} @@ -102,8 +108,8 @@ def input_schema(self) -> JSONDict: {"type": "array", "items": {"type": "string"}}, ], "description": ( - "Namespace identifier. Provide as a string that uses the ASCII Unit Separator (0x1F) " - '(e.g. "analytics\\u001Fdaily") or as an array of path components.' + "Namespace identifier. Provide as a string for a single namespace " + 'or an array of strings (e.g. ["analytics", "daily"]) for nested namespaces.' ), }, "query": { @@ -272,18 +278,11 @@ def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: trimmed = namespace.strip(string.whitespace) if not trimmed: raise ValueError("Namespace must be a non-empty string.") - raw_parts = trimmed.split(self.NAMESPACE_DELIMITER) - parts: List[str] = [] - for element in raw_parts: - candidate = element.strip(string.whitespace) - if not candidate: - raise ValueError("Namespace components must be non-empty strings.") - parts.append(candidate) - return parts + return namespace.strip().split(".") def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: parts = self._resolve_namespace_array(arguments) - joined = self.NAMESPACE_DELIMITER.join(parts) + joined = NAMESPACE_PATH_DELIMITER.join(parts) return encode_path_segment(joined) def _normalize_operation(self, operation: str) -> str: diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index b8805c3d..103c48cc 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -22,12 +22,20 @@ from __future__ import annotations import copy -from typing import Any, Dict, Optional, Set +import string +from typing import Any, Dict, List, Set import urllib3 from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from ..base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, + NAMESPACE_PATH_DELIMITER, +) from ..rest import PolarisRestTool, encode_path_segment @@ -38,8 +46,6 @@ class PolarisTableTool(McpTool): TOOL_DESCRIPTION = ( "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." ) - NAMESPACE_DELIMITER = "\x1f" - LIST_ALIASES: Set[str] = {"list", "ls"} GET_ALIASES: Set[str] = {"get", "load", "fetch"} CREATE_ALIASES: Set[str] = {"create"} @@ -91,9 +97,8 @@ def input_schema(self) -> JSONDict: {"type": "array", "items": {"type": "string"}}, ], "description": ( - "Namespace that contains the target tables. Provide as a string that uses the ASCII Unit " - 'Separator (0x1F) between hierarchy levels (e.g. "analytics\\u001Fdaily") or as an array of ' - "strings." + "Namespace that contains the target tables. Provide as a string for a single namespace " + 'or an array of strings (e.g. ["analytics", "daily"]) for nested namespaces.' ), }, "table": { @@ -128,7 +133,8 @@ def call(self, arguments: Any) -> ToolExecutionResult: normalized = self._normalize_operation(operation) catalog = encode_path_segment(require_text(arguments, "catalog")) - namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) + namespace_parts = self._resolve_namespace(arguments.get("namespace")) + namespace = encode_path_segment(NAMESPACE_PATH_DELIMITER.join(namespace_parts)) delegate_args: JSONDict = {} copy_if_object(arguments.get("query"), delegate_args, "query") @@ -228,18 +234,21 @@ def _normalize_operation(self, operation: str) -> str: return "delete" raise ValueError(f"Unsupported operation: {operation}") - def _resolve_namespace(self, namespace: Any) -> str: + def _resolve_namespace(self, namespace: Any) -> List[str]: if namespace is None: raise ValueError("Namespace must be provided.") if isinstance(namespace, list): if not namespace: raise ValueError("Namespace array must contain at least one element.") - parts = [] + parts: List[str] = [] for element in namespace: - if not isinstance(element, str) or not element.strip(): + if not isinstance(element, str): + raise ValueError("Namespace array elements must be non-empty strings.") + candidate = element.strip(string.whitespace) + if not candidate: raise ValueError("Namespace array elements must be non-empty strings.") - parts.append(element.strip()) - return self.NAMESPACE_DELIMITER.join(parts) - if not isinstance(namespace, str) or not namespace.strip(): + parts.append(candidate) + return parts + if not isinstance(namespace, str): raise ValueError("Namespace must be a non-empty string.") - return namespace.strip() + return [namespace.strip()] diff --git a/mcp-server/tests/test_namespace_tool.py b/mcp-server/tests/test_namespace_tool.py index 5ca36341..7618f946 100644 --- a/mcp-server/tests/test_namespace_tool.py +++ b/mcp-server/tests/test_namespace_tool.py @@ -43,7 +43,7 @@ def test_create_operation_infers_namespace_array_from_string(self, mock_rest: mo { "operation": "create", "catalog": "prod", - "namespace": "analytics\x1fdaily", + "namespace": "analytics.daily", "body": body, } ) @@ -58,27 +58,5 @@ def test_create_operation_infers_namespace_array_from_string(self, mock_rest: mo body["properties"]["owner"] = "changed" self.assertEqual(payload["body"]["properties"]["owner"], "analytics") - @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") - def test_namespace_string_with_empty_component_is_rejected(self, mock_rest: mock.Mock) -> None: - tool, _ = self._build_tool(mock_rest) - - with self.assertRaisesRegex(ValueError, "Namespace components must be non-empty strings"): - tool.call( - { - "operation": "get", - "catalog": "prod", - "namespace": "analytics\x1f ", - } - ) - with self.assertRaisesRegex(ValueError, "Namespace components must be non-empty strings"): - tool.call( - { - "operation": "get", - "catalog": "prod", - "namespace": " analytics\x1f", - } - ) - - if __name__ == "__main__": unittest.main() From eb5effa4c88b7f774957bab86a9b40a2a65b6117 Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 15:34:24 -0800 Subject: [PATCH 10/23] Allow MCP client to specify namespaces separated by dot --- mcp-server/polaris_mcp/tools/table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index 103c48cc..cad61488 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -251,4 +251,4 @@ def _resolve_namespace(self, namespace: Any) -> List[str]: return parts if not isinstance(namespace, str): raise ValueError("Namespace must be a non-empty string.") - return [namespace.strip()] + return namespace.strip().split(".") From 240ad8b6d1032d4933834742d484dfb0760fdb84 Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 15:39:18 -0800 Subject: [PATCH 11/23] Resovle comments --- mcp-server/README.md | 2 +- mcp-server/pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index dc772d52..17a804f0 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -17,7 +17,7 @@ under the License. --> -# Apache Polaris MCP Server (Python) +# Apache Polaris MCP Server This package provides a Python implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for Apache Polaris. It wraps the Polaris REST APIs so MCP-compatible clients (IDEs, agents, chat applications) can issue structured requests via JSON-RPC on stdin/stdout. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index 817cb37f..904e2968 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -19,10 +19,10 @@ [project] name = "polaris-mcp" -version = "1.2.0" +version = "0.9.0" description = "Apache Polaris Model Context Protocol server" authors = [ - {name = "Apache Software Foundation", email = "dev@polaris.apache.org"} + {name = "Apache Polaris Community", email = "dev@polaris.apache.org"} ] readme = "README.md" requires-python = ">=3.10,<4.0" @@ -38,7 +38,7 @@ polaris-mcp = "polaris_mcp.server:main" [project.urls] homepage = "https://polaris.apache.org/" -repository = "https://github.com/apache/polaris/" +repository = "https://github.com/apache/polaris-tools/" [build-system] requires = ["setuptools>=68.0"] From 76ffac4c407749a05e5873bf8e6a080dfb0f8ff8 Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 15:46:25 -0800 Subject: [PATCH 12/23] Resovle comments --- mcp-server/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index 17a804f0..2405d17e 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -23,12 +23,16 @@ This package provides a Python implementation of the [Model Context Protocol (MC The implementation is built on top of [FastMCP](https://gofastmcp.com) for streamlined server registration and transport handling. +## Prerequisites +- Python 3.10 or later +- [uv](https://docs.astral.sh/uv/) + ## Installation From the repository root: ```bash -cd client/python-mcp +cd mcp-server uv sync ``` @@ -67,7 +71,7 @@ For a `tools/call` invocation you will typically set environment variables such "command": "uv", "args": [ "--directory", - "/path/to/polaris/client/python-mcp", + "/path/to/polaris-tools/mcp-server", "run", "polaris-mcp" ], From 8f6b0394d2ab35cc358d9fb26cdd7f523667b945 Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 15:58:14 -0800 Subject: [PATCH 13/23] Resolve comments --- mcp-server/README.md | 35 +++-------------------------------- mcp-server/uv.lock | 2 +- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index 2405d17e..3f6125f7 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -27,38 +27,9 @@ The implementation is built on top of [FastMCP](https://gofastmcp.com) for strea - Python 3.10 or later - [uv](https://docs.astral.sh/uv/) -## Installation - -From the repository root: - -```bash -cd mcp-server -uv sync -``` - -## Running - -Launch the MCP server (which reads from stdin and writes to stdout): - -```bash -uv run polaris-mcp -``` - -## Testing - -Install dependencies (one-time) and run the Python unit tests with `uv` so the locked environment is used: - -```bash -uv sync -uv run python -m unittest discover -s tests -``` - -Example interaction: - -```json -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"manual","version":"0"}}} -{"jsonrpc":"2.0","id":2,"method":"tools/list"} -``` +## Building and Running +- `cd mcp-server && uv sync` - to build +- `uv run python -m unittest discover -s tests` - to test For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock index 528073ed..bc88e91f 100644 --- a/mcp-server/uv.lock +++ b/mcp-server/uv.lock @@ -709,7 +709,7 @@ wheels = [ [[package]] name = "polaris-mcp" -version = "1.2.0" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, From c7e4f98f8690931b664a85b194755b061a378cab Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 16:10:59 -0800 Subject: [PATCH 14/23] Resolve comments --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index a893541d..2ff0a241 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,10 @@ version.txt # Python venv venv/ +mcp-server/polaris_mcp.egg-info/ +mcp-server/polaris_mcp/__pycache__/ +mcp-server/polaris_mcp/tools/__pycache__/ +mcp-server/tests/__pycache__/ # Maven flatten plugin .flattened-pom.xml From c5d75dab8d5501cd2001554b23f7d18933a8250f Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 18:07:26 -0800 Subject: [PATCH 15/23] Resolve comments --- README.md | 1 + mcp-server/polaris_mcp/rest.py | 16 ++- mcp-server/tests/test_rest_tool.py | 160 +++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 mcp-server/tests/test_rest_tool.py diff --git a/README.md b/README.md index dedd892e..e36fdbb5 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,4 @@ There are three tools: 1. [Benchmarks](/benchmarks/README.md): Performance benchmarks for Polaris. 2. [Iceberg Catalog Migrator](/iceberg-catalog-migrator/README.md): A command-line tool to migrate Iceberg tables from one Iceberg catalog to another. 3. [Polaris Synchronizer](/polaris-synchronizer/README.md): A tool to migrate entities from one Polaris instance to another. +4. [Polaris MCP Server](/mcp-server/README.md): A Polaris MCP server implementation. diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py index be4115da..738ae1fc 100644 --- a/mcp-server/polaris_mcp/rest.py +++ b/mcp-server/polaris_mcp/rest.py @@ -22,7 +22,6 @@ from __future__ import annotations import json -from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit, quote @@ -266,7 +265,7 @@ def call(self, arguments: Any) -> ToolExecutionResult: "request": { "method": method, "url": target_uri, - "headers": dict(header_values), + "headers": self._sanitize_headers(dict(header_values)), }, "response": { "status": response.status, @@ -297,6 +296,19 @@ def _require_path(self, args: Dict[str, Any]) -> str: raise ValueError("The 'path' argument must be provided and must not be empty.") return path.strip() + @staticmethod + def _sanitize_headers(headers: Dict[str, str]) -> Dict[str, str]: + sanitized = {} + sensitive_headers = {"authorization", "x-api-key", "cookie", "set-cookie"} + + for key, value in headers.items(): + if key.lower() in sensitive_headers: + sanitized[key] = "[REDACTED]" + else: + sanitized[key] = value + + return sanitized + def _resolve_target_uri(self, path: str, query: Optional[Dict[str, Any]]) -> str: if path.startswith(("http://", "https://")): target = path diff --git a/mcp-server/tests/test_rest_tool.py b/mcp-server/tests/test_rest_tool.py new file mode 100644 index 00000000..3bd44c92 --- /dev/null +++ b/mcp-server/tests/test_rest_tool.py @@ -0,0 +1,160 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +"""Unit tests for ``polaris_mcp.rest``.""" + +from __future__ import annotations + +from types import SimpleNamespace +import unittest +from unittest import mock + +from urllib3._collections import HTTPHeaderDict + +from polaris_mcp.rest import DEFAULT_TIMEOUT, PolarisRestTool + + +def _build_response(status: int, body: str, headers: dict[str, object] | None = None) -> SimpleNamespace: + """Return a lightweight stub with the attributes accessed by PolarisRestTool.""" + + header_dict = HTTPHeaderDict() + if headers: + for key, value in headers.items(): + if isinstance(value, (list, tuple)): + for item in value: + header_dict.add(key, item) + else: + header_dict.add(key, value) + return SimpleNamespace(status=status, data=body.encode("utf-8"), headers=header_dict) + + +class PolarisRestToolCallTests(unittest.TestCase): + def _create_tool(self) -> tuple[PolarisRestTool, mock.Mock, mock.Mock]: + http = mock.Mock() + auth = mock.Mock() + auth.authorization_header.return_value = "Bearer provided" + tool = PolarisRestTool( + name="test", + description="desc", + base_url="https://example.test/", + default_path_prefix="api/catalog/v1/", + http=http, + authorization_provider=auth, + ) + return tool, http, auth + + def test_call_builds_request_and_metadata_with_json_body(self) -> None: + tool, http, auth = self._create_tool() + http.request.return_value = _build_response( + status=201, + body='{"result":"ok"}', + headers={"Content-Type": "application/json", "X-Request-Id": "abc123"}, + ) + + result = tool.call( + { + "method": "post", + "path": "namespaces", + "query": {"page-size": "200", "tag": ["blue", "green"]}, + "headers": {"Authorization": "Bearer user"}, + "body": {"name": "analytics"}, + } + ) + + expected_url = "https://example.test/api/catalog/v1/namespaces?page-size=200&tag=blue&tag=green" + expected_headers = { + "Accept": "application/json", + "Authorization": "Bearer user", + "Content-Type": "application/json", + } + + http.request.assert_called_once_with( + "POST", + expected_url, + body=b'{"name": "analytics"}', + headers=expected_headers, + timeout=DEFAULT_TIMEOUT, + ) + auth.authorization_header.assert_not_called() + + self.assertFalse(result.is_error) + self.assertIn("POST " + expected_url, result.text) + self.assertIn('"result": "ok"', result.text) + self.assertEqual(result.metadata["method"], "POST") + self.assertEqual(result.metadata["url"], expected_url) + self.assertEqual(result.metadata["status"], 201) + self.assertEqual(result.metadata["request"]["body"], {"name": "analytics"}) + self.assertEqual(result.metadata["response"]["body"], {"result": "ok"}) + self.assertEqual(result.metadata["response"]["headers"]["X-Request-Id"], "abc123") + self.assertEqual(result.metadata["request"]["headers"]["Authorization"], "[REDACTED]") + + def test_call_uses_authorization_provider_and_handles_plain_text(self) -> None: + tool, http, auth = self._create_tool() + auth.authorization_header.return_value = "Bearer dynamic" + http.request.return_value = _build_response( + status=404, + body="failure", + headers={"X-Trace": ["abc", "def"]}, + ) + + result = tool.call( + { + "path": "https://override.test/api", + "query": {"q": "one"}, + "headers": {"X-Custom": "42"}, + "body": "payload", + } + ) + + expected_url = "https://override.test/api?q=one" + expected_headers = { + "Accept": "application/json", + "X-Custom": "42", + "Authorization": "Bearer dynamic", + "Content-Type": "application/json", + } + + http.request.assert_called_once_with( + "GET", + expected_url, + body=b"payload", + headers=expected_headers, + timeout=DEFAULT_TIMEOUT, + ) + auth.authorization_header.assert_called_once() + + self.assertTrue(result.is_error) + self.assertIn("Status: 404", result.text) + self.assertEqual(result.metadata["url"], expected_url) + self.assertEqual(result.metadata["request"]["bodyText"], "payload") + self.assertEqual(result.metadata["response"]["bodyText"], "failure") + self.assertEqual(result.metadata["response"]["headers"]["X-Trace"], "abc, def") + self.assertEqual(result.metadata["request"]["headers"]["Authorization"], "[REDACTED]") + + def test_call_requires_non_empty_path(self) -> None: + tool, http, _ = self._create_tool() + + with self.assertRaisesRegex(ValueError, "path.*must not be empty"): + tool.call({"method": "GET"}) + + http.request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From 0a239b7ecbce6d79dfa95e37b7512f73e2467192 Mon Sep 17 00:00:00 2001 From: Yufei Date: Wed, 12 Nov 2025 18:13:36 -0800 Subject: [PATCH 16/23] Resolve comments --- mcp-server/polaris_mcp/server.py | 15 ++++++++++++--- mcp-server/tests/test_server.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py index d5fb9064..af335a10 100644 --- a/mcp-server/polaris_mcp/server.py +++ b/mcp-server/polaris_mcp/server.py @@ -23,7 +23,7 @@ import os from typing import Any, Mapping, MutableMapping, Sequence -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse import urllib3 from fastmcp import FastMCP @@ -373,8 +373,17 @@ def _resolve_base_url() -> str: os.getenv("POLARIS_REST_BASE_URL"), ): if candidate and candidate.strip(): - return candidate.strip() - return DEFAULT_BASE_URL + return _validate_base_url(candidate.strip()) + return _validate_base_url(DEFAULT_BASE_URL) + + +def _validate_base_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in ("http", "https"): + raise ValueError("Polaris base URL must use http or https.") + if not parsed.netloc: + raise ValueError("Polaris base URL must include a hostname.") + return value def _resolve_authorization_provider( diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py index 125c748d..cf3924e9 100644 --- a/mcp-server/tests/test_server.py +++ b/mcp-server/tests/test_server.py @@ -100,6 +100,23 @@ def test_resolve_base_url_prefers_env_vars(self) -> None: with mock.patch.dict(os.environ, {}, clear=True): self.assertEqual(server._resolve_base_url(), server.DEFAULT_BASE_URL) + def test_resolve_base_url_validates_scheme_and_host(self) -> None: + with mock.patch.dict( + os.environ, + {"POLARIS_BASE_URL": "ftp://legacy"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "http or https"): + server._resolve_base_url() + + with mock.patch.dict( + os.environ, + {"POLARIS_BASE_URL": "localhost:8181"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "Polaris base URL must use http or https."): + server._resolve_base_url() + def test_first_non_blank_returns_first_usable_value(self) -> None: self.assertEqual(server._first_non_blank(None, " ", "\tvalue", "later"), "value") self.assertIsNone(server._first_non_blank(None)) From 5743e93959b65c9abc43051a86909b8310a3f3c2 Mon Sep 17 00:00:00 2001 From: Yufei Date: Thu, 13 Nov 2025 22:14:28 -0800 Subject: [PATCH 17/23] Resolve comments --- mcp-server/polaris_mcp/tools/catalog.py | 6 +++--- mcp-server/polaris_mcp/tools/catalog_role.py | 6 +++--- mcp-server/polaris_mcp/tools/namespace.py | 6 +++--- mcp-server/polaris_mcp/tools/policy.py | 6 +++--- mcp-server/polaris_mcp/tools/principal.py | 6 +++--- mcp-server/polaris_mcp/tools/principal_role.py | 6 +++--- mcp-server/polaris_mcp/tools/table.py | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/mcp-server/polaris_mcp/tools/catalog.py b/mcp-server/polaris_mcp/tools/catalog.py index 9c6f91d5..eb834dd3 100644 --- a/mcp-server/polaris_mcp/tools/catalog.py +++ b/mcp-server/polaris_mcp/tools/catalog.py @@ -25,9 +25,9 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisCatalogTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py b/mcp-server/polaris_mcp/tools/catalog_role.py index 8a1fad82..bce94e92 100644 --- a/mcp-server/polaris_mcp/tools/catalog_role.py +++ b/mcp-server/polaris_mcp/tools/catalog_role.py @@ -25,9 +25,9 @@ from typing import Any, Dict, Optional, Set import urllib3 -from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisCatalogRoleTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index ff6d9be8..0d90834f 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -27,8 +27,8 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import ( +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import ( JSONDict, McpTool, ToolExecutionResult, @@ -36,7 +36,7 @@ require_text, NAMESPACE_PATH_DELIMITER, ) -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisNamespaceTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/policy.py b/mcp-server/polaris_mcp/tools/policy.py index 9f59bfa8..1fbdf7e0 100644 --- a/mcp-server/polaris_mcp/tools/policy.py +++ b/mcp-server/polaris_mcp/tools/policy.py @@ -26,9 +26,9 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisPolicyTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/principal.py b/mcp-server/polaris_mcp/tools/principal.py index 6db143ad..61dfb286 100644 --- a/mcp-server/polaris_mcp/tools/principal.py +++ b/mcp-server/polaris_mcp/tools/principal.py @@ -26,9 +26,9 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisPrincipalTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/principal_role.py b/mcp-server/polaris_mcp/tools/principal_role.py index 489114a5..db937df6 100644 --- a/mcp-server/polaris_mcp/tools/principal_role.py +++ b/mcp-server/polaris_mcp/tools/principal_role.py @@ -25,9 +25,9 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisPrincipalRoleTool(McpTool): diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index cad61488..e13e5f4c 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -27,8 +27,8 @@ import urllib3 -from ..authorization import AuthorizationProvider -from ..base import ( +from polaris_mcp.authorization import AuthorizationProvider +from polaris_mcp.base import ( JSONDict, McpTool, ToolExecutionResult, @@ -36,7 +36,7 @@ require_text, NAMESPACE_PATH_DELIMITER, ) -from ..rest import PolarisRestTool, encode_path_segment +from polaris_mcp.rest import PolarisRestTool, encode_path_segment class PolarisTableTool(McpTool): From c2741f60b89a42579d690a2ecfa678c7168f4e93 Mon Sep 17 00:00:00 2001 From: Yufei Date: Thu, 13 Nov 2025 22:35:18 -0800 Subject: [PATCH 18/23] Use pytest --- mcp-server/README.md | 8 +- mcp-server/pyproject.toml | 5 + mcp-server/tests/test_namespace_tool.py | 101 ++++----- mcp-server/tests/test_rest_tool.py | 226 +++++++++---------- mcp-server/tests/test_server.py | 107 ++++----- mcp-server/tests/test_table_tool.py | 288 ++++++++++++------------ mcp-server/uv.lock | 52 +++++ 7 files changed, 412 insertions(+), 375 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index 3f6125f7..e3515f50 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -25,11 +25,13 @@ The implementation is built on top of [FastMCP](https://gofastmcp.com) for strea ## Prerequisites - Python 3.10 or later -- [uv](https://docs.astral.sh/uv/) +- [uv](https://docs.astral.sh/uv/) 0.9.7 or later ## Building and Running -- `cd mcp-server && uv sync` - to build -- `uv run python -m unittest discover -s tests` - to test +- `cd mcp-server && uv sync` - install runtime dependencies +- `uv run polaris-mcp` - start the MCP server (stdin/stdout transport) +- `uv sync --extra test` - install runtime + test dependencies +- `uv run pytest` - run the test suite For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index 904e2968..ac4587ac 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -33,6 +33,11 @@ dependencies = [ "urllib3>=1.25.3,<3.0.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8.2", +] + [project.scripts] polaris-mcp = "polaris_mcp.server:main" diff --git a/mcp-server/tests/test_namespace_tool.py b/mcp-server/tests/test_namespace_tool.py index 7618f946..70c1c9cb 100644 --- a/mcp-server/tests/test_namespace_tool.py +++ b/mcp-server/tests/test_namespace_tool.py @@ -2,61 +2,58 @@ from __future__ import annotations -import unittest from unittest import mock from polaris_mcp.base import ToolExecutionResult from polaris_mcp.tools.namespace import PolarisNamespaceTool -class PolarisNamespaceToolTest(unittest.TestCase): - def _build_tool(self, mock_rest: mock.Mock) -> tuple[PolarisNamespaceTool, mock.Mock]: - delegate = mock.Mock() - delegate.call.return_value = ToolExecutionResult(text="done", is_error=False) - mock_rest.return_value = delegate - tool = PolarisNamespaceTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) - return tool, delegate - - @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") - def test_get_operation_encodes_namespace_with_unit_separator(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - - tool.call( - { - "operation": "get", - "catalog": "prod", - "namespace": [" analytics", "daily "], - } - ) - - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "GET") - self.assertEqual(payload["path"], "prod/namespaces/analytics%1Fdaily") - - @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") - def test_create_operation_infers_namespace_array_from_string(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - body = {"properties": {"owner": "analytics"}} - - tool.call( - { - "operation": "create", - "catalog": "prod", - "namespace": "analytics.daily", - "body": body, - } - ) - - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "POST") - self.assertEqual(payload["path"], "prod/namespaces") - self.assertEqual(payload["body"]["namespace"], ["analytics", "daily"]) - self.assertIsNot(payload["body"], body) - self.assertIsNot(payload["body"]["properties"], body["properties"]) - body["properties"]["owner"] = "changed" - self.assertEqual(payload["body"]["properties"]["owner"], "analytics") - -if __name__ == "__main__": - unittest.main() +def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisNamespaceTool, mock.Mock]: + delegate = mock.Mock() + delegate.call.return_value = ToolExecutionResult(text="done", is_error=False) + mock_rest.return_value = delegate + tool = PolarisNamespaceTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) + return tool, delegate + + +@mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") +def test_get_operation_encodes_namespace_with_unit_separator(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + + tool.call( + { + "operation": "get", + "catalog": "prod", + "namespace": [" analytics", "daily "], + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "GET" + assert payload["path"] == "prod/namespaces/analytics%1Fdaily" + + +@mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") +def test_create_operation_infers_namespace_array_from_string(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + body = {"properties": {"owner": "analytics"}} + + tool.call( + { + "operation": "create", + "catalog": "prod", + "namespace": "analytics.daily", + "body": body, + } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "POST" + assert payload["path"] == "prod/namespaces" + assert payload["body"]["namespace"] == ["analytics", "daily"] + assert payload["body"] is not body + assert payload["body"]["properties"] is not body["properties"] + body["properties"]["owner"] = "changed" + assert payload["body"]["properties"]["owner"] == "analytics" diff --git a/mcp-server/tests/test_rest_tool.py b/mcp-server/tests/test_rest_tool.py index 3bd44c92..6d5bd74c 100644 --- a/mcp-server/tests/test_rest_tool.py +++ b/mcp-server/tests/test_rest_tool.py @@ -22,9 +22,9 @@ from __future__ import annotations from types import SimpleNamespace -import unittest from unittest import mock +import pytest from urllib3._collections import HTTPHeaderDict from polaris_mcp.rest import DEFAULT_TIMEOUT, PolarisRestTool @@ -44,117 +44,117 @@ def _build_response(status: int, body: str, headers: dict[str, object] | None = return SimpleNamespace(status=status, data=body.encode("utf-8"), headers=header_dict) -class PolarisRestToolCallTests(unittest.TestCase): - def _create_tool(self) -> tuple[PolarisRestTool, mock.Mock, mock.Mock]: - http = mock.Mock() - auth = mock.Mock() - auth.authorization_header.return_value = "Bearer provided" - tool = PolarisRestTool( - name="test", - description="desc", - base_url="https://example.test/", - default_path_prefix="api/catalog/v1/", - http=http, - authorization_provider=auth, - ) - return tool, http, auth - - def test_call_builds_request_and_metadata_with_json_body(self) -> None: - tool, http, auth = self._create_tool() - http.request.return_value = _build_response( - status=201, - body='{"result":"ok"}', - headers={"Content-Type": "application/json", "X-Request-Id": "abc123"}, - ) - - result = tool.call( - { - "method": "post", - "path": "namespaces", - "query": {"page-size": "200", "tag": ["blue", "green"]}, - "headers": {"Authorization": "Bearer user"}, - "body": {"name": "analytics"}, - } - ) - - expected_url = "https://example.test/api/catalog/v1/namespaces?page-size=200&tag=blue&tag=green" - expected_headers = { - "Accept": "application/json", - "Authorization": "Bearer user", - "Content-Type": "application/json", +def _create_tool() -> tuple[PolarisRestTool, mock.Mock, mock.Mock]: + http = mock.Mock() + auth = mock.Mock() + auth.authorization_header.return_value = "Bearer provided" + tool = PolarisRestTool( + name="test", + description="desc", + base_url="https://example.test/", + default_path_prefix="api/catalog/v1/", + http=http, + authorization_provider=auth, + ) + return tool, http, auth + + +def test_call_builds_request_and_metadata_with_json_body() -> None: + tool, http, auth = _create_tool() + http.request.return_value = _build_response( + status=201, + body='{"result":"ok"}', + headers={"Content-Type": "application/json", "X-Request-Id": "abc123"}, + ) + + result = tool.call( + { + "method": "post", + "path": "namespaces", + "query": {"page-size": "200", "tag": ["blue", "green"]}, + "headers": {"Prefer": ["return-minimal", "respond-async"], "Authorization": "Bearer user"}, + "body": {"name": "analytics"}, } - - http.request.assert_called_once_with( - "POST", - expected_url, - body=b'{"name": "analytics"}', - headers=expected_headers, - timeout=DEFAULT_TIMEOUT, - ) - auth.authorization_header.assert_not_called() - - self.assertFalse(result.is_error) - self.assertIn("POST " + expected_url, result.text) - self.assertIn('"result": "ok"', result.text) - self.assertEqual(result.metadata["method"], "POST") - self.assertEqual(result.metadata["url"], expected_url) - self.assertEqual(result.metadata["status"], 201) - self.assertEqual(result.metadata["request"]["body"], {"name": "analytics"}) - self.assertEqual(result.metadata["response"]["body"], {"result": "ok"}) - self.assertEqual(result.metadata["response"]["headers"]["X-Request-Id"], "abc123") - self.assertEqual(result.metadata["request"]["headers"]["Authorization"], "[REDACTED]") - - def test_call_uses_authorization_provider_and_handles_plain_text(self) -> None: - tool, http, auth = self._create_tool() - auth.authorization_header.return_value = "Bearer dynamic" - http.request.return_value = _build_response( - status=404, - body="failure", - headers={"X-Trace": ["abc", "def"]}, - ) - - result = tool.call( - { - "path": "https://override.test/api", - "query": {"q": "one"}, - "headers": {"X-Custom": "42"}, - "body": "payload", - } - ) - - expected_url = "https://override.test/api?q=one" - expected_headers = { - "Accept": "application/json", - "X-Custom": "42", - "Authorization": "Bearer dynamic", - "Content-Type": "application/json", + ) + + expected_url = "https://example.test/api/catalog/v1/namespaces?page-size=200&tag=blue&tag=green" + expected_headers = { + "Accept": "application/json", + "Prefer": "return-minimal, respond-async", + "Authorization": "Bearer user", + "Content-Type": "application/json", + } + + http.request.assert_called_once_with( + "POST", + expected_url, + body=b'{"name": "analytics"}', + headers=expected_headers, + timeout=DEFAULT_TIMEOUT, + ) + auth.authorization_header.assert_not_called() + + assert not result.is_error + assert f"POST {expected_url}" in result.text + assert '"result": "ok"' in result.text + assert result.metadata["method"] == "POST" + assert result.metadata["url"] == expected_url + assert result.metadata["status"] == 201 + assert result.metadata["request"]["body"] == {"name": "analytics"} + assert result.metadata["response"]["body"] == {"result": "ok"} + assert result.metadata["response"]["headers"]["X-Request-Id"] == "abc123" + assert result.metadata["request"]["headers"]["Authorization"] == "[REDACTED]" + assert result.metadata["request"]["headers"]["Prefer"] == "return-minimal, respond-async" + + +def test_call_uses_authorization_provider_and_handles_plain_text() -> None: + tool, http, auth = _create_tool() + auth.authorization_header.return_value = "Bearer dynamic" + http.request.return_value = _build_response( + status=404, + body="failure", + headers={"X-Trace": ["abc", "def"]}, + ) + + result = tool.call( + { + "path": "https://override.test/api", + "query": {"q": "one"}, + "headers": {"X-Custom": "42"}, + "body": "payload", } - - http.request.assert_called_once_with( - "GET", - expected_url, - body=b"payload", - headers=expected_headers, - timeout=DEFAULT_TIMEOUT, - ) - auth.authorization_header.assert_called_once() - - self.assertTrue(result.is_error) - self.assertIn("Status: 404", result.text) - self.assertEqual(result.metadata["url"], expected_url) - self.assertEqual(result.metadata["request"]["bodyText"], "payload") - self.assertEqual(result.metadata["response"]["bodyText"], "failure") - self.assertEqual(result.metadata["response"]["headers"]["X-Trace"], "abc, def") - self.assertEqual(result.metadata["request"]["headers"]["Authorization"], "[REDACTED]") - - def test_call_requires_non_empty_path(self) -> None: - tool, http, _ = self._create_tool() - - with self.assertRaisesRegex(ValueError, "path.*must not be empty"): - tool.call({"method": "GET"}) - - http.request.assert_not_called() - - -if __name__ == "__main__": - unittest.main() + ) + + expected_url = "https://override.test/api?q=one" + expected_headers = { + "Accept": "application/json", + "X-Custom": "42", + "Authorization": "Bearer dynamic", + "Content-Type": "application/json", + } + + http.request.assert_called_once_with( + "GET", + expected_url, + body=b"payload", + headers=expected_headers, + timeout=DEFAULT_TIMEOUT, + ) + auth.authorization_header.assert_called_once() + + assert result.is_error + assert "Status: 404" in result.text + assert result.metadata["url"] == expected_url + assert result.metadata["request"]["bodyText"] == "payload" + assert result.metadata["response"]["bodyText"] == "failure" + assert result.metadata["response"]["headers"]["X-Trace"] == "abc, def" + assert result.metadata["request"]["headers"]["Authorization"] == "[REDACTED]" + + +def test_call_requires_non_empty_path() -> None: + tool, http, _ = _create_tool() + + with pytest.raises(ValueError, match="path.*must not be empty"): + tool.call({"method": "GET"}) + + http.request.assert_not_called() diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py index cf3924e9..8728965e 100644 --- a/mcp-server/tests/test_server.py +++ b/mcp-server/tests/test_server.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -import unittest +import pytest from collections import UserDict from importlib import metadata from unittest import mock @@ -12,7 +12,7 @@ from polaris_mcp.base import ToolExecutionResult -class ServerHelpersTest(unittest.TestCase): +class TestServerHelpers: def test_call_tool_merges_arguments_and_applies_transforms(self) -> None: captured: dict[str, object] = {} @@ -38,39 +38,33 @@ def call(self, arguments: dict[str, object]) -> ToolExecutionResult: }, ) - self.assertIs(result, sentinel) - self.assertEqual( - captured["arguments"], - { - "operation": "GET", - "catalog": "prod", - "namespace": ["db", "1"], - "query": {"limit": 10}, - }, - ) + assert result is sentinel + assert captured["arguments"] == { + "operation": "GET", + "catalog": "prod", + "namespace": ["db", "1"], + "query": {"limit": 10}, + } mock_to_result.assert_called_once() tool_result_arg = mock_to_result.call_args.args[0] - self.assertIsInstance(tool_result_arg, ToolExecutionResult) - self.assertEqual(tool_result_arg.text, "done") + assert isinstance(tool_result_arg, ToolExecutionResult) + assert tool_result_arg.text == "done" def test_copy_mapping_filters_none_and_normalizes_sequences(self) -> None: source = {"a": "keep", "b": None, "c": ["one", 2], "d": ("x", 3)} copied = server._copy_mapping(source) - self.assertEqual( - copied, - { - "a": "keep", - "c": ["one", "2"], - "d": ["x", "3"], - }, - ) - self.assertIsNot(copied, source) - self.assertIsNone(server._copy_mapping(None)) + assert copied == { + "a": "keep", + "c": ["one", "2"], + "d": ["x", "3"], + } + assert copied is not source + assert server._copy_mapping(None) is None def test_normalize_namespace_accepts_text_and_sequences(self) -> None: - self.assertEqual(server._normalize_namespace("analytics"), "analytics") - self.assertEqual(server._normalize_namespace(("db", 23)), ["db", "23"]) + assert server._normalize_namespace("analytics") == "analytics" + assert server._normalize_namespace(("db", 23)) == ["db", "23"] def test_resolve_base_url_prefers_env_vars(self) -> None: with mock.patch.dict( @@ -81,24 +75,24 @@ def test_resolve_base_url_prefers_env_vars(self) -> None: }, clear=True, ): - self.assertEqual(server._resolve_base_url(), "https://primary/") + assert server._resolve_base_url() == "https://primary/" with mock.patch.dict( os.environ, {"POLARIS_REST_BASE_URL": "https://secondary/"}, clear=True, ): - self.assertEqual(server._resolve_base_url(), "https://secondary/") + assert server._resolve_base_url() == "https://secondary/" with mock.patch.dict( - os.environ, - { "POLARIS_BASE_URL": " ","POLARIS_REST_BASE_URL": "https://secondary/"}, - clear=True, + os.environ, + {"POLARIS_BASE_URL": " ", "POLARIS_REST_BASE_URL": "https://secondary/"}, + clear=True, ): - self.assertEqual(server._resolve_base_url(), "https://secondary/") + assert server._resolve_base_url() == "https://secondary/" with mock.patch.dict(os.environ, {}, clear=True): - self.assertEqual(server._resolve_base_url(), server.DEFAULT_BASE_URL) + assert server._resolve_base_url() == server.DEFAULT_BASE_URL def test_resolve_base_url_validates_scheme_and_host(self) -> None: with mock.patch.dict( @@ -106,20 +100,20 @@ def test_resolve_base_url_validates_scheme_and_host(self) -> None: {"POLARIS_BASE_URL": "ftp://legacy"}, clear=True, ): - with self.assertRaisesRegex(ValueError, "http or https"): + with pytest.raises(ValueError, match="http or https"): server._resolve_base_url() with mock.patch.dict( os.environ, - {"POLARIS_BASE_URL": "localhost:8181"}, + {"POLARIS_BASE_URL": "http://"}, clear=True, ): - with self.assertRaisesRegex(ValueError, "Polaris base URL must use http or https."): + with pytest.raises(ValueError, match="hostname"): server._resolve_base_url() def test_first_non_blank_returns_first_usable_value(self) -> None: - self.assertEqual(server._first_non_blank(None, " ", "\tvalue", "later"), "value") - self.assertIsNone(server._first_non_blank(None)) + assert server._first_non_blank(None, " ", "\tvalue", "later") == "value" + assert server._first_non_blank(None) is None def test_resolve_token_checks_multiple_env_variables(self) -> None: with mock.patch.dict( @@ -131,13 +125,13 @@ def test_resolve_token_checks_multiple_env_variables(self) -> None: }, clear=True, ): - self.assertEqual(server._resolve_token(), "token-b") + assert server._resolve_token() == "token-b" def test_coerce_body_returns_plain_dict_for_mappings(self) -> None: user_dict = UserDict({"a": 1}) - self.assertEqual(server._coerce_body(user_dict), {"a": 1}) + assert server._coerce_body(user_dict) == {"a": 1} sequence = [1, 2] - self.assertIs(server._coerce_body(sequence), sequence) + assert server._coerce_body(sequence) is sequence def test_to_tool_result_builds_fastmcp_payload_with_metadata(self) -> None: execution = ToolExecutionResult(text="ok", is_error=True, metadata={"foo": "bar"}) @@ -150,7 +144,7 @@ def test_to_tool_result_builds_fastmcp_payload_with_metadata(self) -> None: ) as mock_result: output = server._to_tool_result(execution) - self.assertIs(output, fast_instance) + assert output is fast_instance mock_text.assert_called_once_with(type="text", text="ok") mock_result.assert_called_once_with( content=[text_instance], @@ -166,19 +160,19 @@ def test_to_tool_result_omits_meta_when_not_provided(self) -> None: mock_text.assert_called_once_with(type="text", text="hello") structured = mock_result.call_args.kwargs["structured_content"] - self.assertEqual(structured, {"isError": False}) + assert structured == {"isError": False} def test_resolve_package_version_uses_metadata_and_handles_missing(self) -> None: with mock.patch("polaris_mcp.server.metadata.version", return_value="2.0.0"): - self.assertEqual(server._resolve_package_version(), "2.0.0") + assert server._resolve_package_version() == "2.0.0" with mock.patch( "polaris_mcp.server.metadata.version", side_effect=metadata.PackageNotFoundError ): - self.assertEqual(server._resolve_package_version(), "dev") + assert server._resolve_package_version() == "dev" -class AuthorizationProviderResolutionTest(unittest.TestCase): +class TestAuthorizationProviderResolution: def test_resolve_authorization_provider_uses_token_when_available(self) -> None: fake_http = object() with mock.patch("polaris_mcp.server._resolve_token", return_value="abc"), mock.patch.dict( @@ -186,8 +180,8 @@ def test_resolve_authorization_provider_uses_token_when_available(self) -> None: ): provider = server._resolve_authorization_provider("https://base/", fake_http) - self.assertIsInstance(provider, server.StaticAuthorizationProvider) - self.assertEqual(provider.authorization_header(), "Bearer abc") + assert isinstance(provider, server.StaticAuthorizationProvider) + assert provider.authorization_header() == "Bearer abc" def test_resolve_authorization_provider_uses_client_credentials(self) -> None: fake_http = object() @@ -206,7 +200,7 @@ def test_resolve_authorization_provider_uses_client_credentials(self) -> None: ) as mock_factory: provider = server._resolve_authorization_provider("https://base/", fake_http) - self.assertIs(provider, fake_provider) + assert provider is fake_provider mock_factory.assert_called_once_with( token_endpoint="https://oauth/token", client_id="client", @@ -214,18 +208,3 @@ def test_resolve_authorization_provider_uses_client_credentials(self) -> None: scope="scope", http=fake_http, ) - - def test_resolve_authorization_provider_falls_back_to_none(self) -> None: - fake_http = object() - sentinel = object() - with mock.patch("polaris_mcp.server._resolve_token", return_value=None), mock.patch.dict( - os.environ, {}, clear=True - ), mock.patch("polaris_mcp.server.none", return_value=sentinel) as mock_none: - provider = server._resolve_authorization_provider("https://base/", fake_http) - - self.assertIs(provider, sentinel) - mock_none.assert_called_once_with() - - -if __name__ == "__main__": - unittest.main() diff --git a/mcp-server/tests/test_table_tool.py b/mcp-server/tests/test_table_tool.py index b1bf9af0..762b8ebe 100644 --- a/mcp-server/tests/test_table_tool.py +++ b/mcp-server/tests/test_table_tool.py @@ -2,183 +2,185 @@ from __future__ import annotations -import unittest +import pytest from unittest import mock from polaris_mcp.base import ToolExecutionResult from polaris_mcp.tools.table import PolarisTableTool -class PolarisTableToolOperationsTest(unittest.TestCase): - def _build_tool(self, mock_rest: mock.Mock) -> tuple[PolarisTableTool, mock.Mock]: - delegate = mock.Mock() - delegate.call.return_value = ToolExecutionResult(text="ok", is_error=False, metadata={"k": "v"}) - mock_rest.return_value = delegate - tool = PolarisTableTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) - return tool, delegate - - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_list_operation_uses_get_and_copies_query_and_headers(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - arguments = { - "operation": "LS", - "catalog": "prod west", - "namespace": [" analytics", "daily "], - "query": {"page-size": "200"}, - "headers": {"Prefer": "return=representation"}, - } - - result = tool.call(arguments) - - self.assertIs(result, delegate.call.return_value) - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "GET") - self.assertEqual(payload["path"], "prod%20west/namespaces/analytics%1Fdaily/tables") - self.assertEqual(payload["query"], {"page-size": "200"}) - self.assertIsNot(payload["query"], arguments["query"]) - self.assertEqual(payload["headers"], {"Prefer": "return=representation"}) - self.assertIsNot(payload["headers"], arguments["headers"]) - - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_get_operation_accepts_alias_and_encodes_table(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - arguments = { - "operation": "fetch", +def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisTableTool, mock.Mock]: + delegate = mock.Mock() + delegate.call.return_value = ToolExecutionResult(text="ok", is_error=False, metadata={"k": "v"}) + mock_rest.return_value = delegate + tool = PolarisTableTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) + return tool, delegate + + +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_list_operation_uses_get_and_copies_query_and_headers(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + arguments = { + "operation": "LS", + "catalog": "prod west", + "namespace": [" analytics", "daily "], + "query": {"page-size": "200"}, + "headers": {"Prefer": "return=representation"}, + } + + result = tool.call(arguments) + + assert result is delegate.call.return_value + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "GET" + assert payload["path"] == "prod%20west/namespaces/analytics%1Fdaily/tables" + assert payload["query"] == {"page-size": "200"} + assert payload["query"] is not arguments["query"] + assert payload["headers"] == {"Prefer": "return=representation"} + assert payload["headers"] is not arguments["headers"] + + +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_get_operation_accepts_alias_and_encodes_table(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + arguments = { + "operation": "fetch", + "catalog": "prod", + "namespace": [" core ", "sales"], + "table": "Daily Metrics", + } + + tool.call(arguments) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "GET" + assert payload["path"] == "prod/namespaces/core%1Fsales/tables/Daily%20Metrics" + assert "body" not in payload + + +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_get_operation_requires_table_argument(mock_rest: mock.Mock) -> None: + tool, _ = _build_tool(mock_rest) + + with pytest.raises(ValueError, match="Table name is required"): + tool.call({"operation": "get", "catalog": "prod", "namespace": "analytics"}) + + +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_create_operation_deep_copies_request_body(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + body = {"table": "t1", "properties": {"schema-id": 1}} + tool.call( + { + "operation": "create", "catalog": "prod", - "namespace": [" core ", "sales"], - "table": "Daily Metrics", + "namespace": "analytics", + "body": body, } + ) + + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "POST" + assert payload["path"] == "prod/namespaces/analytics/tables" + assert payload["body"] == {"table": "t1", "properties": {"schema-id": 1}} + assert payload["body"] is not body + assert payload["body"]["properties"] is not body["properties"] + + body["properties"]["schema-id"] = 99 + assert payload["body"]["properties"]["schema-id"] == 1 + - tool.call(arguments) +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_create_operation_requires_body(mock_rest: mock.Mock) -> None: + tool, _ = _build_tool(mock_rest) - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "GET") - self.assertEqual(payload["path"], "prod/namespaces/core%1Fsales/tables/Daily%20Metrics") - self.assertNotIn("body", payload) + with pytest.raises(ValueError, match="Create operations require"): + tool.call({"operation": "create", "catalog": "prod", "namespace": "analytics"}) - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_get_operation_requires_table_argument(self, mock_rest: mock.Mock) -> None: - tool, _ = self._build_tool(mock_rest) - with self.assertRaisesRegex(ValueError, "Table name is required"): - tool.call({"operation": "get", "catalog": "prod", "namespace": "analytics"}) +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_commit_operation_requires_table_and_body(mock_rest: mock.Mock) -> None: + tool, _ = _build_tool(mock_rest) - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_create_operation_deep_copies_request_body(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - body = {"table": "t1", "properties": {"schema-id": 1}} + with pytest.raises(ValueError, match="Table name is required"): tool.call( { - "operation": "create", + "operation": "commit", "catalog": "prod", "namespace": "analytics", - "body": body, + "body": {"changes": []}, } ) - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "POST") - self.assertEqual(payload["path"], "prod/namespaces/analytics/tables") - self.assertEqual(payload["body"], {"table": "t1", "properties": {"schema-id": 1}}) - self.assertIsNot(payload["body"], body) - self.assertIsNot(payload["body"]["properties"], body["properties"]) - - body["properties"]["schema-id"] = 99 - self.assertEqual(payload["body"]["properties"]["schema-id"], 1) - - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_create_operation_requires_body(self, mock_rest: mock.Mock) -> None: - tool, _ = self._build_tool(mock_rest) - - with self.assertRaisesRegex(ValueError, "Create operations require"): - tool.call({"operation": "create", "catalog": "prod", "namespace": "analytics"}) - - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_commit_operation_requires_table_and_body(self, mock_rest: mock.Mock) -> None: - tool, _ = self._build_tool(mock_rest) - - with self.assertRaisesRegex(ValueError, "Table name is required"): - tool.call( - { - "operation": "commit", - "catalog": "prod", - "namespace": "analytics", - "body": {"changes": []}, - } - ) - - with self.assertRaisesRegex(ValueError, "Commit operations require"): - tool.call( - { - "operation": "commit", - "catalog": "prod", - "namespace": "analytics", - "table": "t1", - } - ) - - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_commit_operation_post_request_with_body_copy(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) - body = {"changes": [{"type": "append", "snapshot-id": 5}]} - + with pytest.raises(ValueError, match="Commit operations require"): tool.call( { - "operation": "update", + "operation": "commit", "catalog": "prod", "namespace": "analytics", - "table": "metrics", - "body": body, + "table": "t1", } ) - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "POST") - self.assertEqual(payload["path"], "prod/namespaces/analytics/tables/metrics") - self.assertEqual( - payload["body"], {"changes": [{"type": "append", "snapshot-id": 5}]} - ) - self.assertIsNot(payload["body"], body) - self.assertIsNot(payload["body"]["changes"], body["changes"]) - body["changes"][0]["snapshot-id"] = 42 - self.assertEqual(payload["body"]["changes"][0]["snapshot-id"], 5) +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_commit_operation_post_request_with_body_copy(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + body = {"changes": [{"type": "append", "snapshot-id": 5}]} - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_delete_operation_uses_alias_and_encodes_table(self, mock_rest: mock.Mock) -> None: - tool, delegate = self._build_tool(mock_rest) + tool.call( + { + "operation": "update", + "catalog": "prod", + "namespace": "analytics", + "table": "metrics", + "body": body, + } + ) - tool.call( - { - "operation": "drop", - "catalog": "prod", - "namespace": "analytics", - "table": "fact daily", - } - ) + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "POST" + assert payload["path"] == "prod/namespaces/analytics/tables/metrics" + assert payload["body"] == {"changes": [{"type": "append", "snapshot-id": 5}]} + assert payload["body"] is not body + assert payload["body"]["changes"] is not body["changes"] - delegate.call.assert_called_once() - payload = delegate.call.call_args.args[0] - self.assertEqual(payload["method"], "DELETE") - self.assertEqual(payload["path"], "prod/namespaces/analytics/tables/fact%20daily") + body["changes"][0]["snapshot-id"] = 42 + assert payload["body"]["changes"][0]["snapshot-id"] == 5 + + +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_delete_operation_uses_alias_and_encodes_table(mock_rest: mock.Mock) -> None: + tool, delegate = _build_tool(mock_rest) + + tool.call( + { + "operation": "drop", + "catalog": "prod", + "namespace": "analytics", + "table": "fact daily", + } + ) - @mock.patch("polaris_mcp.tools.table.PolarisRestTool") - def test_namespace_validation_rejects_blank_values(self, mock_rest: mock.Mock) -> None: - tool, _ = self._build_tool(mock_rest) + delegate.call.assert_called_once() + payload = delegate.call.call_args.args[0] + assert payload["method"] == "DELETE" + assert payload["path"] == "prod/namespaces/analytics/tables/fact%20daily" - with self.assertRaisesRegex(ValueError, "Namespace must be provided"): - tool.call({"operation": "list", "catalog": "prod", "namespace": None}) - with self.assertRaisesRegex(ValueError, "Namespace array must contain"): - tool.call({"operation": "list", "catalog": "prod", "namespace": []}) +@mock.patch("polaris_mcp.tools.table.PolarisRestTool") +def test_namespace_validation_rejects_blank_values(mock_rest: mock.Mock) -> None: + tool, _ = _build_tool(mock_rest) - with self.assertRaisesRegex(ValueError, "Namespace array elements"): - tool.call({"operation": "list", "catalog": "prod", "namespace": ["ok", " "]}) + with pytest.raises(ValueError, match="Namespace must be provided"): + tool.call({"operation": "list", "catalog": "prod", "namespace": None}) + with pytest.raises(ValueError, match="Namespace array must contain"): + tool.call({"operation": "list", "catalog": "prod", "namespace": []}) -if __name__ == "__main__": - unittest.main() + with pytest.raises(ValueError, match="Namespace array elements"): + tool.call({"operation": "list", "catalog": "prod", "namespace": ["ok", " "]}) diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock index bc88e91f..cd9575c7 100644 --- a/mcp-server/uv.lock +++ b/mcp-server/uv.lock @@ -510,6 +510,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] +[[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 = "jaraco-classes" version = "3.4.0" @@ -680,6 +689,15 @@ 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 = "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 = "pathable" version = "0.4.4" @@ -707,6 +725,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[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 = "polaris-mcp" version = "0.9.0" @@ -716,11 +743,18 @@ dependencies = [ { name = "urllib3" }, ] +[package.optional-dependencies] +test = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=2.13.0.2" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.2" }, { name = "urllib3", specifier = ">=1.25.3,<3.0.0" }, ] +provides-extras = ["test"] [[package]] name = "py-key-value-aio" @@ -953,6 +987,24 @@ 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 = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" From 24cc759409453e6b2695f1e75b15dfc3e8ba9a14 Mon Sep 17 00:00:00 2001 From: Yufei Date: Thu, 13 Nov 2025 23:08:45 -0800 Subject: [PATCH 19/23] Add lint --- mcp-server/.pre-commit-config.yaml | 37 +++++++++ mcp-server/README.md | 4 +- mcp-server/polaris_mcp/authorization.py | 3 +- mcp-server/polaris_mcp/base.py | 5 +- mcp-server/polaris_mcp/rest.py | 22 ++++-- mcp-server/polaris_mcp/server.py | 4 +- mcp-server/polaris_mcp/tools/catalog.py | 14 +++- mcp-server/polaris_mcp/tools/catalog_role.py | 53 +++++++++---- mcp-server/polaris_mcp/tools/namespace.py | 26 +++++-- mcp-server/polaris_mcp/tools/policy.py | 77 ++++++++++++++----- mcp-server/polaris_mcp/tools/principal.py | 56 ++++++++++---- .../polaris_mcp/tools/principal_role.py | 39 +++++++--- mcp-server/polaris_mcp/tools/table.py | 28 ++++--- mcp-server/tests/test_namespace_tool.py | 12 ++- mcp-server/tests/test_rest_tool.py | 18 ++++- mcp-server/tests/test_server.py | 77 ++++++++++++------- mcp-server/tests/test_table_tool.py | 8 +- 17 files changed, 351 insertions(+), 132 deletions(-) create mode 100644 mcp-server/.pre-commit-config.yaml diff --git a/mcp-server/.pre-commit-config.yaml b/mcp-server/.pre-commit-config.yaml new file mode 100644 index 00000000..30051568 --- /dev/null +++ b/mcp-server/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + files: ^mcp-server/ + - id: end-of-file-fixer + files: ^mcp-server/ + - id: debug-statements + files: ^mcp-server/ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.1 + hooks: + # Run the linter. + - id: ruff-check + files: ^mcp-server/ + args: [ --fix, --exit-non-zero-on-fix ] + # Run the formatter. + - id: ruff-format + files: ^mcp-server/ diff --git a/mcp-server/README.md b/mcp-server/README.md index e3515f50..abcfe013 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -25,9 +25,9 @@ The implementation is built on top of [FastMCP](https://gofastmcp.com) for strea ## Prerequisites - Python 3.10 or later -- [uv](https://docs.astral.sh/uv/) 0.9.7 or later +- [uv](https://docs.astral.sh/uv/) 0.9.7 or later -## Building and Running +## Building and Running - `cd mcp-server && uv sync` - install runtime dependencies - `uv run polaris-mcp` - start the MCP server (stdin/stdout transport) - `uv sync --extra test` - install runtime + test dependencies diff --git a/mcp-server/polaris_mcp/authorization.py b/mcp-server/polaris_mcp/authorization.py index 2955d332..e58c8c87 100644 --- a/mcp-server/polaris_mcp/authorization.py +++ b/mcp-server/polaris_mcp/authorization.py @@ -35,8 +35,7 @@ class AuthorizationProvider(ABC): """Return Authorization header values for outgoing requests.""" @abstractmethod - def authorization_header(self) -> Optional[str]: - ... + def authorization_header(self) -> Optional[str]: ... class StaticAuthorizationProvider(AuthorizationProvider): diff --git a/mcp-server/polaris_mcp/base.py b/mcp-server/polaris_mcp/base.py index 7faf4009..686969d1 100644 --- a/mcp-server/polaris_mcp/base.py +++ b/mcp-server/polaris_mcp/base.py @@ -30,6 +30,7 @@ NAMESPACE_PATH_DELIMITER = "\x1f" + def copy_if_object(source: Any, target: Dict[str, Any], field: str) -> None: """Deep copy dict-like values into target when present.""" @@ -37,7 +38,9 @@ def copy_if_object(source: Any, target: Dict[str, Any], field: str) -> None: target[field] = copy.deepcopy(source) -def require_text(node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: +def require_text( + node: Dict[str, Any], field: str, message: Optional[str] = None +) -> str: """Return a trimmed string field, raising ValueError when missing or blank.""" value = node.get(field) diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py index 738ae1fc..e7628401 100644 --- a/mcp-server/polaris_mcp/rest.py +++ b/mcp-server/polaris_mcp/rest.py @@ -110,7 +110,9 @@ def _append_query(url: str, params: List[Tuple[str, str]]) -> str: query = "&".join([existing] + extra_parts) if extra_parts else existing else: query = "&".join(extra_parts) - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment) + ) def _build_query(parameters: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]: @@ -190,7 +192,10 @@ def input_schema(self) -> JSONDict: "Optional query string parameters. Values can be strings or arrays of strings." ), "additionalProperties": { - "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ] }, }, "headers": { @@ -200,7 +205,10 @@ def input_schema(self) -> JSONDict: "automatically when omitted." ), "additionalProperties": { - "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ] }, }, "body": { @@ -236,7 +244,9 @@ def call(self, arguments: Any) -> ToolExecutionResult: header_values["Authorization"] = token body_text = _serialize_body(body_node) - if body_text is not None and not any(name.lower() == "content-type" for name in header_values): + if body_text is not None and not any( + name.lower() == "content-type" for name in header_values + ): header_values["Content-Type"] = "application/json" response = self._http.request( @@ -293,7 +303,9 @@ def call(self, arguments: Any) -> ToolExecutionResult: def _require_path(self, args: Dict[str, Any]) -> str: path = args.get("path") if not isinstance(path, str) or not path.strip(): - raise ValueError("The 'path' argument must be provided and must not be empty.") + raise ValueError( + "The 'path' argument must be provided and must not be empty." + ) return path.strip() @staticmethod diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py index af335a10..600806b3 100644 --- a/mcp-server/polaris_mcp/server.py +++ b/mcp-server/polaris_mcp/server.py @@ -70,7 +70,9 @@ def create_server() -> FastMCP: table_tool = PolarisTableTool(base_url, http, authorization_provider) namespace_tool = PolarisNamespaceTool(base_url, http, authorization_provider) principal_tool = PolarisPrincipalTool(base_url, http, authorization_provider) - principal_role_tool = PolarisPrincipalRoleTool(base_url, http, authorization_provider) + principal_role_tool = PolarisPrincipalRoleTool( + base_url, http, authorization_provider + ) catalog_role_tool = PolarisCatalogRoleTool(base_url, http, authorization_provider) policy_tool = PolarisPolicyTool(base_url, http, authorization_provider) catalog_tool = PolarisCatalogTool(base_url, http, authorization_provider) diff --git a/mcp-server/polaris_mcp/tools/catalog.py b/mcp-server/polaris_mcp/tools/catalog.py index eb834dd3..0f7280a5 100644 --- a/mcp-server/polaris_mcp/tools/catalog.py +++ b/mcp-server/polaris_mcp/tools/catalog.py @@ -21,12 +21,18 @@ from __future__ import annotations import copy -from typing import Any, Dict, Optional, Set +from typing import Any, Optional, Set import urllib3 from polaris_mcp.authorization import AuthorizationProvider -from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, +) from polaris_mcp.rest import PolarisRestTool, encode_path_segment @@ -151,7 +157,9 @@ def call(self, arguments: Any) -> ToolExecutionResult: raw = self._delegate.call(delegate_args) return self._maybe_augment_error(raw, normalized) - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py b/mcp-server/polaris_mcp/tools/catalog_role.py index bce94e92..2ec9064c 100644 --- a/mcp-server/polaris_mcp/tools/catalog_role.py +++ b/mcp-server/polaris_mcp/tools/catalog_role.py @@ -26,7 +26,13 @@ import urllib3 from polaris_mcp.authorization import AuthorizationProvider -from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, +) from polaris_mcp.rest import PolarisRestTool, encode_path_segment @@ -41,7 +47,10 @@ class PolarisCatalogRoleTool(McpTool): GET_ALIASES: Set[str] = {"get"} UPDATE_ALIASES: Set[str] = {"update"} DELETE_ALIASES: Set[str] = {"delete", "remove"} - LIST_PRINCIPAL_ROLES_ALIASES: Set[str] = {"list-principal-roles", "list-assigned-principal-roles"} + LIST_PRINCIPAL_ROLES_ALIASES: Set[str] = { + "list-principal-roles", + "list-assigned-principal-roles", + } LIST_GRANTS_ALIASES: Set[str] = {"list-grants"} ADD_GRANT_ALIASES: Set[str] = {"add-grant", "grant"} REVOKE_GRANT_ALIASES: Set[str] = {"revoke-grant"} @@ -140,30 +149,44 @@ def call(self, arguments: Any) -> ToolExecutionResult: elif normalized == "create": delegate_args["method"] = "POST" delegate_args["path"] = base_path - delegate_args["body"] = self._require_object(arguments, "body", "CreateCatalogRoleRequest") + delegate_args["body"] = self._require_object( + arguments, "body", "CreateCatalogRoleRequest" + ) elif normalized == "get": delegate_args["method"] = "GET" delegate_args["path"] = self._catalog_role_path(base_path, arguments) elif normalized == "update": delegate_args["method"] = "PUT" delegate_args["path"] = self._catalog_role_path(base_path, arguments) - delegate_args["body"] = self._require_object(arguments, "body", "UpdateCatalogRoleRequest") + delegate_args["body"] = self._require_object( + arguments, "body", "UpdateCatalogRoleRequest" + ) elif normalized == "delete": delegate_args["method"] = "DELETE" delegate_args["path"] = self._catalog_role_path(base_path, arguments) elif normalized == "list-principal-roles": delegate_args["method"] = "GET" - delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/principal-roles" + delegate_args["path"] = ( + f"{self._catalog_role_path(base_path, arguments)}/principal-roles" + ) elif normalized == "list-grants": delegate_args["method"] = "GET" - delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + delegate_args["path"] = ( + f"{self._catalog_role_path(base_path, arguments)}/grants" + ) elif normalized == "add-grant": delegate_args["method"] = "PUT" - delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" - delegate_args["body"] = self._require_object(arguments, "body", "AddGrantRequest") + delegate_args["path"] = ( + f"{self._catalog_role_path(base_path, arguments)}/grants" + ) + delegate_args["body"] = self._require_object( + arguments, "body", "AddGrantRequest" + ) elif normalized == "revoke-grant": delegate_args["method"] = "POST" - delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + delegate_args["path"] = ( + f"{self._catalog_role_path(base_path, arguments)}/grants" + ) if isinstance(arguments.get("body"), dict): delegate_args["body"] = copy.deepcopy(arguments["body"]) else: # pragma: no cover @@ -176,13 +199,17 @@ def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> str: role = encode_path_segment(require_text(arguments, "catalogRole")) return f"{base_path}/{role}" - def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + def _require_object( + self, arguments: Dict[str, Any], field: str, description: str + ) -> Dict[str, Any]: node = arguments.get(field) if not isinstance(node, dict): raise ValueError(f"{description} payload (`{field}`) is required.") return copy.deepcopy(node) - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} @@ -194,9 +221,7 @@ def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> T if operation == "create": hint = "Create catalog role requires CreateCatalogRoleRequest body." elif operation == "update": - hint = ( - "Update catalog role requires UpdateCatalogRoleRequest body with currentEntityVersion." - ) + hint = "Update catalog role requires UpdateCatalogRoleRequest body with currentEntityVersion." elif operation == "add-grant": hint = "Grant operations require AddGrantRequest body." diff --git a/mcp-server/polaris_mcp/tools/namespace.py b/mcp-server/polaris_mcp/tools/namespace.py index 0d90834f..e1fbcf5d 100644 --- a/mcp-server/polaris_mcp/tools/namespace.py +++ b/mcp-server/polaris_mcp/tools/namespace.py @@ -43,15 +43,17 @@ class PolarisNamespaceTool(McpTool): """Manage namespaces through the Polaris REST API.""" TOOL_NAME = "polaris-namespace-request" - TOOL_DESCRIPTION = ( - "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." - ) + TOOL_DESCRIPTION = "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." LIST_ALIASES: Set[str] = {"list"} GET_ALIASES: Set[str] = {"get", "load"} EXISTS_ALIASES: Set[str] = {"exists", "head"} CREATE_ALIASES: Set[str] = {"create"} - UPDATE_PROPS_ALIASES: Set[str] = {"update-properties", "set-properties", "properties-update"} + UPDATE_PROPS_ALIASES: Set[str] = { + "update-properties", + "set-properties", + "properties-update", + } GET_PROPS_ALIASES: Set[str] = {"get-properties", "properties"} DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"} @@ -169,7 +171,9 @@ def _handle_list(self, delegate_args: JSONDict, catalog: str) -> None: delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces" - def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str) -> None: + def _handle_get( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: namespace = self._resolve_namespace_path(arguments) delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}" @@ -227,7 +231,9 @@ def _handle_delete( delegate_args["method"] = "DELETE" delegate_args["path"] = f"{catalog}/namespaces/{namespace}" - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result @@ -267,10 +273,14 @@ def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: parts: List[str] = [] for element in namespace: if not isinstance(element, str): - raise ValueError("Namespace array elements must be non-empty strings.") + raise ValueError( + "Namespace array elements must be non-empty strings." + ) candidate = element.strip(string.whitespace) if not candidate: - raise ValueError("Namespace array elements must be non-empty strings.") + raise ValueError( + "Namespace array elements must be non-empty strings." + ) parts.append(candidate) return parts if not isinstance(namespace, str): diff --git a/mcp-server/polaris_mcp/tools/policy.py b/mcp-server/polaris_mcp/tools/policy.py index 1fbdf7e0..4029e5ac 100644 --- a/mcp-server/polaris_mcp/tools/policy.py +++ b/mcp-server/polaris_mcp/tools/policy.py @@ -27,7 +27,13 @@ import urllib3 from polaris_mcp.authorization import AuthorizationProvider -from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, +) from polaris_mcp.rest import PolarisRestTool, encode_path_segment @@ -35,9 +41,7 @@ class PolarisPolicyTool(McpTool): """Expose Polaris policy endpoints via MCP.""" TOOL_NAME = "polaris-policy" - TOOL_DESCRIPTION = ( - "Manage Polaris policies (list, create, update, delete, attach, detach, applicable)." - ) + TOOL_DESCRIPTION = "Manage Polaris policies (list, create, update, delete, attach, detach, applicable)." LIST_ALIASES: Set[str] = {"list"} GET_ALIASES: Set[str] = {"get", "load", "fetch"} @@ -77,7 +81,16 @@ def input_schema(self) -> JSONDict: "properties": { "operation": { "type": "string", - "enum": ["list", "get", "create", "update", "delete", "attach", "detach", "applicable"], + "enum": [ + "list", + "get", + "create", + "update", + "delete", + "attach", + "detach", + "applicable", + ], "description": ( "Policy operation to execute. Supported values: list, get, create, update, delete, attach, detach, applicable." ), @@ -133,9 +146,13 @@ def call(self, arguments: Any) -> ToolExecutionResult: catalog = encode_path_segment(require_text(arguments, "catalog")) namespace: Optional[str] = None if normalized != "applicable": - namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) + namespace = encode_path_segment( + self._resolve_namespace(arguments.get("namespace")) + ) elif arguments.get("namespace") is not None: - namespace = encode_path_segment(self._resolve_namespace(arguments.get("namespace"))) + namespace = encode_path_segment( + self._resolve_namespace(arguments.get("namespace")) + ) delegate_args: JSONDict = {} copy_if_object(arguments.get("query"), delegate_args, "query") @@ -170,7 +187,9 @@ def call(self, arguments: Any) -> ToolExecutionResult: raw = self._delegate.call(delegate_args) return self._maybe_augment_error(raw, normalized) - def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + def _handle_list( + self, delegate_args: JSONDict, catalog: str, namespace: str + ) -> None: delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies" @@ -182,7 +201,9 @@ def _handle_get( namespace: str, ) -> None: policy = encode_path_segment( - require_text(arguments, "policy", "Policy name is required for get operations.") + require_text( + arguments, "policy", "Policy name is required for get operations." + ) ) delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -216,7 +237,9 @@ def _handle_update( "Update operations require a request body that matches the UpdatePolicyRequest schema." ) policy = encode_path_segment( - require_text(arguments, "policy", "Policy name is required for update operations.") + require_text( + arguments, "policy", "Policy name is required for update operations." + ) ) delegate_args["method"] = "PUT" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -230,7 +253,9 @@ def _handle_delete( namespace: str, ) -> None: policy = encode_path_segment( - require_text(arguments, "policy", "Policy name is required for delete operations.") + require_text( + arguments, "policy", "Policy name is required for delete operations." + ) ) delegate_args["method"] = "DELETE" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" @@ -248,10 +273,14 @@ def _handle_attach( "Attach operations require a request body that matches the AttachPolicyRequest schema." ) policy = encode_path_segment( - require_text(arguments, "policy", "Policy name is required for attach operations.") + require_text( + arguments, "policy", "Policy name is required for attach operations." + ) ) delegate_args["method"] = "PUT" - delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["path"] = ( + f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + ) delegate_args["body"] = copy.deepcopy(body) def _handle_detach( @@ -267,17 +296,23 @@ def _handle_detach( "Detach operations require a request body that matches the DetachPolicyRequest schema." ) policy = encode_path_segment( - require_text(arguments, "policy", "Policy name is required for detach operations.") + require_text( + arguments, "policy", "Policy name is required for detach operations." + ) ) delegate_args["method"] = "POST" - delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["path"] = ( + f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + ) delegate_args["body"] = copy.deepcopy(body) def _handle_applicable(self, delegate_args: JSONDict, catalog: str) -> None: delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/applicable-policies" - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} @@ -292,7 +327,7 @@ def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> T "See CreatePolicyRequest in spec/polaris-catalog-apis/policy-apis.yaml. " "Common types include system.data-compaction, system.metadata-compaction, " "system.orphan-file-removal, and system.snapshot-expiry. " - "Example: {\"name\":\"weekly_compaction\",\"type\":\"system.data-compaction\",\"content\":{...}}. " + 'Example: {"name":"weekly_compaction","type":"system.data-compaction","content":{...}}. ' "Reference schema: http://polaris.apache.org/schemas/policies/system/data-compaction/2025-02-03.json" ) elif operation == "update": @@ -306,9 +341,7 @@ def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> T "Ensure the policy exists first (create it with operation=create) before attaching." ) elif operation == "detach": - hint = ( - "Detach requests require a body with `targetType`, `targetName`, and optional `parameters`." - ) + hint = "Detach requests require a body with `targetType`, `targetName`, and optional `parameters`." if not hint: return result @@ -353,7 +386,9 @@ def _resolve_namespace(self, namespace: Any) -> str: parts = [] for element in namespace: if not isinstance(element, str) or not element.strip(): - raise ValueError("Namespace array elements must be non-empty strings.") + raise ValueError( + "Namespace array elements must be non-empty strings." + ) parts.append(element.strip()) return ".".join(parts) if not isinstance(namespace, str) or not namespace.strip(): diff --git a/mcp-server/polaris_mcp/tools/principal.py b/mcp-server/polaris_mcp/tools/principal.py index 61dfb286..693990b0 100644 --- a/mcp-server/polaris_mcp/tools/principal.py +++ b/mcp-server/polaris_mcp/tools/principal.py @@ -27,7 +27,13 @@ import urllib3 from polaris_mcp.authorization import AuthorizationProvider -from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, +) from polaris_mcp.rest import PolarisRestTool, encode_path_segment @@ -168,10 +174,14 @@ def _handle_list(self, delegate_args: JSONDict) -> None: delegate_args["method"] = "GET" delegate_args["path"] = "principals" - def _handle_create(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_create( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: body = arguments.get("body") if not isinstance(body, dict): - raise ValueError("Create principal requires a body matching CreatePrincipalRequest.") + raise ValueError( + "Create principal requires a body matching CreatePrincipalRequest." + ) delegate_args["method"] = "POST" delegate_args["path"] = "principals" delegate_args["body"] = copy.deepcopy(body) @@ -181,21 +191,29 @@ def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> Non delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}" - def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_update( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): - raise ValueError("Update principal requires a body matching UpdatePrincipalRequest.") + raise ValueError( + "Update principal requires a body matching UpdatePrincipalRequest." + ) delegate_args["method"] = "PUT" delegate_args["path"] = f"principals/{principal}" delegate_args["body"] = copy.deepcopy(body) - def _handle_delete(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_delete( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}" - def _handle_rotate(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_rotate( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "POST" delegate_args["path"] = f"principals/{principal}/rotate" @@ -207,12 +225,16 @@ def _handle_reset(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> N if isinstance(arguments.get("body"), dict): delegate_args["body"] = copy.deepcopy(arguments["body"]) - def _handle_list_roles(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_list_roles( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) delegate_args["method"] = "GET" delegate_args["path"] = f"principals/{principal}/principal-roles" - def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_assign_role( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) body = arguments.get("body") if not isinstance(body, dict): @@ -223,13 +245,17 @@ def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict delegate_args["path"] = f"principals/{principal}/principal-roles" delegate_args["body"] = copy.deepcopy(body) - def _handle_revoke_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + def _handle_revoke_role( + self, arguments: Dict[str, Any], delegate_args: JSONDict + ) -> None: principal = encode_path_segment(require_text(arguments, "principal")) role = encode_path_segment(require_text(arguments, "principalRole")) delegate_args["method"] = "DELETE" delegate_args["path"] = f"principals/{principal}/principal-roles/{role}" - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} @@ -244,13 +270,9 @@ def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> T "See spec/polaris-management-service.yml." ) elif operation == "update": - hint = ( - "Update principal requires `principal` and body matching UpdatePrincipalRequest with currentEntityVersion." - ) + hint = "Update principal requires `principal` and body matching UpdatePrincipalRequest with currentEntityVersion." elif operation == "assign-principal-role": - hint = ( - "Provide GrantPrincipalRoleRequest in the body (principalRoleName, catalogName, etc.)." - ) + hint = "Provide GrantPrincipalRoleRequest in the body (principalRoleName, catalogName, etc.)." if not hint: return result diff --git a/mcp-server/polaris_mcp/tools/principal_role.py b/mcp-server/polaris_mcp/tools/principal_role.py index db937df6..8fd84f73 100644 --- a/mcp-server/polaris_mcp/tools/principal_role.py +++ b/mcp-server/polaris_mcp/tools/principal_role.py @@ -26,7 +26,13 @@ import urllib3 from polaris_mcp.authorization import AuthorizationProvider -from polaris_mcp.base import JSONDict, McpTool, ToolExecutionResult, copy_if_object, require_text +from polaris_mcp.base import ( + JSONDict, + McpTool, + ToolExecutionResult, + copy_if_object, + require_text, +) from polaris_mcp.rest import PolarisRestTool, encode_path_segment @@ -34,9 +40,7 @@ class PolarisPrincipalRoleTool(McpTool): """Manage principal roles through the Polaris management API.""" TOOL_NAME = "polaris-principal-role-request" - TOOL_DESCRIPTION = ( - "Manage principal roles (list, get, create, update, delete) and their catalog-role assignments via the Polaris management API." - ) + TOOL_DESCRIPTION = "Manage principal roles (list, get, create, update, delete) and their catalog-role assignments via the Polaris management API." LIST_ALIASES: Set[str] = {"list"} CREATE_ALIASES: Set[str] = {"create"} @@ -44,9 +48,18 @@ class PolarisPrincipalRoleTool(McpTool): UPDATE_ALIASES: Set[str] = {"update"} DELETE_ALIASES: Set[str] = {"delete", "remove"} LIST_PRINCIPALS_ALIASES: Set[str] = {"list-principals", "list-assignees"} - LIST_CATALOG_ROLES_ALIASES: Set[str] = {"list-catalog-roles", "list-mapped-catalog-roles"} - ASSIGN_CATALOG_ROLE_ALIASES: Set[str] = {"assign-catalog-role", "grant-catalog-role"} - REVOKE_CATALOG_ROLE_ALIASES: Set[str] = {"revoke-catalog-role", "remove-catalog-role"} + LIST_CATALOG_ROLES_ALIASES: Set[str] = { + "list-catalog-roles", + "list-mapped-catalog-roles", + } + ASSIGN_CATALOG_ROLE_ALIASES: Set[str] = { + "assign-catalog-role", + "grant-catalog-role", + } + REVOKE_CATALOG_ROLE_ALIASES: Set[str] = { + "revoke-catalog-role", + "remove-catalog-role", + } def __init__( self, @@ -190,13 +203,17 @@ def _principal_role_catalog_path(self, arguments: Dict[str, Any]) -> str: catalog = encode_path_segment(require_text(arguments, "catalog")) return f"{self._principal_role_path(arguments)}/catalog-roles/{catalog}" - def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + def _require_object( + self, arguments: Dict[str, Any], field: str, description: str + ) -> Dict[str, Any]: node = arguments.get(field) if not isinstance(node, dict): raise ValueError(f"{description} payload (`{field}`) is required.") return copy.deepcopy(node) - def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + def _maybe_augment_error( + self, result: ToolExecutionResult, operation: str + ) -> ToolExecutionResult: if not result.is_error: return result metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} @@ -208,9 +225,7 @@ def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> T if operation == "create": hint = "Create principal role requires CreatePrincipalRoleRequest body." elif operation == "update": - hint = ( - "Update principal role requires UpdatePrincipalRoleRequest body with currentEntityVersion." - ) + hint = "Update principal role requires UpdatePrincipalRoleRequest body with currentEntityVersion." elif operation == "assign-catalog-role": hint = "Provide GrantCatalogRoleRequest body when assigning catalog roles." diff --git a/mcp-server/polaris_mcp/tools/table.py b/mcp-server/polaris_mcp/tools/table.py index e13e5f4c..df02fb8f 100644 --- a/mcp-server/polaris_mcp/tools/table.py +++ b/mcp-server/polaris_mcp/tools/table.py @@ -43,9 +43,7 @@ class PolarisTableTool(McpTool): """Expose Polaris table REST endpoints through MCP.""" TOOL_NAME = "polaris-iceberg-table" - TOOL_DESCRIPTION = ( - "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." - ) + TOOL_DESCRIPTION = "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." LIST_ALIASES: Set[str] = {"list", "ls"} GET_ALIASES: Set[str] = {"get", "load", "fetch"} CREATE_ALIASES: Set[str] = {"create"} @@ -155,7 +153,9 @@ def call(self, arguments: Any) -> ToolExecutionResult: return self._delegate.call(delegate_args) - def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + def _handle_list( + self, delegate_args: JSONDict, catalog: str, namespace: str + ) -> None: delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" @@ -167,7 +167,9 @@ def _handle_get( namespace: str, ) -> None: table = encode_path_segment( - require_text(arguments, "table", "Table name is required for get operations.") + require_text( + arguments, "table", "Table name is required for get operations." + ) ) delegate_args["method"] = "GET" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -202,7 +204,9 @@ def _handle_commit( "Commit operations require a request body that matches the CommitTableRequest schema." ) table = encode_path_segment( - require_text(arguments, "table", "Table name is required for commit operations.") + require_text( + arguments, "table", "Table name is required for commit operations." + ) ) delegate_args["method"] = "POST" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -216,7 +220,9 @@ def _handle_delete( namespace: str, ) -> None: table = encode_path_segment( - require_text(arguments, "table", "Table name is required for delete operations.") + require_text( + arguments, "table", "Table name is required for delete operations." + ) ) delegate_args["method"] = "DELETE" delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" @@ -243,10 +249,14 @@ def _resolve_namespace(self, namespace: Any) -> List[str]: parts: List[str] = [] for element in namespace: if not isinstance(element, str): - raise ValueError("Namespace array elements must be non-empty strings.") + raise ValueError( + "Namespace array elements must be non-empty strings." + ) candidate = element.strip(string.whitespace) if not candidate: - raise ValueError("Namespace array elements must be non-empty strings.") + raise ValueError( + "Namespace array elements must be non-empty strings." + ) parts.append(candidate) return parts if not isinstance(namespace, str): diff --git a/mcp-server/tests/test_namespace_tool.py b/mcp-server/tests/test_namespace_tool.py index 70c1c9cb..88af2b16 100644 --- a/mcp-server/tests/test_namespace_tool.py +++ b/mcp-server/tests/test_namespace_tool.py @@ -12,12 +12,16 @@ def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisNamespaceTool, mock.Mock]: delegate = mock.Mock() delegate.call.return_value = ToolExecutionResult(text="done", is_error=False) mock_rest.return_value = delegate - tool = PolarisNamespaceTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) + tool = PolarisNamespaceTool( + "https://polaris/", mock.sentinel.http, mock.sentinel.auth + ) return tool, delegate @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") -def test_get_operation_encodes_namespace_with_unit_separator(mock_rest: mock.Mock) -> None: +def test_get_operation_encodes_namespace_with_unit_separator( + mock_rest: mock.Mock, +) -> None: tool, delegate = _build_tool(mock_rest) tool.call( @@ -35,7 +39,9 @@ def test_get_operation_encodes_namespace_with_unit_separator(mock_rest: mock.Moc @mock.patch("polaris_mcp.tools.namespace.PolarisRestTool") -def test_create_operation_infers_namespace_array_from_string(mock_rest: mock.Mock) -> None: +def test_create_operation_infers_namespace_array_from_string( + mock_rest: mock.Mock, +) -> None: tool, delegate = _build_tool(mock_rest) body = {"properties": {"owner": "analytics"}} diff --git a/mcp-server/tests/test_rest_tool.py b/mcp-server/tests/test_rest_tool.py index 6d5bd74c..ba108ead 100644 --- a/mcp-server/tests/test_rest_tool.py +++ b/mcp-server/tests/test_rest_tool.py @@ -30,7 +30,9 @@ from polaris_mcp.rest import DEFAULT_TIMEOUT, PolarisRestTool -def _build_response(status: int, body: str, headers: dict[str, object] | None = None) -> SimpleNamespace: +def _build_response( + status: int, body: str, headers: dict[str, object] | None = None +) -> SimpleNamespace: """Return a lightweight stub with the attributes accessed by PolarisRestTool.""" header_dict = HTTPHeaderDict() @@ -41,7 +43,9 @@ def _build_response(status: int, body: str, headers: dict[str, object] | None = header_dict.add(key, item) else: header_dict.add(key, value) - return SimpleNamespace(status=status, data=body.encode("utf-8"), headers=header_dict) + return SimpleNamespace( + status=status, data=body.encode("utf-8"), headers=header_dict + ) def _create_tool() -> tuple[PolarisRestTool, mock.Mock, mock.Mock]: @@ -72,7 +76,10 @@ def test_call_builds_request_and_metadata_with_json_body() -> None: "method": "post", "path": "namespaces", "query": {"page-size": "200", "tag": ["blue", "green"]}, - "headers": {"Prefer": ["return-minimal", "respond-async"], "Authorization": "Bearer user"}, + "headers": { + "Prefer": ["return-minimal", "respond-async"], + "Authorization": "Bearer user", + }, "body": {"name": "analytics"}, } ) @@ -104,7 +111,10 @@ def test_call_builds_request_and_metadata_with_json_body() -> None: assert result.metadata["response"]["body"] == {"result": "ok"} assert result.metadata["response"]["headers"]["X-Request-Id"] == "abc123" assert result.metadata["request"]["headers"]["Authorization"] == "[REDACTED]" - assert result.metadata["request"]["headers"]["Prefer"] == "return-minimal, respond-async" + assert ( + result.metadata["request"]["headers"]["Prefer"] + == "return-minimal, respond-async" + ) def test_call_uses_authorization_provider_and_handles_plain_text() -> None: diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py index 8728965e..0322a56b 100644 --- a/mcp-server/tests/test_server.py +++ b/mcp-server/tests/test_server.py @@ -19,11 +19,15 @@ def test_call_tool_merges_arguments_and_applies_transforms(self) -> None: class DummyTool: def call(self, arguments: dict[str, object]) -> ToolExecutionResult: captured["arguments"] = arguments - return ToolExecutionResult(text="done", is_error=False, metadata={"x": 1}) + return ToolExecutionResult( + text="done", is_error=False, metadata={"x": 1} + ) tool = DummyTool() sentinel = object() - with mock.patch("polaris_mcp.server._to_tool_result", return_value=sentinel) as mock_to_result: + with mock.patch( + "polaris_mcp.server._to_tool_result", return_value=sentinel + ) as mock_to_result: result = server._call_tool( tool, required={"operation": "GET", "catalog": "prod"}, @@ -134,14 +138,19 @@ def test_coerce_body_returns_plain_dict_for_mappings(self) -> None: assert server._coerce_body(sequence) is sequence def test_to_tool_result_builds_fastmcp_payload_with_metadata(self) -> None: - execution = ToolExecutionResult(text="ok", is_error=True, metadata={"foo": "bar"}) + execution = ToolExecutionResult( + text="ok", is_error=True, metadata={"foo": "bar"} + ) text_instance = object() fast_instance = object() - with mock.patch( - "polaris_mcp.server.TextContent", return_value=text_instance - ) as mock_text, mock.patch( - "polaris_mcp.server.FastMcpToolResult", return_value=fast_instance - ) as mock_result: + with ( + mock.patch( + "polaris_mcp.server.TextContent", return_value=text_instance + ) as mock_text, + mock.patch( + "polaris_mcp.server.FastMcpToolResult", return_value=fast_instance + ) as mock_result, + ): output = server._to_tool_result(execution) assert output is fast_instance @@ -153,9 +162,10 @@ def test_to_tool_result_builds_fastmcp_payload_with_metadata(self) -> None: def test_to_tool_result_omits_meta_when_not_provided(self) -> None: execution = ToolExecutionResult(text="hello", is_error=False, metadata=None) - with mock.patch("polaris_mcp.server.TextContent") as mock_text, mock.patch( - "polaris_mcp.server.FastMcpToolResult" - ) as mock_result: + with ( + mock.patch("polaris_mcp.server.TextContent") as mock_text, + mock.patch("polaris_mcp.server.FastMcpToolResult") as mock_result, + ): server._to_tool_result(execution) mock_text.assert_called_once_with(type="text", text="hello") @@ -167,7 +177,8 @@ def test_resolve_package_version_uses_metadata_and_handles_missing(self) -> None assert server._resolve_package_version() == "2.0.0" with mock.patch( - "polaris_mcp.server.metadata.version", side_effect=metadata.PackageNotFoundError + "polaris_mcp.server.metadata.version", + side_effect=metadata.PackageNotFoundError, ): assert server._resolve_package_version() == "dev" @@ -175,10 +186,13 @@ def test_resolve_package_version_uses_metadata_and_handles_missing(self) -> None class TestAuthorizationProviderResolution: def test_resolve_authorization_provider_uses_token_when_available(self) -> None: fake_http = object() - with mock.patch("polaris_mcp.server._resolve_token", return_value="abc"), mock.patch.dict( - os.environ, {}, clear=True + with ( + mock.patch("polaris_mcp.server._resolve_token", return_value="abc"), + mock.patch.dict(os.environ, {}, clear=True), ): - provider = server._resolve_authorization_provider("https://base/", fake_http) + provider = server._resolve_authorization_provider( + "https://base/", fake_http + ) assert isinstance(provider, server.StaticAuthorizationProvider) assert provider.authorization_header() == "Bearer abc" @@ -186,19 +200,26 @@ def test_resolve_authorization_provider_uses_token_when_available(self) -> None: def test_resolve_authorization_provider_uses_client_credentials(self) -> None: fake_http = object() fake_provider = object() - with mock.patch("polaris_mcp.server._resolve_token", return_value=None), mock.patch.dict( - os.environ, - { - "POLARIS_CLIENT_ID": " client ", - "POLARIS_CLIENT_SECRET": "secret", - "POLARIS_TOKEN_SCOPE": " scope ", - "POLARIS_TOKEN_URL": "https://oauth/token", - }, - clear=True, - ), mock.patch( - "polaris_mcp.server.ClientCredentialsAuthorizationProvider", return_value=fake_provider - ) as mock_factory: - provider = server._resolve_authorization_provider("https://base/", fake_http) + with ( + mock.patch("polaris_mcp.server._resolve_token", return_value=None), + mock.patch.dict( + os.environ, + { + "POLARIS_CLIENT_ID": " client ", + "POLARIS_CLIENT_SECRET": "secret", + "POLARIS_TOKEN_SCOPE": " scope ", + "POLARIS_TOKEN_URL": "https://oauth/token", + }, + clear=True, + ), + mock.patch( + "polaris_mcp.server.ClientCredentialsAuthorizationProvider", + return_value=fake_provider, + ) as mock_factory, + ): + provider = server._resolve_authorization_provider( + "https://base/", fake_http + ) assert provider is fake_provider mock_factory.assert_called_once_with( diff --git a/mcp-server/tests/test_table_tool.py b/mcp-server/tests/test_table_tool.py index 762b8ebe..88bd8eee 100644 --- a/mcp-server/tests/test_table_tool.py +++ b/mcp-server/tests/test_table_tool.py @@ -11,14 +11,18 @@ def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisTableTool, mock.Mock]: delegate = mock.Mock() - delegate.call.return_value = ToolExecutionResult(text="ok", is_error=False, metadata={"k": "v"}) + delegate.call.return_value = ToolExecutionResult( + text="ok", is_error=False, metadata={"k": "v"} + ) mock_rest.return_value = delegate tool = PolarisTableTool("https://polaris/", mock.sentinel.http, mock.sentinel.auth) return tool, delegate @mock.patch("polaris_mcp.tools.table.PolarisRestTool") -def test_list_operation_uses_get_and_copies_query_and_headers(mock_rest: mock.Mock) -> None: +def test_list_operation_uses_get_and_copies_query_and_headers( + mock_rest: mock.Mock, +) -> None: tool, delegate = _build_tool(mock_rest) arguments = { "operation": "LS", From 578da2f28fea968c88dada6d1230b12898773cb0 Mon Sep 17 00:00:00 2001 From: Yufei Date: Thu, 13 Nov 2025 23:17:47 -0800 Subject: [PATCH 20/23] Add lint --- mcp-server/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index abcfe013..2f77d33f 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -28,10 +28,12 @@ The implementation is built on top of [FastMCP](https://gofastmcp.com) for strea - [uv](https://docs.astral.sh/uv/) 0.9.7 or later ## Building and Running -- `cd mcp-server && uv sync` - install runtime dependencies -- `uv run polaris-mcp` - start the MCP server (stdin/stdout transport) -- `uv sync --extra test` - install runtime + test dependencies -- `uv run pytest` - run the test suite +Run the following commands from the `mcp-server` directory: +- `uv sync` — install runtime dependencies +- `uv run polaris-mcp` — start the MCP server (stdin/stdout transport) +- `uv sync --extra test` — install runtime + test dependencies +- `uv run pytest` — run the test suite +- `pre-commit run --all-files` — lint all files For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. From 6c7e91266af5a634563b548ef2bdc7f892e4afb7 Mon Sep 17 00:00:00 2001 From: Yufei Date: Fri, 14 Nov 2025 10:04:47 -0800 Subject: [PATCH 21/23] Add Github workflow for mcp-server --- .github/workflows/mcp-server.yml | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/mcp-server.yml diff --git a/.github/workflows/mcp-server.yml b/.github/workflows/mcp-server.yml new file mode 100644 index 00000000..cf8a0379 --- /dev/null +++ b/.github/workflows/mcp-server.yml @@ -0,0 +1,75 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Python project with Poetry and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python + +name: Python Client CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + GRADLE_TOS_ACCEPTED: ${{ vars.GRADLE_TOS_ACCEPTED }} + DEVELOCITY_SERVER: ${{ vars.DEVELOCITY_SERVER }} + DEVELOCITY_PROJECT_ID: ${{ vars.DEVELOCITY_PROJECT_ID }} + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout Polaris Tools project + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Sync dependencies + working-directory: mcp-server + run: | + uv sync --extra test + + - name: Lint + working-directory: mcp-server + run: | + uv run pre-commit run --all-files + + - name: Unit Tests + working-directory: mcp-server + run: | + uv run pytest From e14f59af644f09a9d0530ef50f22f416f7570744 Mon Sep 17 00:00:00 2001 From: Yufei Date: Fri, 14 Nov 2025 10:17:01 -0800 Subject: [PATCH 22/23] Add Github workflow for mcp-server --- .github/workflows/mcp-server.yml | 2 +- mcp-server/README.md | 4 ++-- mcp-server/pyproject.toml | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mcp-server.yml b/.github/workflows/mcp-server.yml index cf8a0379..685bc93b 100644 --- a/.github/workflows/mcp-server.yml +++ b/.github/workflows/mcp-server.yml @@ -62,7 +62,7 @@ jobs: - name: Sync dependencies working-directory: mcp-server run: | - uv sync --extra test + uv sync --extra test --extra dev - name: Lint working-directory: mcp-server diff --git a/mcp-server/README.md b/mcp-server/README.md index 2f77d33f..a1c57c2c 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -31,9 +31,9 @@ The implementation is built on top of [FastMCP](https://gofastmcp.com) for strea Run the following commands from the `mcp-server` directory: - `uv sync` — install runtime dependencies - `uv run polaris-mcp` — start the MCP server (stdin/stdout transport) -- `uv sync --extra test` — install runtime + test dependencies +- `uv sync --extra test --extra dev` — install runtime, test and dev dependencies - `uv run pytest` — run the test suite -- `pre-commit run --all-files` — lint all files +- `uv run pre-commit run --all-files` — lint all files For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index ac4587ac..3b34c48b 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ test = [ "pytest>=8.2", ] +dev = [ + "pre-commit>=3.7", +] [project.scripts] polaris-mcp = "polaris_mcp.server:main" From c23b4221239e9f7a012b3d097aeb06ca54fd6067 Mon Sep 17 00:00:00 2001 From: Yufei Date: Fri, 14 Nov 2025 10:24:19 -0800 Subject: [PATCH 23/23] Add Github workflow for mcp-server --- .github/workflows/mcp-server.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mcp-server.yml b/.github/workflows/mcp-server.yml index 685bc93b..8e3328e7 100644 --- a/.github/workflows/mcp-server.yml +++ b/.github/workflows/mcp-server.yml @@ -24,7 +24,7 @@ # This workflow will build a Python project with Poetry and cache/restore any dependencies to improve the workflow execution time # For more information see: https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python -name: Python Client CI +name: MCP Server CI on: push: