diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml
index b6b54dc5..73c74ba7 100644
--- a/.github/workflows/unit-test.yaml
+++ b/.github/workflows/unit-test.yaml
@@ -56,6 +56,12 @@ jobs:
python-version: ${{ matrix.python-version }}
architecture: x64
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ enable-cache: true
+
- name: Get cache key
id: get-cache-key
run: |
@@ -84,9 +90,7 @@ jobs:
- name: Install additional dependencies (if any)
run: |
- python -m pip install --upgrade pip
- cd libs/kotaemon
- pip install -U --upgrade-strategy eager -e .[all]
+ uv sync --frozen --no-cache
- name: New dependencies cache for key ${{ steps.restore-dependencies.outputs.cache-primary-key }}
if: |
@@ -104,6 +108,7 @@ jobs:
- name: Test kotaemon with pytest
run: |
- pip show pytest
+ source .venv/bin/activate
+ uv pip show pytest
cd libs/kotaemon
pytest
diff --git a/Dockerfile b/Dockerfile
index fd8f9d92..cba41890 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,9 @@ RUN apt-get update -qqy && \
libpoppler-dev \
unzip \
curl \
- cargo
+ cargo \
+ && \
+ apt-get autoremove && apt-get clean && rm -rf /var/lib/apt/lists/*
# Setup args
ARG TARGETPLATFORM
@@ -33,6 +35,9 @@ RUN chmod +x /app/scripts/download_pdfjs.sh
ENV PDFJS_PREBUILT_DIR="/app/libs/ktem/ktem/assets/prebuilt/pdfjs-dist"
RUN bash scripts/download_pdfjs.sh $PDFJS_PREBUILT_DIR
+# Install uv dependencies
+RUN pip install --no-cache-dir "uv"
+
# Copy contents
COPY . /app
COPY launch.sh /app/launch.sh
@@ -40,20 +45,13 @@ COPY .env.example /app/.env
# Install pip packages
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- pip install -e "libs/kotaemon" \
- && pip install -e "libs/ktem" \
- && pip install "pdfservices-sdk@git+https://github.com/niallcm/pdfservices-python-sdk.git@bump-and-unfreeze-requirements"
+ --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-cache \
+ && uv pip install --python .venv "pdfservices-sdk@git+https://github.com/niallcm/pdfservices-python-sdk.git@bump-and-unfreeze-requirements"
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- if [ "$TARGETARCH" = "amd64" ]; then pip install "graphrag<=0.3.6" future; fi
-
-# Clean up
-RUN apt-get autoremove \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/* \
- && rm -rf ~/.cache
+ --mount=type=cache,target=/root/.cache/uv \
+ if [ "$TARGETARCH" = "amd64" ]; then uv pip install --python .venv "graphrag<=0.3.6" future; fi
ENTRYPOINT ["sh", "/app/launch.sh"]
@@ -69,38 +67,33 @@ RUN apt-get update -qqy && \
libxext6 \
libreoffice \
ffmpeg \
- libmagic-dev
+ libmagic-dev \
+ && \
+ apt-get autoremove && apt-get clean && rm -rf /var/lib/apt/lists/*
# Install torch and torchvision for unstructured
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
+ --mount=type=cache,target=/root/.cache/uv \
+ uv pip install --python .venv torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
# Install additional pip packages
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- pip install -e "libs/kotaemon[adv]" \
- && pip install unstructured[all-docs]
+ --mount=type=cache,target=/root/.cache/uv \
+ uv pip install --python .venv "libs/kotaemon[adv]" \
+ && uv pip install --python .venv unstructured[all-docs]
# Install lightRAG
ENV USE_LIGHTRAG=true
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- pip install aioboto3 nano-vectordb ollama xxhash "lightrag-hku<=1.3.0"
+ --mount=type=cache,target=/root/.cache/uv \
+ uv pip install --python .venv aioboto3 nano-vectordb ollama xxhash "lightrag-hku<=1.3.0"
RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- pip install "docling<=2.5.2"
-
+ --mount=type=cache,target=/root/.cache/uv \
+ uv pip install --python .venv "docling<=2.5.2"
# Download NLTK data from LlamaIndex
-RUN python -c "from llama_index.core.readers.base import BaseReader"
-
-# Clean up
-RUN apt-get autoremove \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/* \
- && rm -rf ~/.cache
+RUN /app/.venv/bin/python -c "from llama_index.core.readers.base import BaseReader"
ENTRYPOINT ["sh", "/app/launch.sh"]
@@ -108,9 +101,7 @@ ENTRYPOINT ["sh", "/app/launch.sh"]
FROM full AS ollama
# Install ollama
-RUN --mount=type=ssh \
- --mount=type=cache,target=/root/.cache/pip \
- curl -fsSL https://ollama.com/install.sh | sh
+RUN curl -fsSL https://ollama.com/install.sh | sh
# RUN nohup bash -c "ollama serve &" && sleep 4 && ollama pull qwen2.5:7b
RUN nohup bash -c "ollama serve &" && sleep 4 && ollama pull nomic-embed-text
diff --git a/README.md b/README.md
index a55788c9..cc97c7f1 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,7 @@ documents and developers who want to build their own RAG pipeline.
```
This script will:
+
- Install uv package manager if not present
- Create a virtual environment with Python 3.10
- Install all dependencies using uv (significantly faster than conda/pip)
diff --git a/launch.sh b/launch.sh
index 08b3a593..39655b1b 100755
--- a/launch.sh
+++ b/launch.sh
@@ -11,13 +11,13 @@ fi
if [ "$KH_DEMO_MODE" = "true" ]; then
echo "KH_DEMO_MODE is true. Launching in demo mode..."
# Command to launch in demo mode
- GR_FILE_ROOT_PATH="/app" KH_FEATURE_USER_MANAGEMENT=false USE_LIGHTRAG=false uvicorn sso_app_demo:app --host "$GRADIO_SERVER_NAME" --port "$GRADIO_SERVER_PORT"
+ GR_FILE_ROOT_PATH="/app" KH_FEATURE_USER_MANAGEMENT=false USE_LIGHTRAG=false .venv/bin/uvicorn sso_app_demo:app --host "$GRADIO_SERVER_NAME" --port "$GRADIO_SERVER_PORT"
else
if [ "$KH_SSO_ENABLED" = "true" ]; then
echo "KH_SSO_ENABLED is true. Launching in SSO mode..."
- GR_FILE_ROOT_PATH="/app" KH_SSO_ENABLED=true uvicorn sso_app:app --host "$GRADIO_SERVER_NAME" --port "$GRADIO_SERVER_PORT"
+ GR_FILE_ROOT_PATH="/app" KH_SSO_ENABLED=true .venv/bin/uvicorn sso_app:app --host "$GRADIO_SERVER_NAME" --port "$GRADIO_SERVER_PORT"
else
ollama serve &
- python app.py
+ .venv/bin/python app.py
fi
fi
diff --git a/libs/kotaemon/kotaemon/agents/__init__.py b/libs/kotaemon/kotaemon/agents/__init__.py
index 81a18df0..fbb6e559 100644
--- a/libs/kotaemon/kotaemon/agents/__init__.py
+++ b/libs/kotaemon/kotaemon/agents/__init__.py
@@ -3,7 +3,14 @@ from .io import AgentFinish, AgentOutput, AgentType, BaseScratchPad
from .langchain_based import LangchainAgent
from .react.agent import ReactAgent
from .rewoo.agent import RewooAgent
-from .tools import BaseTool, ComponentTool, GoogleSearchTool, LLMTool, WikipediaTool
+from .tools import (
+ BaseTool,
+ ComponentTool,
+ GoogleSearchTool,
+ LLMTool,
+ MCPTool,
+ WikipediaTool,
+)
__all__ = [
# agent
@@ -17,6 +24,7 @@ __all__ = [
"GoogleSearchTool",
"WikipediaTool",
"LLMTool",
+ "MCPTool",
# io
"AgentType",
"AgentOutput",
diff --git a/libs/kotaemon/kotaemon/agents/react/agent.py b/libs/kotaemon/kotaemon/agents/react/agent.py
index 73d2d219..e49a35f6 100644
--- a/libs/kotaemon/kotaemon/agents/react/agent.py
+++ b/libs/kotaemon/kotaemon/agents/react/agent.py
@@ -221,7 +221,15 @@ class ReactAgent(BaseAgent):
tool_input = action_step.tool_input
logging.info(f"Action: {action_name}")
logging.info(f"Tool Input: {tool_input}")
- result = self._format_function_map()[action_name](tool_input)
+ function_map = self._format_function_map()
+ if action_name not in function_map:
+ available = ", ".join(function_map.keys())
+ result = (
+ f"Tool '{action_name}' not found. "
+ f"Available tools: {available}"
+ )
+ else:
+ result = function_map[action_name](tool_input)
# trim the worker output to 1000 tokens, as we are appending
# all workers' logs and it can exceed the token limit if we
@@ -297,7 +305,15 @@ class ReactAgent(BaseAgent):
print(f"Action: {action_name}")
logging.info(f"Tool Input: {tool_input}")
print(f"Tool Input: {tool_input}")
- result = self._format_function_map()[action_name](tool_input)
+ function_map = self._format_function_map()
+ if action_name not in function_map:
+ available = ", ".join(function_map.keys())
+ result = (
+ f"Tool '{action_name}' not found. "
+ f"Available tools: {available}"
+ )
+ else:
+ result = function_map[action_name](tool_input)
# trim the worker output to 1000 tokens, as we are appending
# all workers' logs and it can exceed the token limit if we
diff --git a/libs/kotaemon/kotaemon/agents/tools/__init__.py b/libs/kotaemon/kotaemon/agents/tools/__init__.py
index 3869dc93..229b3a47 100644
--- a/libs/kotaemon/kotaemon/agents/tools/__init__.py
+++ b/libs/kotaemon/kotaemon/agents/tools/__init__.py
@@ -1,6 +1,26 @@
from .base import BaseTool, ComponentTool
from .google import GoogleSearchTool
from .llm import LLMTool
+from .mcp import (
+ MCPTool,
+ build_args_model,
+ create_tools_from_config,
+ discover_tools_info,
+ format_tool_list,
+ parse_mcp_config,
+)
from .wikipedia import WikipediaTool
-__all__ = ["BaseTool", "ComponentTool", "GoogleSearchTool", "WikipediaTool", "LLMTool"]
+__all__ = [
+ "BaseTool",
+ "ComponentTool",
+ "GoogleSearchTool",
+ "WikipediaTool",
+ "LLMTool",
+ "MCPTool",
+ "build_args_model",
+ "create_tools_from_config",
+ "discover_tools_info",
+ "format_tool_list",
+ "parse_mcp_config",
+]
diff --git a/libs/kotaemon/kotaemon/agents/tools/mcp.py b/libs/kotaemon/kotaemon/agents/tools/mcp.py
new file mode 100644
index 00000000..e0bce4cc
--- /dev/null
+++ b/libs/kotaemon/kotaemon/agents/tools/mcp.py
@@ -0,0 +1,380 @@
+"""MCP Tool for kotaemon agents.
+
+Bridges the MCP SDK's tool schema with kotaemon's BaseTool abstraction
+so MCP tools can be seamlessly used by ReAct/ReWOO agents.
+
+This module contains:
+- MCPTool: BaseTool wrapper for individual MCP server tools
+- Tool discovery/creation functions for building MCPTool instances from config
+- Config parsing utilities
+"""
+
+import asyncio
+import json
+import logging
+import shlex
+from typing import Any, Optional, Type
+
+from pydantic import BaseModel, Field, create_model
+
+from .base import BaseTool
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# JSON Schema → Pydantic helpers
+# ---------------------------------------------------------------------------
+
+
+def _json_schema_type_to_python(json_type: str) -> type:
+ """Map JSON Schema types to Python types."""
+ mapping: dict[str, type] = {
+ "string": str,
+ "integer": int,
+ "number": float,
+ "boolean": bool,
+ "object": dict,
+ "array": list,
+ }
+ return mapping.get(json_type, str)
+
+
+def build_args_model(tool_name: str, input_schema: dict) -> Type[BaseModel]:
+ """Build a Pydantic model from MCP tool's JSON Schema input_schema."""
+ properties = input_schema.get("properties", {})
+ required = set(input_schema.get("required", []))
+ fields: dict[str, Any] = {}
+ for prop_name, prop_info in properties.items():
+ python_type = _json_schema_type_to_python(prop_info.get("type", "string"))
+ description = prop_info.get("description", "")
+ if prop_name in required:
+ fields[prop_name] = (python_type, Field(..., description=description))
+ else:
+ default = prop_info.get("default", None)
+ fields[prop_name] = (
+ Optional[python_type],
+ Field(default=default, description=description),
+ )
+
+ model_name = f"MCPArgs_{tool_name}"
+ return create_model(model_name, **fields)
+
+
+# ---------------------------------------------------------------------------
+# Config parsing
+# ---------------------------------------------------------------------------
+
+
+def parse_mcp_config(config: dict) -> dict:
+ """Parse a JSON config into normalised transport/command/args/env.
+
+ Handles the case where the user puts the full command string
+ (e.g. ``"npx -y mcp-remote https://..."`` ) into the command field.
+
+ Returns a dict with keys: transport, command, args, env.
+ """
+ transport = config.get("transport", "stdio")
+ command = config.get("command", "")
+ args = config.get("args", [])
+ env = config.get("env", {})
+ url = config.get("url", "")
+
+ # If stdio and args is empty but command has spaces, split it
+ if transport == "stdio" and not args and " " in command:
+ parts = shlex.split(command)
+ command = parts[0]
+ args = parts[1:]
+
+ return {
+ "transport": transport,
+ "command": command if transport == "stdio" else url,
+ "args": args,
+ "env": env,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Tool discovery & creation
+# ---------------------------------------------------------------------------
+
+
+def _make_tool(parsed: dict, tool_info: Any) -> "MCPTool":
+ """Build an MCPTool from MCP tool info."""
+ input_schema = tool_info.inputSchema if hasattr(tool_info, "inputSchema") else {}
+ args_model = (
+ build_args_model(tool_info.name, input_schema) if input_schema else None
+ )
+
+ return MCPTool(
+ name=tool_info.name,
+ description=tool_info.description or f"MCP tool: {tool_info.name}",
+ args_schema=args_model,
+ server_transport=parsed["transport"],
+ server_command=parsed["command"],
+ server_args=parsed.get("args", []),
+ server_env=parsed.get("env", {}),
+ mcp_tool_name=tool_info.name,
+ )
+
+
+async def _async_discover_tools(parsed: dict) -> list["MCPTool"]:
+ """Async: connect to an MCP server and return MCPTool wrappers."""
+ from mcp import ClientSession
+ from mcp.client.sse import sse_client
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+
+ tools: list[MCPTool] = []
+ transport = parsed["transport"]
+
+ if transport == "stdio":
+ server_params = StdioServerParameters(
+ command=parsed["command"],
+ args=parsed.get("args", []),
+ env=parsed.get("env") or None,
+ )
+ async with stdio_client(server_params) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ for tool_info in result.tools:
+ tools.append(_make_tool(parsed, tool_info))
+ elif transport == "sse":
+ async with sse_client(url=parsed["command"]) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ for tool_info in result.tools:
+ tools.append(_make_tool(parsed, tool_info))
+
+ return tools
+
+
+def _run_async(coro: Any) -> Any:
+ """Run an async coroutine from a sync context, handling event loops."""
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor() as pool:
+ return pool.submit(asyncio.run, coro).result()
+ else:
+ return loop.run_until_complete(coro)
+ except RuntimeError:
+ return asyncio.run(coro)
+
+
+def create_tools_from_config(
+ config: dict,
+ enabled_tools: Optional[list[str]] = None,
+) -> list["MCPTool"]:
+ """Create MCPTool instances from an MCP server config dict.
+
+ Args:
+ config: MCP server JSON config with keys like transport, command, etc.
+ enabled_tools: If provided, only return tools whose names are in this
+ list. If ``None`` or empty, return all discovered tools.
+
+ Returns:
+ List of MCPTool instances ready for use by agents.
+ """
+ parsed = parse_mcp_config(config)
+ tools = _run_async(_async_discover_tools(parsed))
+
+ if enabled_tools:
+ tools = [t for t in tools if t.mcp_tool_name in enabled_tools]
+
+ return tools
+
+
+async def async_discover_tools_info(config: dict) -> list[dict]:
+ """Connect to an MCP server and return raw tool info dicts.
+
+ Returns a list of dicts with keys: name, description.
+ Useful for UI display without instantiating full MCPTool objects.
+ """
+ from mcp import ClientSession
+ from mcp.client.sse import sse_client
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+
+ parsed = parse_mcp_config(config)
+ transport = parsed["transport"]
+ tool_infos: list[dict] = []
+
+ if transport == "stdio":
+ server_params = StdioServerParameters(
+ command=parsed["command"],
+ args=parsed.get("args", []),
+ env=parsed.get("env") or None,
+ )
+ async with stdio_client(server_params) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ for t in result.tools:
+ tool_infos.append(
+ {
+ "name": t.name,
+ "description": t.description or "",
+ }
+ )
+ elif transport == "sse":
+ async with sse_client(url=parsed["command"]) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.list_tools()
+ for t in result.tools:
+ tool_infos.append(
+ {
+ "name": t.name,
+ "description": t.description or "",
+ }
+ )
+
+ return tool_infos
+
+
+def discover_tools_info(config: dict) -> list[dict]:
+ """Sync wrapper around async_discover_tools_info."""
+ return _run_async(async_discover_tools_info(config))
+
+
+def format_tool_list(
+ tool_infos: list[dict],
+ enabled_tools: Optional[list[str]] = None,
+) -> str:
+ """Format tool info dicts into a readable HTML string.
+
+ Args:
+ tool_infos: List of dicts with 'name' and 'description' keys.
+ enabled_tools: If provided, marks which tools are enabled.
+ """
+ lines = [f"✅ Connected! Found {len(tool_infos)} tool(s):
"]
+ for t in tool_infos:
+ desc = (t.get("description") or "No description")[:120]
+ if enabled_tools is not None:
+ check = "✅" if t["name"] in enabled_tools else "⬜"
+ lines.append(f" {check} {t['name']} — {desc}
")
+ else:
+ lines.append(f" • {t['name']} — {desc}
")
+ if enabled_tools is not None:
+ enabled_count = sum(1 for t in tool_infos if t["name"] in enabled_tools)
+ lines.append(
+ f"
{enabled_count}/{len(tool_infos)} tool(s) enabled. "
+ 'Add "enabled_tools": ["tool_name", ...] '
+ "to your config JSON to limit tools."
+ )
+ else:
+ lines.append(
+ "
All tools enabled. Add "
+ '"enabled_tools": ["tool_name", ...] '
+ "to your config JSON to limit tools."
+ )
+ return "".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# MCPTool class
+# ---------------------------------------------------------------------------
+
+
+class MCPTool(BaseTool):
+ """A kotaemon BaseTool wrapper around a single MCP server tool.
+
+ This tool holds the MCP server configuration and establishes
+ a connection to invoke the tool on demand.
+
+ Example usage::
+
+ tool = MCPTool(
+ name="search",
+ description="Search the web",
+ server_transport="stdio",
+ server_command="uvx",
+ server_args=["mcp-server-fetch"],
+ mcp_tool_name="fetch",
+ )
+ result = tool.run("https://example.com")
+ """
+
+ name: str = ""
+ description: str = ""
+ args_schema: Optional[Type[BaseModel]] = None
+
+ # MCP server connection details
+ server_transport: str = "stdio"
+ server_command: str = ""
+ server_args: list[str] = []
+ server_env: dict[str, str] = {}
+
+ # The original MCP tool name (on the server)
+ mcp_tool_name: str = ""
+
+ def _run_tool(self, *args: Any, **kwargs: Any) -> str:
+ """Invoke the MCP tool by establishing a session."""
+ return _run_async(self._arun_tool(*args, **kwargs))
+
+ async def _arun_tool(self, *args: Any, **kwargs: Any) -> str:
+ """Async implementation that connects to the MCP server and calls
+ the tool."""
+ from mcp import ClientSession
+ from mcp.client.sse import sse_client
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+
+ # Build tool arguments
+ if args and isinstance(args[0], str):
+ try:
+ tool_args = json.loads(args[0])
+ except json.JSONDecodeError:
+ # If not JSON, assume single string argument
+ if self.args_schema:
+ first_field = next(iter(self.args_schema.model_fields.keys()))
+ tool_args = {first_field: args[0]}
+ else:
+ tool_args = {"input": args[0]}
+ else:
+ tool_args = kwargs
+
+ if self.server_transport == "stdio":
+ cmd = self.server_command
+ cmd_args = self.server_args
+ # Auto-split if full command string with no separate args
+ if not cmd_args and " " in cmd:
+ parts = shlex.split(cmd)
+ cmd = parts[0]
+ cmd_args = parts[1:]
+
+ server_params = StdioServerParameters(
+ command=cmd,
+ args=cmd_args,
+ env=self.server_env if self.server_env else None,
+ )
+ async with stdio_client(server_params) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.call_tool(self.mcp_tool_name, tool_args)
+ return self._format_result(result)
+ elif self.server_transport == "sse":
+ async with sse_client(url=self.server_command) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.call_tool(self.mcp_tool_name, tool_args)
+ return self._format_result(result)
+ else:
+ return f"Unsupported transport: {self.server_transport}"
+
+ def _format_result(self, result: Any) -> str:
+ """Format MCP CallToolResult into a string."""
+ if result.isError:
+ return f"MCP Tool Error: {result.content}"
+
+ parts = []
+ for content in result.content:
+ if hasattr(content, "text"):
+ parts.append(content.text)
+ elif hasattr(content, "data"):
+ parts.append(f"[Binary data: {content.mimeType}]")
+ else:
+ parts.append(str(content))
+ return "\n".join(parts)
diff --git a/libs/kotaemon/kotaemon/loaders/base.py b/libs/kotaemon/kotaemon/loaders/base.py
index aebf9196..7f000563 100644
--- a/libs/kotaemon/kotaemon/loaders/base.py
+++ b/libs/kotaemon/kotaemon/loaders/base.py
@@ -17,12 +17,26 @@ class AutoReader(BaseReader):
"""General auto reader for a variety of files. (based on llama-hub)"""
def __init__(self, reader_type: Union[str, Type["LIBaseReader"]]) -> None:
- """Init reader using string identifier or class name from llama-hub"""
+ """Init reader using string identifier or class name from llama-hub.
+
+ When a string is given, first attempts a direct import from
+ ``llama_index.readers.file`` (works in uv/pip-less venvs where the
+ package is already installed). Falls back to the deprecated
+ ``download_loader`` only if the direct import fails.
+ """
+ import importlib
if isinstance(reader_type, str):
- from llama_index.core import download_loader
+ # Try direct import first — avoids pip-install side-effect of
+ # download_loader, which fails in venvs without pip (e.g. uv).
+ try:
+ module = importlib.import_module("llama_index.readers.file")
+ reader_cls = getattr(module, reader_type)
+ self._reader = reader_cls()
+ except (ImportError, AttributeError):
+ from llama_index.core import download_loader
- self._reader = download_loader(reader_type)()
+ self._reader = download_loader(reader_type)()
else:
self._reader = reader_type()
super().__init__()
diff --git a/libs/kotaemon/pyproject.toml b/libs/kotaemon/pyproject.toml
index a4d9fe2d..cb406c54 100644
--- a/libs/kotaemon/pyproject.toml
+++ b/libs/kotaemon/pyproject.toml
@@ -28,16 +28,16 @@ dependencies = [
"cookiecutter>=2.6.0,<2.7",
"fast_langdetect",
"fastapi<=0.112.1",
- "gradio>=4.31.0,<4.40",
+ "gradio>=4.31.0,<5",
"html2text==2024.2.26",
- "langchain>=0.1.16,<0.2.16",
- "langchain-community>=0.0.34,<=0.2.11",
- "langchain-openai>=0.1.4,<0.2.0",
- "langchain-google-genai>=1.0.3,<2.0.0",
- "langchain-anthropic",
- "langchain-ollama",
- "langchain-mistralai",
- "langchain-cohere>=0.2.4,<0.3.0",
+ "langchain<2",
+ "langchain-community<1",
+ "langchain-openai<2",
+ "langchain-google-genai<5",
+ "langchain-anthropic<2",
+ "langchain-ollama<2",
+ "langchain-mistralai<2",
+ "langchain-cohere<1",
"llama-hub>=0.0.79,<0.1.0",
"llama-index>=0.10.40,<0.11.0",
"chromadb<=0.5.16",
@@ -86,6 +86,7 @@ adv = [
"llama-index>=0.10.40,<0.11.0",
"llama-index-vector-stores-milvus",
"llama-index-vector-stores-qdrant",
+ "mcp[cli]>=1.0.0",
"sentence-transformers",
"tabulate",
"unstructured>=0.15.8,<0.16",
diff --git a/libs/kotaemon/tests/test_mcp_manager.py b/libs/kotaemon/tests/test_mcp_manager.py
new file mode 100644
index 00000000..7e16af95
--- /dev/null
+++ b/libs/kotaemon/tests/test_mcp_manager.py
@@ -0,0 +1,186 @@
+"""Tests for ktem.mcp.manager module.
+
+Uses an in-memory SQLite engine to test MCPManager CRUD operations
+without depending on the application's database.
+"""
+
+import pytest
+from sqlalchemy import JSON, Column, String, create_engine
+from sqlalchemy.orm import DeclarativeBase, Session
+
+# ---------------------------------------------------------------------------
+# In-memory DB setup (mirrors ktem.mcp.db but fully isolated)
+# ---------------------------------------------------------------------------
+
+
+class _Base(DeclarativeBase):
+ pass
+
+
+class _MCPTable(_Base):
+ __tablename__ = "mcp_table"
+ name = Column(String, primary_key=True, unique=True)
+ config = Column(JSON, default={})
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def manager():
+ """Fresh manager with a clean in-memory DB for each test."""
+ engine = create_engine("sqlite:///:memory:")
+ _MCPTable.metadata.create_all(engine)
+ return MCPManagerForTest(engine)
+
+
+# ---------------------------------------------------------------------------
+# Minimal MCPManager that uses the test engine
+# ---------------------------------------------------------------------------
+
+
+class MCPManagerForTest:
+ """Same logic as ktem.mcp.manager.MCPManager but uses our test engine."""
+
+ def __init__(self, engine):
+ self._engine = engine
+ self._info: dict[str, dict] = {}
+ self.load()
+
+ def load(self):
+ self._info = {}
+ with Session(self._engine) as session:
+ for item in session.query(_MCPTable).all():
+ self._info[item.name] = { # type: ignore[index]
+ "name": item.name,
+ "config": item.config,
+ }
+
+ def info(self) -> dict:
+ return self._info
+
+ def get(self, name: str) -> dict | None:
+ return self._info.get(name)
+
+ def add(self, name: str, config: dict):
+ name = name.strip()
+ if not name:
+ raise ValueError("Name must not be empty")
+ with Session(self._engine) as session:
+ session.add(_MCPTable(name=name, config=config))
+ session.commit()
+ self.load()
+
+ def update(self, name: str, config: dict):
+ if not name:
+ raise ValueError("Name must not be empty")
+ with Session(self._engine) as session:
+ item = session.query(_MCPTable).filter_by(name=name).first()
+ if not item:
+ raise ValueError(f"MCP server '{name}' not found")
+ item.config = config # type: ignore[assignment]
+ session.commit()
+ self.load()
+
+ def delete(self, name: str):
+ with Session(self._engine) as session:
+ item = session.query(_MCPTable).filter_by(name=name).first()
+ if item:
+ session.delete(item)
+ session.commit()
+ self.load()
+
+ def get_enabled_tools(self) -> list[str]:
+ return [
+ f"[MCP] {name}"
+ for name, entry in self._info.items()
+ if entry.get("config", {}).get("enabled_tools") is not None
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestMCPManagerAdd:
+ def test_add_and_retrieve(self, manager):
+ """add() persists data; get() and info() reflect it."""
+ manager.add("server1", {"command": "uvx", "args": ["mcp-server-fetch"]})
+ assert manager.info()["server1"]["config"]["command"] == "uvx"
+ assert manager.get("server1")["name"] == "server1"
+
+ def test_add_multiple(self, manager):
+ manager.add("s1", {"command": "cmd1"})
+ manager.add("s2", {"command": "cmd2"})
+ assert set(manager.info().keys()) == {"s1", "s2"}
+
+ @pytest.mark.parametrize("name", ["", " "])
+ def test_empty_or_whitespace_name_raises(self, manager, name):
+ with pytest.raises(ValueError, match="Name must not be empty"):
+ manager.add(name, {})
+
+ def test_whitespace_name_is_stripped(self, manager):
+ manager.add(" server1 ", {"command": "uvx"})
+ assert "server1" in manager.info()
+
+ def test_complex_config_stored_correctly(self, manager):
+ config = {
+ "command": "uvx",
+ "env": {"JIRA_URL": "https://example.atlassian.net"},
+ "enabled_tools": ["jira_search"],
+ }
+ manager.add("atlassian", config)
+ stored = manager.get("atlassian")["config"]
+ assert stored["env"]["JIRA_URL"] == "https://example.atlassian.net"
+ assert stored["enabled_tools"] == ["jira_search"]
+
+
+class TestMCPManagerUpdateDelete:
+ def test_update_changes_config(self, manager):
+ manager.add("s1", {"command": "cmd1"})
+ manager.add("s2", {"command": "cmd2"})
+ manager.update("s1", {"command": "updated"})
+ assert manager.info()["s1"]["config"]["command"] == "updated"
+ assert manager.info()["s2"]["config"]["command"] == "cmd2" # untouched
+
+ def test_update_nonexistent_raises(self, manager):
+ with pytest.raises(ValueError, match="not found"):
+ manager.update("ghost", {})
+
+ def test_delete_removes_entry(self, manager):
+ manager.add("s1", {})
+ manager.add("s2", {})
+ manager.delete("s1")
+ assert "s1" not in manager.info()
+ assert "s2" in manager.info()
+
+ def test_delete_nonexistent_is_noop(self, manager):
+ manager.delete("ghost") # must not raise
+ assert len(manager.info()) == 0
+
+
+class TestMCPManagerGetEnabledTools:
+ def test_only_servers_with_enabled_tools_listed(self, manager):
+ manager.add("no_filter", {"command": "uvx"})
+ manager.add("with_filter", {"command": "uvx", "enabled_tools": ["tool_a"]})
+ choices = manager.get_enabled_tools()
+ assert "[MCP] no_filter" not in choices
+ assert "[MCP] with_filter" in choices
+
+ def test_empty_when_no_servers(self, manager):
+ assert manager.get_enabled_tools() == []
+
+
+class TestMCPManagerLoad:
+ def test_load_picks_up_external_db_changes(self, manager):
+ manager.add("server1", {})
+ with Session(manager._engine) as session:
+ session.add(_MCPTable(name="external", config={"command": "ext"}))
+ session.commit()
+
+ assert "external" not in manager.info() # not yet refreshed
+ manager.load()
+ assert "external" in manager.info()
diff --git a/libs/kotaemon/tests/test_mcp_tools.py b/libs/kotaemon/tests/test_mcp_tools.py
new file mode 100644
index 00000000..9ccdb72c
--- /dev/null
+++ b/libs/kotaemon/tests/test_mcp_tools.py
@@ -0,0 +1,300 @@
+"""Tests for kotaemon.agents.tools.mcp module.
+
+Covers config parsing, JSON Schema -> Pydantic model building,
+tool formatting, and MCPTool construction (without real MCP servers).
+"""
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from kotaemon.agents.tools.mcp import (
+ MCPTool,
+ _json_schema_type_to_python,
+ _make_tool,
+ build_args_model,
+ create_tools_from_config,
+ format_tool_list,
+ parse_mcp_config,
+)
+
+# ---------------------------------------------------------------------------
+# _json_schema_type_to_python — parametrized to avoid 7 near-identical tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "json_type, expected",
+ [
+ ("string", str),
+ ("integer", int),
+ ("number", float),
+ ("boolean", bool),
+ ("object", dict),
+ ("array", list),
+ ("unknown_type", str), # fallback
+ ],
+)
+def test_json_schema_type_to_python(json_type, expected):
+ assert _json_schema_type_to_python(json_type) is expected
+
+
+# ---------------------------------------------------------------------------
+# build_args_model
+# ---------------------------------------------------------------------------
+
+
+class TestBuildArgsModel:
+ def test_model_fields_and_name(self):
+ """Required + optional fields and the generated model name."""
+ schema = {
+ "properties": {
+ "url": {"type": "string", "description": "The URL to fetch"},
+ "timeout": {"type": "integer", "description": "Timeout in seconds"},
+ },
+ "required": ["url"],
+ }
+ model = build_args_model("fetch", schema)
+ assert model.__name__ == "MCPArgs_fetch"
+ assert model.model_fields["url"].is_required()
+ assert not model.model_fields["timeout"].is_required()
+
+ def test_optional_field_preserves_default(self):
+ schema = {
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "description": "Max results",
+ "default": 10,
+ },
+ },
+ "required": [],
+ }
+ assert build_args_model("search", schema).model_fields["limit"].default == 10
+
+ def test_empty_schema_produces_no_fields(self):
+ assert len(build_args_model("empty", {}).model_fields) == 0
+
+
+# ---------------------------------------------------------------------------
+# parse_mcp_config
+# ---------------------------------------------------------------------------
+
+
+class TestParseMcpConfig:
+ def test_full_stdio_config(self):
+ config = {
+ "transport": "stdio",
+ "command": "uvx",
+ "args": ["mcp-server-fetch"],
+ "env": {"KEY": "value"},
+ }
+ parsed = parse_mcp_config(config)
+ assert parsed == {
+ "transport": "stdio",
+ "command": "uvx",
+ "args": ["mcp-server-fetch"],
+ "env": {"KEY": "value"},
+ }
+
+ def test_defaults_for_empty_config(self):
+ parsed = parse_mcp_config({})
+ assert parsed["transport"] == "stdio"
+ assert parsed["command"] == ""
+ assert parsed["args"] == []
+ assert parsed["env"] == {}
+
+ def test_auto_split_multi_word_command(self):
+ """stdio with no explicit args: space-delimited command is split."""
+ parsed = parse_mcp_config(
+ {"command": "npx -y mcp-remote https://example.com/sse"}
+ )
+ assert parsed["command"] == "npx"
+ assert parsed["args"] == ["-y", "mcp-remote", "https://example.com/sse"]
+
+ def test_no_split_when_args_already_provided(self):
+ """Explicit args suppress the auto-split."""
+ parsed = parse_mcp_config(
+ {
+ "command": "npx -y mcp-remote https://example.com/sse",
+ "args": ["--flag"],
+ }
+ )
+ assert parsed["command"] == "npx -y mcp-remote https://example.com/sse"
+ assert parsed["args"] == ["--flag"]
+
+ def test_sse_transport_uses_url_as_command(self):
+ """For SSE, the url field becomes the effective command."""
+ parsed = parse_mcp_config(
+ {
+ "transport": "sse",
+ "url": "http://localhost:8080/sse",
+ "command": "ignored",
+ }
+ )
+ assert parsed["transport"] == "sse"
+ assert parsed["command"] == "http://localhost:8080/sse"
+
+
+# ---------------------------------------------------------------------------
+# _make_tool
+# ---------------------------------------------------------------------------
+
+
+class TestMakeTool:
+ def test_creates_mcp_tool_with_schema(self):
+ parsed = {
+ "transport": "stdio",
+ "command": "uvx",
+ "args": ["mcp-server-fetch"],
+ "env": {},
+ }
+ tool_info = SimpleNamespace(
+ name="fetch",
+ description="Fetch a URL",
+ inputSchema={
+ "properties": {
+ "url": {"type": "string", "description": "URL to fetch"}
+ },
+ "required": ["url"],
+ },
+ )
+ tool = _make_tool(parsed, tool_info)
+
+ assert isinstance(tool, MCPTool)
+ assert tool.name == "fetch"
+ assert tool.description == "Fetch a URL"
+ assert tool.server_transport == "stdio"
+ assert tool.server_command == "uvx"
+ assert tool.server_args == ["mcp-server-fetch"]
+
+ def test_missing_schema_and_description_uses_defaults(self):
+ """No inputSchema → args_schema is None; None description → auto-generated."""
+ parsed = {"transport": "stdio", "command": "uvx", "args": [], "env": {}}
+ tool_info = SimpleNamespace(name="ping", description=None)
+ tool = _make_tool(parsed, tool_info)
+ assert tool.description == "MCP tool: ping"
+ assert tool.args_schema is None
+
+
+# ---------------------------------------------------------------------------
+# format_tool_list
+# ---------------------------------------------------------------------------
+
+
+class TestFormatToolList:
+ def test_all_tools_enabled_by_default(self):
+ tool_infos = [
+ {"name": "fetch", "description": "Fetch a URL"},
+ {"name": "search", "description": "Search the web"},
+ ]
+ result = format_tool_list(tool_infos)
+ assert "2" in result
+ assert "fetch" in result and "search" in result
+ assert "All tools enabled" in result
+
+ def test_partial_filter_shows_counts_and_icons(self):
+ tool_infos = [
+ {"name": "fetch", "description": "Fetch a URL"},
+ {"name": "search", "description": "Search the web"},
+ ]
+ result = format_tool_list(tool_infos, enabled_tools=["fetch"])
+ assert "1/2 tool(s) enabled" in result
+ assert "✅" in result # fetch enabled
+ assert "⬜" in result # search disabled
+
+ def test_long_description_is_truncated(self):
+ result = format_tool_list([{"name": "tool", "description": "A" * 200}])
+ assert "A" * 121 not in result
+
+ def test_none_description_shows_placeholder(self):
+ result = format_tool_list([{"name": "tool", "description": None}])
+ assert "No description" in result
+
+
+# ---------------------------------------------------------------------------
+# create_tools_from_config (mocked MCP server connection)
+# ---------------------------------------------------------------------------
+
+
+class TestCreateToolsFromConfig:
+ def _make_mock_tools(self):
+ return [
+ MCPTool(
+ name="fetch",
+ description="Fetch",
+ server_transport="stdio",
+ server_command="uvx",
+ mcp_tool_name="fetch",
+ ),
+ MCPTool(
+ name="search",
+ description="Search",
+ server_transport="stdio",
+ server_command="uvx",
+ mcp_tool_name="search",
+ ),
+ ]
+
+ @patch("kotaemon.agents.tools.mcp._run_async")
+ def test_no_filter_returns_all(self, mock_run_async):
+ mock_run_async.return_value = self._make_mock_tools()
+ tools = create_tools_from_config({"command": "uvx"})
+ assert len(tools) == 2
+
+ @patch("kotaemon.agents.tools.mcp._run_async")
+ def test_enabled_tools_filter(self, mock_run_async):
+ """Non-empty filter returns only nominated tools; empty list returns all."""
+ mock_run_async.return_value = self._make_mock_tools()
+ filtered = create_tools_from_config({"command": "uvx"}, enabled_tools=["fetch"])
+ assert len(filtered) == 1
+ assert filtered[0].mcp_tool_name == "fetch"
+
+ # Empty list == no filter
+ mock_run_async.return_value = self._make_mock_tools()
+ all_tools = create_tools_from_config({"command": "uvx"}, enabled_tools=[])
+ assert len(all_tools) == 2
+
+
+# ---------------------------------------------------------------------------
+# MCPTool._format_result
+# ---------------------------------------------------------------------------
+
+
+class TestMCPToolFormatResult:
+ def _make_tool(self):
+ return MCPTool(
+ name="test",
+ description="Test tool",
+ server_transport="stdio",
+ server_command="echo",
+ mcp_tool_name="test",
+ )
+
+ def test_text_content_joined(self):
+ result = self._make_tool()._format_result(
+ SimpleNamespace(
+ isError=False,
+ content=[SimpleNamespace(text="Hello"), SimpleNamespace(text="World")],
+ )
+ )
+ assert result == "Hello\nWorld"
+
+ def test_error_flag(self):
+ result = self._make_tool()._format_result(
+ SimpleNamespace(
+ isError=True,
+ content="Something went wrong",
+ )
+ )
+ assert "MCP Tool Error" in result
+
+ def test_binary_content(self):
+ result = self._make_tool()._format_result(
+ SimpleNamespace(
+ isError=False,
+ content=[SimpleNamespace(data=b"bytes", mimeType="image/png")],
+ )
+ )
+ assert "[Binary data: image/png]" in result
diff --git a/libs/ktem/ktem/embeddings/manager.py b/libs/ktem/ktem/embeddings/manager.py
index f7107fd7..023dca6b 100644
--- a/libs/ktem/ktem/embeddings/manager.py
+++ b/libs/ktem/ktem/embeddings/manager.py
@@ -184,11 +184,21 @@ class EmbeddingManager:
self.load()
- def update(self, name: str, spec: dict, default: bool):
- """Update a model in the pool"""
+ def update(self, name: str, spec: dict, default: bool, new_name: str = ""):
+ """Update a model in the pool, optionally renaming it."""
if not name:
raise ValueError("Name must not be empty")
+ # If update name
+ if new_name and new_name != name:
+ if new_name in self._info:
+ raise ValueError(
+ f"Model '{new_name}' already exists. Use a unique name."
+ )
+ self.delete(name)
+ self.add(new_name, spec=spec, default=default)
+ return
+
try:
with Session(engine) as sess:
diff --git a/libs/ktem/ktem/embeddings/ui.py b/libs/ktem/ktem/embeddings/ui.py
index c97e75b1..0d519b38 100644
--- a/libs/ktem/ktem/embeddings/ui.py
+++ b/libs/ktem/ktem/embeddings/ui.py
@@ -33,6 +33,7 @@ class EmbeddingManagement(BasePage):
self.emb_list = gr.DataFrame(
headers=["name", "vendor", "default"],
interactive=False,
+ column_widths=[30, 40, 30],
)
with gr.Column(visible=False) as self._selected_panel:
@@ -47,6 +48,10 @@ class EmbeddingManagement(BasePage):
"if no Embedding is specified for such components."
),
)
+ self.edit_name = gr.Textbox(
+ label="Name",
+ info="Edit to rename this Embedding model.",
+ )
self.edit_spec = gr.Textbox(
label="Specification",
info="Specification of the Embedding model in YAML format",
@@ -184,10 +189,10 @@ class EmbeddingManagement(BasePage):
self.btn_delete_yes,
self.btn_delete_no,
# edit section
+ self.edit_name,
self.edit_spec,
self.edit_spec_desc,
self.edit_default,
- self._check_connection_panel,
],
show_progress="hidden",
).success(lambda: gr.update(value=""), outputs=[self.connection_logs])
@@ -222,9 +227,11 @@ class EmbeddingManagement(BasePage):
self.save_emb,
inputs=[
self.selected_emb_name,
+ self.edit_name,
self.edit_default,
self.edit_spec,
],
+ outputs=[self.selected_emb_name],
show_progress="hidden",
).then(
self.list_embeddings,
@@ -244,6 +251,7 @@ class EmbeddingManagement(BasePage):
def create_emb(self, name, choices, spec, default):
try:
+ name = name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = (
embedding_models_manager.vendors()[choices].__module__
@@ -252,9 +260,11 @@ class EmbeddingManagement(BasePage):
)
embedding_models_manager.add(name, spec=spec, default=default)
- gr.Info(f'Create Embedding model "{name}" successfully')
+ gr.Info(f'Embedding model "{name}" created successfully')
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- raise gr.Error(f"Failed to create Embedding model {name}: {e}")
+ raise gr.Error(f"Failed to create Embedding model '{name}': {e}")
def list_embeddings(self):
"""List the Embedding models"""
@@ -287,17 +297,16 @@ class EmbeddingManagement(BasePage):
def on_selected_emb_change(self, selected_emb_name):
if selected_emb_name == "":
- _check_connection_panel = gr.update(visible=False)
_selected_panel = gr.update(visible=False)
_selected_panel_btn = gr.update(visible=False)
btn_delete = gr.update(visible=True)
btn_delete_yes = gr.update(visible=False)
btn_delete_no = gr.update(visible=False)
+ edit_name = gr.update(value="")
edit_spec = gr.update(value="")
edit_spec_desc = gr.update(value="")
edit_default = gr.update(value=False)
else:
- _check_connection_panel = gr.update(visible=True)
_selected_panel = gr.update(visible=True)
_selected_panel_btn = gr.update(visible=True)
btn_delete = gr.update(visible=True)
@@ -308,6 +317,7 @@ class EmbeddingManagement(BasePage):
vendor_str = info["spec"].pop("__type__", "-").split(".")[-1]
vendor = embedding_models_manager.vendors()[vendor_str]
+ edit_name = selected_emb_name
edit_spec = yaml.dump(info["spec"])
edit_spec_desc = format_description(vendor)
edit_default = info["default"]
@@ -318,10 +328,10 @@ class EmbeddingManagement(BasePage):
btn_delete,
btn_delete_yes,
btn_delete_no,
+ edit_name,
edit_spec,
edit_spec_desc,
edit_default,
- _check_connection_panel,
)
def on_btn_delete_click(self):
@@ -370,18 +380,25 @@ class EmbeddingManagement(BasePage):
return log_content
- def save_emb(self, selected_emb_name, default, spec):
+ def save_emb(self, selected_emb_name, edit_name, default, spec):
try:
+ new_name = edit_name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = embedding_models_manager.info()[selected_emb_name][
"spec"
]["__type__"]
embedding_models_manager.update(
- selected_emb_name, spec=spec, default=default
+ selected_emb_name, spec=spec, default=default, new_name=new_name
)
- gr.Info(f'Save Embedding model "{selected_emb_name}" successfully')
+ final_name = (
+ new_name if new_name != selected_emb_name else selected_emb_name
+ )
+ gr.Info(f'Embedding model "{final_name}" saved successfully')
+ return final_name
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- gr.Error(f'Failed to save Embedding model "{selected_emb_name}": {e}')
+ raise gr.Error(f'Failed to save Embedding model "{selected_emb_name}": {e}')
def delete_emb(self, selected_emb_name):
try:
diff --git a/libs/ktem/ktem/index/file/ui.py b/libs/ktem/ktem/index/file/ui.py
index deb1ac34..3b51a28f 100644
--- a/libs/ktem/ktem/index/file/ui.py
+++ b/libs/ktem/ktem/index/file/ui.py
@@ -182,7 +182,7 @@ class FileIndexPage(BasePage):
"loader",
"date_created",
],
- column_widths=["0%", "50%", "8%", "7%", "15%", "20%"],
+ column_widths=[0, 50, 8, 7, 15, 20],
interactive=False,
wrap=False,
elem_id="file_list_view",
@@ -241,7 +241,7 @@ class FileIndexPage(BasePage):
"files",
"date_created",
],
- column_widths=["0%", "25%", "55%", "20%"],
+ column_widths=[0, 25, 55, 20],
interactive=False,
wrap=False,
)
diff --git a/libs/ktem/ktem/index/ui.py b/libs/ktem/ktem/index/ui.py
index 47f7d9a2..6cf4f690 100644
--- a/libs/ktem/ktem/index/ui.py
+++ b/libs/ktem/ktem/index/ui.py
@@ -46,6 +46,7 @@ class IndexManagement(BasePage):
self.index_list = gr.DataFrame(
headers=["id", "name", "index type"],
interactive=False,
+ column_widths=[10, 30, 60],
)
with gr.Column(visible=False) as self._selected_panel:
@@ -238,22 +239,24 @@ class IndexManagement(BasePage):
return yaml.dump(required, sort_keys=False), format_description(index_type_cls)
def create_index(self, name: str, index_type: str, config: str):
- """Create the index
+ """Create the index"""
+ name = name.strip()
+ if not name:
+ raise gr.Error("Name must not be empty")
+
+ existing_names = {idx.name for idx in self.manager.indices}
+ if name in existing_names:
+ raise gr.Error(f"Index '{name}' already exists. Please use a unique name.")
- Args:
- name: the name of the index
- index_type: the type of the index
- config: the expected config of the index
- """
try:
self.manager.build_index(
name=name,
config=yaml.load(config, Loader=YAMLNoDateSafeLoader),
index_type=index_type,
)
- gr.Info(f'Create index "{name}" successfully. Please restart the app!')
+ gr.Info(f'Index "{name}" created successfully. Please restart the app!')
except Exception as e:
- raise gr.Error(f"Failed to create Embedding model {name}: {e}")
+ raise gr.Error(f'Failed to create index "{name}": {e}')
def list_indices(self):
"""List the indices constructed by the user"""
@@ -311,10 +314,23 @@ class IndexManagement(BasePage):
)
def update_index(self, selected_index_id: int, name: str, config: str):
+ name = name.strip()
+ if not name:
+ raise gr.Error("Name must not be empty")
+
+ # Check uniqueness (excluding current index)
+ for idx in self.manager.indices:
+ if idx.name == name and idx.id != selected_index_id:
+ raise gr.Error(
+ f"Index '{name}' already exists. Please use a unique name."
+ )
+
try:
spec = yaml.load(config, Loader=YAMLNoDateSafeLoader)
self.manager.update_index(selected_index_id, name, spec)
- gr.Info(f'Update index "{name}" successfully. Please restart the app!')
+ gr.Info(f'Index "{name}" updated successfully. Please restart the app!')
+ except gr.Error:
+ raise
except Exception as e:
raise gr.Error(f'Failed to save index "{name}": {e}')
diff --git a/libs/ktem/ktem/llms/manager.py b/libs/ktem/ktem/llms/manager.py
index 317d970f..6c723c25 100644
--- a/libs/ktem/ktem/llms/manager.py
+++ b/libs/ktem/ktem/llms/manager.py
@@ -160,7 +160,6 @@ class LLMManager:
def add(self, name: str, spec: dict, default: bool):
"""Add a new model to the pool"""
- name = name.strip()
if not name:
raise ValueError("Name must not be empty")
@@ -192,11 +191,21 @@ class LLMManager:
self.load()
- def update(self, name: str, spec: dict, default: bool):
- """Update a model in the pool"""
+ def update(self, name: str, spec: dict, default: bool, new_name: str = ""):
+ """Update a model in the pool, optionally renaming it."""
if not name:
raise ValueError("Name must not be empty")
+ if new_name and new_name != name:
+ # Check uniqueness before destructive delete
+ if new_name in self._info:
+ raise ValueError(
+ f"Model '{new_name}' already exists. Use a unique name."
+ )
+ self.delete(name)
+ self.add(new_name, spec=spec, default=default)
+ return
+
try:
with Session(engine) as session:
diff --git a/libs/ktem/ktem/llms/ui.py b/libs/ktem/ktem/llms/ui.py
index d29babba..abd48d22 100644
--- a/libs/ktem/ktem/llms/ui.py
+++ b/libs/ktem/ktem/llms/ui.py
@@ -33,6 +33,7 @@ class LLMManagement(BasePage):
self.llm_list = gr.DataFrame(
headers=["name", "vendor", "default"],
interactive=False,
+ column_widths=[30, 40, 30],
)
with gr.Column(visible=False) as self._selected_panel:
@@ -42,10 +43,16 @@ class LLMManagement(BasePage):
self.edit_default = gr.Checkbox(
label="Set default",
info=(
- "Set this LLM as default. If no default is set, a "
- "random LLM will be used."
+ "Set this LLM as default. If no default is set, "
+ "a random LLM will be used. "
+ "This default LLM will be used by other components "
+ "by default if no LLM is specified for such components."
),
)
+ self.edit_name = gr.Textbox(
+ label="Name",
+ info="Edit to rename this LLM.",
+ )
self.edit_spec = gr.Textbox(
label="Specification",
info="Specification of the LLM in YAML format",
@@ -181,11 +188,10 @@ class LLMManagement(BasePage):
self.btn_delete_yes,
self.btn_delete_no,
# edit section
+ self.edit_name,
self.edit_spec,
self.edit_spec_desc,
self.edit_default,
- # check connection panel
- self._check_connection_panel,
],
show_progress="hidden",
).success(lambda: gr.update(value=""), outputs=[self.connection_logs])
@@ -220,9 +226,11 @@ class LLMManagement(BasePage):
self.save_llm,
inputs=[
self.selected_llm_name,
+ self.edit_name,
self.edit_default,
self.edit_spec,
],
+ outputs=[self.selected_llm_name],
show_progress="hidden",
).then(
self.list_llms,
@@ -242,6 +250,7 @@ class LLMManagement(BasePage):
def create_llm(self, name, choices, spec, default):
try:
+ name = name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = (
llms.vendors()[choices].__module__
@@ -250,9 +259,11 @@ class LLMManagement(BasePage):
)
llms.add(name, spec=spec, default=default)
- gr.Info(f"LLM {name} created successfully")
+ gr.Info(f"LLM '{name}' created successfully")
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- raise gr.Error(f"Failed to create LLM {name}: {e}")
+ raise gr.Error(f"Failed to create LLM '{name}': {e}")
def list_llms(self):
"""List the LLMs"""
@@ -285,17 +296,16 @@ class LLMManagement(BasePage):
def on_selected_llm_change(self, selected_llm_name):
if selected_llm_name == "":
- _check_connection_panel = gr.update(visible=False)
_selected_panel = gr.update(visible=False)
_selected_panel_btn = gr.update(visible=False)
btn_delete = gr.update(visible=True)
btn_delete_yes = gr.update(visible=False)
btn_delete_no = gr.update(visible=False)
+ edit_name = gr.update(value="")
edit_spec = gr.update(value="")
edit_spec_desc = gr.update(value="")
edit_default = gr.update(value=False)
else:
- _check_connection_panel = gr.update(visible=True)
_selected_panel = gr.update(visible=True)
_selected_panel_btn = gr.update(visible=True)
btn_delete = gr.update(visible=True)
@@ -306,6 +316,7 @@ class LLMManagement(BasePage):
vendor_str = info["spec"].pop("__type__", "-").split(".")[-1]
vendor = llms.vendors()[vendor_str]
+ edit_name = selected_llm_name
edit_spec = yaml.dump(info["spec"])
edit_spec_desc = format_description(vendor)
edit_default = info["default"]
@@ -316,10 +327,10 @@ class LLMManagement(BasePage):
btn_delete,
btn_delete_yes,
btn_delete_no,
+ edit_name,
edit_spec,
edit_spec_desc,
edit_default,
- _check_connection_panel,
)
def on_btn_delete_click(self):
@@ -368,14 +379,23 @@ class LLMManagement(BasePage):
return log_content
- def save_llm(self, selected_llm_name, default, spec):
+ def save_llm(self, selected_llm_name, edit_name, default, spec):
try:
+ new_name = edit_name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = llms.info()[selected_llm_name]["spec"]["__type__"]
- llms.update(selected_llm_name, spec=spec, default=default)
- gr.Info(f"LLM {selected_llm_name} saved successfully")
+ llms.update(
+ selected_llm_name, spec=spec, default=default, new_name=new_name
+ )
+ final_name = (
+ new_name if new_name != selected_llm_name else selected_llm_name
+ )
+ gr.Info(f"LLM '{final_name}' saved successfully")
+ return final_name
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- raise gr.Error(f"Failed to save LLM {selected_llm_name}: {e}")
+ raise gr.Error(f"Failed to save LLM '{selected_llm_name}': {e}")
def delete_llm(self, selected_llm_name):
try:
diff --git a/libs/ktem/ktem/mcp/__init__.py b/libs/ktem/ktem/mcp/__init__.py
new file mode 100644
index 00000000..9d458c58
--- /dev/null
+++ b/libs/ktem/ktem/mcp/__init__.py
@@ -0,0 +1 @@
+# MCP (Model Context Protocol) integration for kotaemon
diff --git a/libs/ktem/ktem/mcp/db.py b/libs/ktem/ktem/mcp/db.py
new file mode 100644
index 00000000..20578665
--- /dev/null
+++ b/libs/ktem/ktem/mcp/db.py
@@ -0,0 +1,31 @@
+from ktem.db.engine import engine
+from sqlalchemy import JSON, Column, String
+from sqlalchemy import inspect as sa_inspect
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class BaseMCPTable(Base):
+ """Base table to store MCP server configurations"""
+
+ __abstract__ = True
+
+ name = Column(String, primary_key=True, unique=True)
+ config = Column(JSON, default={}) # Full JSON config for the MCP server
+
+
+class MCPTable(BaseMCPTable):
+ __tablename__ = "mcp_table"
+
+
+# Drop and recreate to handle schema changes from old multi-column layout.
+_inspector = sa_inspect(engine)
+if _inspector.has_table("mcp_table"):
+ _columns = {col["name"] for col in _inspector.get_columns("mcp_table")}
+ if "config" not in _columns:
+ MCPTable.__table__.drop(engine) # type: ignore[attr-defined]
+
+MCPTable.metadata.create_all(engine)
diff --git a/libs/ktem/ktem/mcp/manager.py b/libs/ktem/ktem/mcp/manager.py
new file mode 100644
index 00000000..a4966964
--- /dev/null
+++ b/libs/ktem/ktem/mcp/manager.py
@@ -0,0 +1,92 @@
+"""Manager for MCP server configurations.
+
+Provides CRUD operations on the MCPTable.
+All tool building/discovery logic lives in kotaemon.agents.tools.mcp.
+"""
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .db import MCPTable, engine
+
+logger = logging.getLogger(__name__)
+
+
+class MCPManager:
+ """Manages MCP server configurations stored in the database."""
+
+ def __init__(self):
+ self._configs: dict[str, dict] = {}
+ self.load()
+
+ def load(self):
+ """Reload configurations from the database."""
+ self._info = {}
+ with Session(engine) as session:
+ stmt = select(MCPTable)
+ items = session.execute(stmt)
+ for (item,) in items:
+ self._info[item.name] = {
+ "name": item.name,
+ "config": item.config,
+ }
+
+ def info(self) -> dict:
+ """Return all MCP server configurations."""
+ return self._info
+
+ def get(self, name: str) -> dict | None:
+ """Get a single configuration by name."""
+ return self._info.get(name)
+
+ def add(self, name: str, config: dict):
+ """Add a new MCP server configuration."""
+ name = name.strip()
+ if not name:
+ raise ValueError("Name must not be empty")
+
+ with Session(engine) as session:
+ item = MCPTable(name=name, config=config)
+ session.add(item)
+ session.commit()
+
+ self.load()
+
+ def update(self, name: str, config: dict):
+ """Update an existing MCP server configuration."""
+ if not name:
+ raise ValueError("Name must not be empty")
+
+ with Session(engine) as session:
+ item = session.query(MCPTable).filter_by(name=name).first()
+ if not item:
+ raise ValueError(f"MCP server '{name}' not found")
+ item.config = config # type: ignore[assignment]
+ session.commit()
+
+ self.load()
+
+ def delete(self, name: str):
+ """Delete an MCP server configuration."""
+ with Session(engine) as session:
+ item = session.query(MCPTable).filter_by(name=name).first()
+ if item:
+ session.delete(item)
+ session.commit()
+
+ self.load()
+
+ def get_enabled_tools(self) -> list[str]:
+ """Return tool choice names for all MCP servers."""
+ choices = []
+ for name, entry in self._info.items():
+ config = entry.get("config", {})
+ enabled_tools = config.get("enabled_tools", None)
+ if enabled_tools is not None:
+ choices.append(f"[MCP] {name}")
+ return choices
+
+
+mcp_manager = MCPManager()
diff --git a/libs/ktem/ktem/mcp/ui.py b/libs/ktem/ktem/mcp/ui.py
new file mode 100644
index 00000000..21284f7c
--- /dev/null
+++ b/libs/ktem/ktem/mcp/ui.py
@@ -0,0 +1,349 @@
+import json
+import logging
+
+import gradio as gr
+import pandas as pd
+from ktem.app import BasePage
+
+from kotaemon.agents.tools.mcp import discover_tools_info, format_tool_list
+
+from .manager import mcp_manager
+
+logger = logging.getLogger(__name__)
+
+TOOLS_DEFAULT = "# Available Tools\n\nSelect or add an MCP server to view its tools."
+
+MCP_SERVERS_KEY = "mcpServers"
+
+EXAMPLE_CONFIG = """{
+ "mcpServers": {
+ }
+}"""
+
+
+class MCPManagement(BasePage):
+ def __init__(self, app):
+ self._app = app
+ self.on_building_ui()
+
+ def on_building_ui(self):
+ with gr.Tab(label="View"):
+ self.mcp_list = gr.DataFrame(
+ headers=["name", "config"],
+ interactive=False,
+ column_widths=[30, 70],
+ )
+
+ with gr.Column(visible=False) as self._selected_panel:
+ self.selected_mcp_name = gr.Textbox(value="", visible=False)
+ with gr.Row():
+ with gr.Column():
+ self.edit_config = gr.Code(
+ label="Configuration (JSON)",
+ language="json",
+ lines=10,
+ )
+
+ with gr.Row(visible=False) as self._selected_panel_btn:
+ with gr.Column():
+ self.btn_edit_save = gr.Button(
+ "Save", min_width=10, variant="primary"
+ )
+ with gr.Column():
+ self.btn_delete = gr.Button(
+ "Delete", min_width=10, variant="stop"
+ )
+ with gr.Row():
+ self.btn_delete_yes = gr.Button(
+ "Confirm Delete",
+ variant="stop",
+ visible=False,
+ min_width=10,
+ )
+ self.btn_delete_no = gr.Button(
+ "Cancel", visible=False, min_width=10
+ )
+ with gr.Column():
+ self.btn_close = gr.Button("Close", min_width=10)
+
+ with gr.Column():
+ self.edit_tools_display = gr.Markdown(TOOLS_DEFAULT)
+
+ with gr.Tab(label="Add"):
+ with gr.Row():
+ with gr.Column(scale=2):
+ self.config = gr.Code(
+ label="Configuration (JSON)",
+ language="json",
+ lines=10,
+ value=EXAMPLE_CONFIG,
+ )
+ gr.HTML(
+ "
"
+ ) # Fix: Prevent the overflow of the gr.Code affect click button
+ with gr.Row():
+ self.btn_new = gr.Button("Add MCP Servers", variant="primary")
+
+ with gr.Column(scale=3):
+ self.add_tools_display = gr.Markdown(TOOLS_DEFAULT)
+
+ def _on_app_created(self):
+ """Called when the app is created."""
+ self._app.app.load(
+ self.list_servers,
+ inputs=[],
+ outputs=[self.mcp_list],
+ )
+
+ def on_register_events(self):
+ # Add new server — save first, then fetch tools async
+ self.btn_new.click(
+ self.create_server,
+ inputs=[self.config],
+ outputs=[self.add_tools_display],
+ ).success(self.list_servers, inputs=[], outputs=[self.mcp_list]).then(
+ self.fetch_tools_for_add,
+ inputs=[self.config],
+ outputs=[self.add_tools_display],
+ ).then(
+ lambda: EXAMPLE_CONFIG,
+ outputs=[self.config],
+ )
+
+ # Select a server from list
+ self.mcp_list.select(
+ self.select_server,
+ inputs=self.mcp_list,
+ outputs=[self.selected_mcp_name],
+ show_progress="hidden",
+ )
+ self.selected_mcp_name.change(
+ self.on_selected_server_change,
+ inputs=[self.selected_mcp_name],
+ outputs=[
+ self._selected_panel,
+ self._selected_panel_btn,
+ self.btn_delete,
+ self.btn_delete_yes,
+ self.btn_delete_no,
+ self.edit_config,
+ self.edit_tools_display,
+ ],
+ show_progress="hidden",
+ ).then(
+ self.fetch_tools_for_view,
+ inputs=[self.selected_mcp_name],
+ outputs=[self.edit_tools_display],
+ )
+
+ # Delete flow
+ self.btn_delete.click(
+ self.on_btn_delete_click,
+ inputs=[],
+ outputs=[self.btn_delete, self.btn_delete_yes, self.btn_delete_no],
+ show_progress="hidden",
+ )
+ self.btn_delete_yes.click(
+ self.delete_server,
+ inputs=[self.selected_mcp_name],
+ outputs=[self.selected_mcp_name],
+ show_progress="hidden",
+ ).then(self.list_servers, inputs=[], outputs=[self.mcp_list])
+ self.btn_delete_no.click(
+ lambda: (
+ gr.update(visible=True),
+ gr.update(visible=False),
+ gr.update(visible=False),
+ ),
+ inputs=[],
+ outputs=[self.btn_delete, self.btn_delete_yes, self.btn_delete_no],
+ show_progress="hidden",
+ )
+
+ # Save edits — save first, then refresh tools
+ self.btn_edit_save.click(
+ self.save_server,
+ inputs=[self.selected_mcp_name, self.edit_config],
+ outputs=[self.edit_tools_display],
+ show_progress="hidden",
+ ).then(self.list_servers, inputs=[], outputs=[self.mcp_list]).then(
+ self.fetch_tools_for_view,
+ inputs=[self.selected_mcp_name],
+ outputs=[self.edit_tools_display],
+ )
+
+ # Close panel
+ self.btn_close.click(lambda: "", outputs=[self.selected_mcp_name])
+
+ # --- Handlers ---
+
+ def _fetch_tools_markdown(self, config: dict) -> str:
+ """Fetch tools from MCP server and return as formatted HTML."""
+ try:
+ tool_infos = discover_tools_info(config)
+ enabled_tools = config.get("enabled_tools", None)
+ return format_tool_list(tool_infos, enabled_tools)
+ except Exception as e:
+ return f"❌ Failed to fetch tools: {e}"
+
+ def create_server(self, config_str):
+ """Create server(s), show loading placeholder."""
+ try:
+ configs = json.loads(config_str)
+ except json.JSONDecodeError as e:
+ raise gr.Error(f"Invalid JSON: {e}")
+
+ if not isinstance(configs, dict) or MCP_SERVERS_KEY not in configs:
+ raise gr.Error(
+ f"Config must be a dictionary with '{MCP_SERVERS_KEY}' root key."
+ )
+
+ mcp_servers = configs[MCP_SERVERS_KEY]
+ if not isinstance(mcp_servers, dict):
+ raise gr.Error(
+ f"'{MCP_SERVERS_KEY}' must be a mapping of server names to configs."
+ )
+
+ # Validate that no names are empty before processing
+ for name in mcp_servers:
+ name = name.strip()
+ if not name:
+ raise gr.Error("Server names cannot be empty.")
+
+ success_count = 0
+ failed_count = 0
+ msgs = []
+ for name, config in mcp_servers.items():
+ name = name.strip()
+ if name in mcp_manager.info():
+ gr.Warning(f"MCP server '{name}' already exists. Skipping.")
+ failed_count += 1
+ continue
+
+ try:
+ mcp_manager.add(name, config)
+ success_count += 1
+ msgs.append(f"# Tools for '{name}'\n\n⏳ Fetching tools...")
+ except Exception as e:
+ gr.Warning(f"Failed to create MCP server '{name}': {e}")
+ failed_count += 1
+
+ if success_count > 0:
+ gr.Info(f"{success_count} MCP server(s) created successfully")
+
+ if not msgs:
+ return TOOLS_DEFAULT
+
+ return "\n\n".join(msgs)
+
+ def fetch_tools_for_add(self, config_str):
+ """Fetch tools after server was added (chained call)."""
+ if not config_str:
+ return TOOLS_DEFAULT
+ try:
+ configs = json.loads(config_str)
+ except json.JSONDecodeError:
+ return "❌ Invalid JSON config"
+
+ if not isinstance(configs, dict) or MCP_SERVERS_KEY not in configs:
+ return f"❌ Config must be a dictionary with '{MCP_SERVERS_KEY}' root key"
+
+ mcp_servers = configs[MCP_SERVERS_KEY]
+ if not isinstance(mcp_servers, dict):
+ return f"❌ '{MCP_SERVERS_KEY}' must be a dictionary"
+
+ msgs = []
+ for name, config in mcp_servers.items():
+ msgs.append(
+ f"# Tools for '{name.strip()}'\n\n{self._fetch_tools_markdown(config)}"
+ )
+ return "\n\n".join(msgs)
+
+ def fetch_tools_for_view(self, selected_name):
+ """Fetch tools for the View panel (chained call)."""
+ if not selected_name:
+ return TOOLS_DEFAULT
+ entry = mcp_manager.info().get(selected_name)
+ if not entry:
+ return TOOLS_DEFAULT
+ config = entry.get("config", {})
+ return f"# Tools for '{selected_name}'\n\n{self._fetch_tools_markdown(config)}"
+
+ def list_servers(self):
+ items = []
+ for entry in mcp_manager.info().values():
+ items.append(
+ {
+ "name": entry["name"],
+ "config": json.dumps(entry.get("config", {})),
+ }
+ )
+
+ if items:
+ return pd.DataFrame.from_records(items)
+ return pd.DataFrame.from_records([{"name": "-", "config": "-"}])
+
+ def select_server(self, mcp_list, ev: gr.SelectData):
+ if ev.value == "-" and ev.index[0] == 0:
+ gr.Info("No MCP server configured. Please add one first.")
+ return ""
+ if not ev.selected:
+ return ""
+ return mcp_list["name"][ev.index[0]]
+
+ def on_selected_server_change(self, selected_name):
+ if selected_name == "":
+ return (
+ gr.update(visible=False), # panel
+ gr.update(visible=False), # buttons
+ gr.update(visible=True), # delete
+ gr.update(visible=False), # delete_yes
+ gr.update(visible=False), # delete_no
+ gr.update(value="{}"), # config
+ gr.update(value=TOOLS_DEFAULT), # tools display
+ )
+
+ entry = mcp_manager.info()[selected_name]
+ config = entry.get("config", {})
+ config_str = json.dumps(config, indent=2)
+
+ return (
+ gr.update(visible=True),
+ gr.update(visible=True),
+ gr.update(visible=True),
+ gr.update(visible=False),
+ gr.update(visible=False),
+ gr.update(value=config_str),
+ gr.update(value=f"# Tools for '{selected_name}'\n\n⏳ Fetching tools..."),
+ )
+
+ def on_btn_delete_click(self):
+ return (
+ gr.update(visible=False),
+ gr.update(visible=True),
+ gr.update(visible=True),
+ )
+
+ def delete_server(self, selected_name):
+ try:
+ mcp_manager.delete(selected_name)
+ gr.Info(f"MCP server '{selected_name}' deleted successfully")
+ except Exception as e:
+ gr.Error(f"Failed to delete MCP server '{selected_name}': {e}")
+ return selected_name
+ return ""
+
+ def save_server(self, selected_name, config_str):
+ try:
+ config = json.loads(config_str)
+ except json.JSONDecodeError as e:
+ raise gr.Error(f"Invalid JSON: {e}")
+
+ try:
+ mcp_manager.update(selected_name, config)
+ gr.Info(f"MCP server '{selected_name}' saved successfully")
+ except Exception as e:
+ raise gr.Error(f"Failed to save MCP server '{selected_name}': {e}")
+
+ # Show loading placeholder; tools fetched in chained .then()
+ return f"# Tools for '{selected_name}'\n\n⏳ Refreshing tools..."
diff --git a/libs/ktem/ktem/pages/resources/__init__.py b/libs/ktem/ktem/pages/resources/__init__.py
index 35bf54c9..1fa90d37 100644
--- a/libs/ktem/ktem/pages/resources/__init__.py
+++ b/libs/ktem/ktem/pages/resources/__init__.py
@@ -4,6 +4,7 @@ from ktem.db.models import User, engine
from ktem.embeddings.ui import EmbeddingManagement
from ktem.index.ui import IndexManagement
from ktem.llms.ui import LLMManagement
+from ktem.mcp.ui import MCPManagement
from ktem.rerankings.ui import RerankingManagement
from sqlmodel import Session, select
@@ -28,6 +29,9 @@ class ResourcesTab(BasePage):
with gr.Tab("Rerankings") as self.rerank_management_tab:
self.rerank_management = RerankingManagement(self._app)
+ with gr.Tab("MCP Servers") as self.mcp_management_tab:
+ self.mcp_management = MCPManagement(self._app)
+
if self._app.f_user_management:
with gr.Tab("Users", visible=False) as self.user_management_tab:
self.user_management = UserManagement(self._app)
diff --git a/libs/ktem/ktem/pages/resources/user.py b/libs/ktem/ktem/pages/resources/user.py
index 49835ced..bb5cb56a 100644
--- a/libs/ktem/ktem/pages/resources/user.py
+++ b/libs/ktem/ktem/pages/resources/user.py
@@ -416,6 +416,18 @@ class UserManagement(BasePage):
return pwd, pwd_cnf
with Session(engine) as session:
+ # Check username uniqueness (excluding current user)
+ statement = select(User).where(
+ User.username_lower == usn.lower(),
+ User.id != selected_user_id,
+ )
+ existing = session.exec(statement).first()
+ if existing:
+ gr.Warning(
+ f'Username "{usn}" already exists. Please use a unique name.'
+ )
+ return pwd, pwd_cnf
+
statement = select(User).where(User.id == selected_user_id)
user = session.exec(statement).one()
user.username = usn
diff --git a/libs/ktem/ktem/reasoning/react.py b/libs/ktem/ktem/reasoning/react.py
index d73a5689..72532735 100644
--- a/libs/ktem/ktem/reasoning/react.py
+++ b/libs/ktem/ktem/reasoning/react.py
@@ -3,6 +3,7 @@ import logging
from typing import AnyStr, Optional, Type
from ktem.llms.manager import llms
+from ktem.mcp.manager import mcp_manager
from ktem.reasoning.base import BaseReasoning
from ktem.utils.generator import Generator
from ktem.utils.render import Render
@@ -16,6 +17,7 @@ from kotaemon.agents import (
ReactAgent,
WikipediaTool,
)
+from kotaemon.agents.tools.mcp import create_tools_from_config
from kotaemon.base import BaseComponent, Document, HumanMessage, Node, SystemMessage
from kotaemon.llms import ChatLLM, PromptTemplate
@@ -279,12 +281,21 @@ class ReactAgentPipeline(BaseReasoning):
tools = []
for tool_name in settings[f"reasoning.options.{_id}.tools"]:
- tool = TOOL_REGISTRY[tool_name]
- if tool_name == "SearchDoc":
- tool.retrievers = retrievers
- elif tool_name == "LLM":
- tool.llm = llm
- tools.append(tool)
+ if tool_name.startswith("[MCP] "):
+ server_name = tool_name[len("[MCP] ") :]
+ entry = mcp_manager.get(server_name)
+ if entry:
+ config = entry["config"]
+ enabled_tools = config.pop("enabled_tools", None)
+ mcp_tools = create_tools_from_config(config, enabled_tools)
+ tools.extend(mcp_tools)
+ else:
+ tool = TOOL_REGISTRY[tool_name]
+ if tool_name == "SearchDoc":
+ tool.retrievers = retrievers
+ elif tool_name == "LLM":
+ tool.llm = llm
+ tools.append(tool)
pipeline.agent.plugins = tools
pipeline.agent.output_lang = SUPPORTED_LANGUAGE_MAP.get(
settings["reasoning.lang"], "English"
@@ -304,6 +315,10 @@ class ReactAgentPipeline(BaseReasoning):
logger.exception(f"Failed to get LLM options: {e}")
tool_choices = ["Wikipedia", "Google", "LLM", "SearchDoc"]
+ try:
+ tool_choices += mcp_manager.get_enabled_tools()
+ except Exception as e:
+ logger.exception(f"Failed to get MCP tool options: {e}")
return {
"llm": {
diff --git a/libs/ktem/ktem/reasoning/rewoo.py b/libs/ktem/ktem/reasoning/rewoo.py
index 758dd2b1..84176f4c 100644
--- a/libs/ktem/ktem/reasoning/rewoo.py
+++ b/libs/ktem/ktem/reasoning/rewoo.py
@@ -4,6 +4,7 @@ from difflib import SequenceMatcher
from typing import AnyStr, Generator, Optional, Type
from ktem.llms.manager import llms
+from ktem.mcp.manager import mcp_manager
from ktem.reasoning.base import BaseReasoning
from ktem.utils.generator import Generator as GeneratorWrapper
from ktem.utils.render import Render
@@ -17,6 +18,7 @@ from kotaemon.agents import (
RewooAgent,
WikipediaTool,
)
+from kotaemon.agents.tools.mcp import create_tools_from_config
from kotaemon.base import BaseComponent, Document, HumanMessage, Node, SystemMessage
from kotaemon.llms import ChatLLM, PromptTemplate
@@ -405,12 +407,21 @@ class RewooAgentPipeline(BaseReasoning):
tools = []
for tool_name in settings[f"{prefix}.tools"]:
- tool = TOOL_REGISTRY[tool_name]
- if tool_name == "SearchDoc":
- tool.retrievers = retrievers
- elif tool_name == "LLM":
- tool.llm = solver_llm
- tools.append(tool)
+ if tool_name.startswith("[MCP] "):
+ server_name = tool_name[len("[MCP] ") :]
+ entry = mcp_manager.get(server_name)
+ if entry:
+ config = entry["config"]
+ enabled_tools = config.pop("enabled_tools", None)
+ mcp_tools = create_tools_from_config(config, enabled_tools)
+ tools.extend(mcp_tools)
+ else:
+ tool = TOOL_REGISTRY[tool_name]
+ if tool_name == "SearchDoc":
+ tool.retrievers = retrievers
+ elif tool_name == "LLM":
+ tool.llm = solver_llm
+ tools.append(tool)
pipeline.agent.plugins = tools
pipeline.agent.output_lang = SUPPORTED_LANGUAGE_MAP.get(
settings["reasoning.lang"], "English"
@@ -441,6 +452,10 @@ class RewooAgentPipeline(BaseReasoning):
logger.exception(f"Failed to get LLM options: {e}")
tool_choices = ["Wikipedia", "Google", "LLM", "SearchDoc"]
+ try:
+ tool_choices += mcp_manager.get_enabled_tools()
+ except Exception as e:
+ logger.exception(f"Failed to get MCP tool options: {e}")
return {
"planner_llm": {
diff --git a/libs/ktem/ktem/rerankings/manager.py b/libs/ktem/ktem/rerankings/manager.py
index 71fe813d..aa02dd88 100644
--- a/libs/ktem/ktem/rerankings/manager.py
+++ b/libs/ktem/ktem/rerankings/manager.py
@@ -166,11 +166,20 @@ class RerankingManager:
self.load()
- def update(self, name: str, spec: dict, default: bool):
- """Update a model in the pool"""
+ def update(self, name: str, spec: dict, default: bool, new_name: str = ""):
+ """Update a model in the pool, optionally renaming it."""
if not name:
raise ValueError("Name must not be empty")
+ if new_name and new_name != name:
+ if new_name in self._info:
+ raise ValueError(
+ f"Model '{new_name}' already exists. Use a unique name."
+ )
+ self.delete(name)
+ self.add(new_name, spec=spec, default=default)
+ return
+
try:
with Session(engine) as sess:
diff --git a/libs/ktem/ktem/rerankings/ui.py b/libs/ktem/ktem/rerankings/ui.py
index 6ac8fee7..a6b27594 100644
--- a/libs/ktem/ktem/rerankings/ui.py
+++ b/libs/ktem/ktem/rerankings/ui.py
@@ -35,6 +35,7 @@ class RerankingManagement(BasePage):
self.rerank_list = gr.DataFrame(
headers=["name", "vendor", "default"],
interactive=False,
+ column_widths=[30, 40, 30],
)
with gr.Column(visible=False) as self._selected_panel:
@@ -49,6 +50,10 @@ class RerankingManagement(BasePage):
"if no Reranking is specified for such components."
),
)
+ self.edit_name = gr.Textbox(
+ label="Name",
+ info="Edit to rename this Reranking model.",
+ )
self.edit_spec = gr.Textbox(
label="Specification",
info="Specification of the Embedding model in YAML format",
@@ -186,10 +191,10 @@ class RerankingManagement(BasePage):
self.btn_delete_yes,
self.btn_delete_no,
# edit section
+ self.edit_name,
self.edit_spec,
self.edit_spec_desc,
self.edit_default,
- self._check_connection_panel,
],
show_progress="hidden",
).success(lambda: gr.update(value=""), outputs=[self.connection_logs])
@@ -224,9 +229,11 @@ class RerankingManagement(BasePage):
self.save_rerank,
inputs=[
self.selected_rerank_name,
+ self.edit_name,
self.edit_default,
self.edit_spec,
],
+ outputs=[self.selected_rerank_name],
show_progress="hidden",
).then(
self.list_rerankings,
@@ -243,6 +250,7 @@ class RerankingManagement(BasePage):
def create_rerank(self, name, choices, spec, default):
try:
+ name = name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = (
reranking_models_manager.vendors()[choices].__module__
@@ -251,9 +259,11 @@ class RerankingManagement(BasePage):
)
reranking_models_manager.add(name, spec=spec, default=default)
- gr.Info(f'Create Reranking model "{name}" successfully')
+ gr.Info(f'Reranking model "{name}" created successfully')
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- raise gr.Error(f"Failed to create Reranking model {name}: {e}")
+ raise gr.Error(f"Failed to create Reranking model '{name}': {e}")
def list_rerankings(self):
"""List the Reranking models"""
@@ -286,17 +296,16 @@ class RerankingManagement(BasePage):
def on_selected_rerank_change(self, selected_rerank_name):
if selected_rerank_name == "":
- _check_connection_panel = gr.update(visible=False)
_selected_panel = gr.update(visible=False)
_selected_panel_btn = gr.update(visible=False)
btn_delete = gr.update(visible=True)
btn_delete_yes = gr.update(visible=False)
btn_delete_no = gr.update(visible=False)
+ edit_name = gr.update(value="")
edit_spec = gr.update(value="")
edit_spec_desc = gr.update(value="")
edit_default = gr.update(value=False)
else:
- _check_connection_panel = gr.update(visible=True)
_selected_panel = gr.update(visible=True)
_selected_panel_btn = gr.update(visible=True)
btn_delete = gr.update(visible=True)
@@ -307,6 +316,7 @@ class RerankingManagement(BasePage):
vendor_str = info["spec"].pop("__type__", "-").split(".")[-1]
vendor = reranking_models_manager.vendors()[vendor_str]
+ edit_name = selected_rerank_name
edit_spec = yaml.dump(info["spec"])
edit_spec_desc = format_description(vendor)
edit_default = info["default"]
@@ -317,10 +327,10 @@ class RerankingManagement(BasePage):
btn_delete,
btn_delete_yes,
btn_delete_no,
+ edit_name,
edit_spec,
edit_spec_desc,
edit_default,
- _check_connection_panel,
)
def on_btn_delete_click(self):
@@ -369,18 +379,27 @@ class RerankingManagement(BasePage):
return log_content
- def save_rerank(self, selected_rerank_name, default, spec):
+ def save_rerank(self, selected_rerank_name, edit_name, default, spec):
try:
+ new_name = edit_name.strip()
spec = yaml.load(spec, Loader=YAMLNoDateSafeLoader)
spec["__type__"] = reranking_models_manager.info()[selected_rerank_name][
"spec"
]["__type__"]
reranking_models_manager.update(
- selected_rerank_name, spec=spec, default=default
+ selected_rerank_name, spec=spec, default=default, new_name=new_name
)
- gr.Info(f'Save Reranking model "{selected_rerank_name}" successfully')
+ final_name = (
+ new_name if new_name != selected_rerank_name else selected_rerank_name
+ )
+ gr.Info(f'Reranking model "{final_name}" saved successfully')
+ return final_name
+ except ValueError as e:
+ raise gr.Error(str(e))
except Exception as e:
- gr.Error(f'Failed to save Embedding model "{selected_rerank_name}": {e}')
+ raise gr.Error(
+ f'Failed to save Reranking model "{selected_rerank_name}": {e}'
+ )
def delete_rerank(self, selected_rerank_name):
try:
diff --git a/libs/ktem/ktem/utils/render.py b/libs/ktem/ktem/utils/render.py
index 18b09fe7..3cd2d7f4 100644
--- a/libs/ktem/ktem/utils/render.py
+++ b/libs/ktem/ktem/utils/render.py
@@ -96,6 +96,7 @@ class Render:
return html_content
if not highlight_text:
+ phrase = "false"
try:
lang = detect(text.replace("\n", " "))["lang"]
if lang not in ["ja", "cn"]:
@@ -104,8 +105,6 @@ class Render:
]
highlight_text = highlight_words[0]
phrase = "true"
- else:
- phrase = "false"
highlight_text = (
text.replace("\n", "").replace('"', "").replace("'", "")
diff --git a/libs/ktem/pyproject.toml b/libs/ktem/pyproject.toml
index 2add23b6..7e6ac2ca 100644
--- a/libs/ktem/pyproject.toml
+++ b/libs/ktem/pyproject.toml
@@ -31,6 +31,7 @@ dependencies = [
"python-multipart==0.0.12", # required for gradio, pinning to avoid yanking issues with micropip (fixed in gradio >= 5.4.0)
"markdown>=3.6,<4",
"tzlocal>=5.0",
+ "mcp>=1.0.0",
]
authors = [
{ name = "@trducng", email = "john@cinnamon.is" },
diff --git a/pyproject.toml b/pyproject.toml
index f089d23a..88f5dd32 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,7 @@ dynamic = ["version"]
requires-python = ">= 3.10"
description = "Kotaemon App"
dependencies = [
- "kotaemon",
+ "kotaemon[all]",
"ktem"
]
authors = [
@@ -43,7 +43,7 @@ members = ["libs/kotaemon", "libs/ktem"]
[dependency-groups]
dev = [
"black",
- "coverage",
+ "coverage",
"flake8",
"ipython",
"pre-commit",
diff --git a/scripts/run_uv.sh b/scripts/run_uv.sh
deleted file mode 100755
index 2e508ad2..00000000
--- a/scripts/run_uv.sh
+++ /dev/null
@@ -1,187 +0,0 @@
-#!/bin/bash
-
-# Kotaemon UV Installation Script
-# This script provides a faster and simpler alternative to conda-based installation
-
-set -euo pipefail
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-function print_header() {
- echo -e "\n${BLUE}======================================================${NC}"
- echo -e "${BLUE}$1${NC}"
- echo -e "${BLUE}======================================================${NC}\n"
-}
-
-function print_success() {
- echo -e "${GREEN}✓ $1${NC}"
-}
-
-function print_warning() {
- echo -e "${YELLOW}⚠ $1${NC}"
-}
-
-function print_error() {
- echo -e "${RED}✗ $1${NC}"
-}
-
-function check_path_for_spaces() {
- if [[ $PWD =~ \ ]]; then
- print_error "The current workdir has whitespace which can lead to unintended behaviour. Please modify your path and continue later."
- exit 1
- fi
-}
-
-function check_python_version() {
- print_success "uv will automatically manage Python 3.10 - no manual Python installation needed!"
-}
-
-function install_uv() {
- if command -v uv &> /dev/null; then
- print_success "uv is already installed"
- return 0
- fi
-
- print_header "Installing uv package manager"
-
- if command -v curl &> /dev/null; then
- curl -LsSf https://astral.sh/uv/install.sh | sh
- elif command -v wget &> /dev/null; then
- wget -qO- https://astral.sh/uv/install.sh | sh
- else
- print_error "Neither curl nor wget is available. Please install one of them."
- exit 1
- fi
-
- # Add uv to PATH for current session
- export PATH="$HOME/.local/bin:$PATH"
-
- if command -v uv &> /dev/null; then
- print_success "uv installed successfully"
- else
- print_error "uv installation failed"
- exit 1
- fi
-}
-
-function setup_environment() {
- print_header "Setting up Python environment with uv"
-
- # Create virtual environment with Python 3.10 (uv will download Python if needed)
- if [[ ! -d ".venv" ]]; then
- print_success "Creating virtual environment with Python 3.10 (uv will download if needed)..."
- uv venv --python 3.10
- print_success "Created virtual environment"
- else
- print_warning "Virtual environment already exists"
- fi
-
- # Activate virtual environment
- source .venv/bin/activate
- print_success "Activated virtual environment"
-}
-
-function install_dependencies() {
- print_header "Installing dependencies"
-
- # Use the exact same approach as conda scripts for compatibility
- print_success "Installing kotaemon with exact dependency resolution..."
-
- # Install in the exact same order as the original conda script
- uv pip install -e "libs/kotaemon[all]"
- uv pip install -e "libs/ktem"
-
- # Fix known version conflicts mentioned in the README
- print_success "Resolving known version conflicts..."
- uv pip uninstall hnswlib chroma-hnswlib -y 2>/dev/null || true
- uv pip install chroma-hnswlib
-
- print_success "Dependencies installed successfully"
-}
-
-function setup_pdfjs() {
- print_header "Setting up PDF.js viewer"
-
- local pdfjs_dir="libs/ktem/ktem/assets/prebuilt/pdfjs-4.0.379-dist"
-
- if [[ -d "$pdfjs_dir" ]]; then
- print_warning "PDF.js already exists, skipping download"
- return 0
- fi
-
- if [[ -f "scripts/download_pdfjs.sh" ]]; then
- bash scripts/download_pdfjs.sh "$pdfjs_dir"
- print_success "PDF.js setup completed"
- else
- print_warning "PDF.js download script not found. You may need to set this up manually."
- fi
-}
-
-function setup_env_file() {
- print_header "Setting up environment configuration"
-
- if [[ ! -f ".env" && -f ".env.example" ]]; then
- cp .env.example .env
- print_success "Created .env file from template"
- print_warning "Please edit .env file to configure your API keys"
- elif [[ -f ".env" ]]; then
- print_warning ".env file already exists"
- else
- print_warning "No .env.example found. You may need to configure environment variables manually."
- fi
-}
-
-function launch_app() {
- print_header "Launching Kotaemon"
-
- print_success "Starting the application..."
- print_warning "The app will be automatically launched in your browser"
- print_warning "Default username and password are both 'admin'"
-
- # Set PDF.js environment variable if directory exists
- local pdfjs_dir="libs/ktem/ktem/assets/prebuilt/pdfjs-4.0.379-dist"
- if [[ -d "$pdfjs_dir" ]]; then
- export PDFJS_PREBUILT_DIR="$pdfjs_dir"
- fi
-
- python app.py
-}
-
-function main() {
- print_header "Kotaemon UV-based Installation"
-
- # Move to project root
- cd "$(dirname "${BASH_SOURCE[0]}")" && cd ..
-
- check_path_for_spaces
- check_python_version
- install_uv
- setup_environment
- install_dependencies
- setup_pdfjs
- setup_env_file
-
- print_success "Installation completed successfully!"
- echo
- print_warning "To launch the application in the future, run:"
- echo -e " ${BLUE}cd $(pwd)${NC}"
- echo -e " ${BLUE}source .venv/bin/activate${NC}"
- echo -e " ${BLUE}python app.py${NC}"
- echo
-
- read -p "Do you want to launch the application now? [Y/n] " -n 1 -r
- echo
- if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
- launch_app
- else
- print_success "You can launch the application later using the commands above."
- fi
-}
-
-# Run main function
-main "$@"
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 38368ca2..12fb09f5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1892,6 +1892,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
{ url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" },
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
@@ -1901,6 +1903,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
+ { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
@@ -1910,6 +1914,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
+ { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
+ { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
@@ -1919,6 +1925,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -1926,6 +1934,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
@@ -2499,6 +2509,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -2664,6 +2701,7 @@ adv = [
{ name = "llama-index" },
{ name = "llama-index-vector-stores-milvus" },
{ name = "llama-index-vector-stores-qdrant" },
+ { name = "mcp", extra = ["cli"] },
{ name = "onnxruntime" },
{ name = "sentence-transformers" },
{ name = "tabulate" },
@@ -2685,6 +2723,7 @@ all = [
{ name = "llama-index" },
{ name = "llama-index-vector-stores-milvus" },
{ name = "llama-index-vector-stores-qdrant" },
+ { name = "mcp", extra = ["cli"] },
{ name = "onnxruntime" },
{ name = "pre-commit" },
{ name = "pytest" },
@@ -2727,18 +2766,18 @@ requires-dist = [
{ name = "fastembed", marker = "extra == 'adv'" },
{ name = "flake8", marker = "extra == 'dev'" },
{ name = "googlesearch-python", marker = "extra == 'adv'", specifier = ">=1.2.4,<1.3" },
- { name = "gradio", specifier = ">=4.31.0,<4.40" },
+ { name = "gradio", specifier = ">=4.31.0,<5" },
{ name = "html2text", specifier = "==2024.2.26" },
{ name = "ipython", marker = "extra == 'dev'" },
{ name = "kotaemon", extras = ["adv", "dev"], marker = "extra == 'all'", editable = "libs/kotaemon" },
- { name = "langchain", specifier = ">=0.1.16,<0.2.16" },
- { name = "langchain-anthropic" },
- { name = "langchain-cohere", specifier = ">=0.2.4,<0.3.0" },
- { name = "langchain-community", specifier = ">=0.0.34,<=0.2.11" },
- { name = "langchain-google-genai", specifier = ">=1.0.3,<2.0.0" },
- { name = "langchain-mistralai" },
- { name = "langchain-ollama" },
- { name = "langchain-openai", specifier = ">=0.1.4,<0.2.0" },
+ { name = "langchain", specifier = "<2" },
+ { name = "langchain-anthropic", specifier = "<2" },
+ { name = "langchain-cohere", specifier = "<1" },
+ { name = "langchain-community", specifier = "<1" },
+ { name = "langchain-google-genai", specifier = "<5" },
+ { name = "langchain-mistralai", specifier = "<2" },
+ { name = "langchain-ollama", specifier = "<2" },
+ { name = "langchain-openai", specifier = "<2" },
{ name = "llama-cpp-python", marker = "extra == 'adv'", specifier = "<0.2.8" },
{ name = "llama-hub", specifier = ">=0.0.79,<0.1.0" },
{ name = "llama-index", specifier = ">=0.10.40,<0.11.0" },
@@ -2749,6 +2788,7 @@ requires-dist = [
{ name = "llama-index-vector-stores-qdrant", marker = "extra == 'adv'" },
{ name = "matplotlib" },
{ name = "matplotlib-inline" },
+ { name = "mcp", extras = ["cli"], marker = "extra == 'adv'", specifier = ">=1.0.0" },
{ name = "onnxruntime", marker = "extra == 'adv'", specifier = "<1.20" },
{ name = "openai", specifier = ">=1.23.6,<2" },
{ name = "openpyxl", specifier = ">=3.1.2,<3.2" },
@@ -2783,7 +2823,7 @@ provides-extras = ["adv", "dev", "all"]
name = "kotaemon-app"
source = { editable = "." }
dependencies = [
- { name = "kotaemon" },
+ { name = "kotaemon", extra = ["all"] },
{ name = "ktem" },
]
@@ -2803,7 +2843,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "kotaemon", editable = "libs/kotaemon" },
+ { name = "kotaemon", extras = ["all"], editable = "libs/kotaemon" },
{ name = "ktem", editable = "libs/ktem" },
]
@@ -2827,6 +2867,7 @@ dependencies = [
{ name = "gradio" },
{ name = "gradiologin" },
{ name = "markdown" },
+ { name = "mcp" },
{ name = "platformdirs" },
{ name = "pluggy" },
{ name = "python-decouple" },
@@ -2843,6 +2884,7 @@ requires-dist = [
{ name = "gradio", specifier = ">=4.31.0,<5" },
{ name = "gradiologin" },
{ name = "markdown", specifier = ">=3.6,<4" },
+ { name = "mcp", specifier = ">=1.0.0" },
{ name = "platformdirs", specifier = ">=4.2.1,<5" },
{ name = "pluggy", specifier = ">=1.5.0,<2" },
{ name = "python-decouple", specifier = ">=3.8,<4" },
@@ -3804,6 +3846,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
+[[package]]
+name = "mcp"
+version = "1.12.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx" },
+ { name = "httpx-sse" },
+ { name = "jsonschema" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "python-multipart" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "sse-starlette" },
+ { name = "starlette" },
+ { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/88/f6cb7e7c260cd4b4ce375f2b1614b33ce401f63af0f49f7141a2e9bf0a45/mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5", size = 431148, upload-time = "2025-08-07T20:31:18.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/68/316cbc54b7163fa22571dcf42c9cc46562aae0a021b974e0a8141e897200/mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789", size = 160145, upload-time = "2025-08-07T20:31:15.69Z" },
+]
+
+[package.optional-dependencies]
+cli = [
+ { name = "python-dotenv" },
+ { name = "typer" },
+]
+
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
@@ -5272,6 +5342,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload-time = "2024-12-18T11:31:22.821Z" },
]
+[[package]]
+name = "pydantic-settings"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
+]
+
[[package]]
name = "pydub"
version = "0.25.1"
@@ -5774,6 +5858,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/58/f515c44ba8c6fa5daa35134b94b99661ced852628c5505ead07b905c3fc7/rapidfuzz-3.14.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a4f18092db4825f2517d135445015b40033ed809a41754918a03ef062abe88a0", size = 1513859, upload-time = "2025-09-08T21:08:13.07Z" },
]
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
[[package]]
name = "regex"
version = "2025.9.18"
@@ -5966,6 +6064,128 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" },
]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" },
+ { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" },
+ { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+]
+
[[package]]
name = "rsa"
version = "4.9.1"
@@ -5980,28 +6200,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.13.2"
+version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" },
- { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" },
- { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" },
- { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" },
- { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" },
- { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" },
- { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" },
- { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" },
- { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" },
- { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" },
- { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" },
- { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" },
- { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" },
- { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" },
- { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" },
- { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" },
- { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
+ { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]
@@ -6472,6 +6691,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893, upload-time = "2025-09-17T21:44:39.764Z" },
]
+[[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 = "stack-data"
version = "0.6.3"
@@ -6916,6 +7147,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" },
]
+[[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 = "tzdata"
version = "2025.2"