MCP server

This commit is contained in:
Yiorgis Gozadinos 2025-06-19 13:52:55 +02:00
parent d7858d0283
commit 8fbf98466a
No known key found for this signature in database
6 changed files with 361 additions and 6 deletions

View file

@ -66,6 +66,9 @@ haiku-rag search "machine learning"
# Search with custom options
haiku-rag search "python programming" --limit 10 --k 100
# Start MCP server (default HTTP transport)
haiku-rag serve # --stdio for stdio transport or --sse for SSE transport
```
All commands support the `--db` option to specify a custom database path. Run
@ -74,6 +77,25 @@ haiku-rag command -h
```
to see additional parameters for a command.
## MCP Server
`haiku.rag` includes a Model Context Protocol (MCP) server that exposes RAG functionality as tools for AI assistants like Claude Desktop. The MCP server provides the following tools:
- `add_document_from_file` - Add documents from local file paths
- `add_document_from_url` - Add documents from URLs
- `add_document_from_text` - Add documents from raw text content
- `search_documents` - Search documents using hybrid search
- `get_document` - Retrieve specific documents by ID
- `list_documents` - List all documents with pagination
- `delete_document` - Delete documents by ID
You can start the server (using Streamble HTTP, stdio or SSE transports) with:
```bash
# Start with default HTTP transport
haiku-rag serve # --stdio for stdio transport or --sse for SSE transport
```
## Using `haiku.rag` from python
### Managing documents

View file

@ -21,6 +21,7 @@ classifiers = [
]
dependencies = [
"fastmcp>=2.8.1",
"httpx>=0.28.1",
"markitdown[audio-transcription,docx,pdf,pptx,xlsx]>=0.1.2",
"ollama>=0.5.1",

View file

@ -87,3 +87,21 @@ class HaikuRAGApp:
self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
self.console.print(content)
self.console.rule()
def serve(self, transport: str | None = None):
"""Start the MCP server."""
from haiku.rag.mcp import create_mcp_server
server = create_mcp_server(self.db_path)
if transport == "stdio":
self.console.print("[green]Starting MCP server on stdio...[/green]")
server.run("stdio")
elif transport == "sse":
self.console.print(
"[green]Starting MCP server with streamable HTTP...[/green]"
)
server.run("sse")
else:
self.console.print("[green]Starting MCP server with HTTP...[/green]")
server.run("streamable-http")

View file

@ -2,6 +2,7 @@ import asyncio
from pathlib import Path
import typer
from rich.console import Console
from haiku.rag.app import HaikuRAGApp
from haiku.rag.utils import get_default_data_dir
@ -10,6 +11,7 @@ cli = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
)
console = Console()
event_loop = asyncio.get_event_loop()
@ -18,7 +20,7 @@ def list_documents(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
@ -33,7 +35,7 @@ def add_document_text(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
@ -48,7 +50,7 @@ def add_document_src(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
@ -63,7 +65,7 @@ def get_document(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
@ -78,7 +80,7 @@ def delete_document(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
@ -104,12 +106,48 @@ def search(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="The path to the sqlite db to use",
help="Path to the SQLite database file",
),
):
app = HaikuRAGApp(db_path=db)
event_loop.run_until_complete(app.search(query=query, limit=limit, k=k))
@cli.command(
"serve", help="Start the haiku.rag MCP server (by default in streamable HTTP mode)"
)
def serve(
db: Path = typer.Option(
get_default_data_dir() / "haiku.rag.sqlite",
"--db",
help="Path to the SQLite database file",
),
stdio: bool = typer.Option(
False,
"--stdio",
help="Run MCP server on stdio Transport",
),
sse: bool = typer.Option(
False,
"--sse",
help="Run MCP server on SSE transport",
),
) -> None:
"""Start the MCP server."""
if stdio and sse:
console.print("[red]Error: Cannot use both --stdio and --http options[/red]")
raise typer.Exit(1)
app = HaikuRAGApp(db_path=db)
transport = None
if stdio:
transport = "stdio"
elif sse:
transport = "sse"
app.serve(transport=transport)
if __name__ == "__main__":
cli()

141
src/haiku/rag/mcp.py Normal file
View file

@ -0,0 +1,141 @@
from pathlib import Path
from typing import Any, Literal
from fastmcp import FastMCP
from pydantic import BaseModel
from haiku.rag.client import HaikuRAG
class SearchResult(BaseModel):
document_id: int
content: str
score: float
class DocumentResult(BaseModel):
id: int | None
content: str
uri: str | None = None
metadata: dict[str, Any] = {}
created_at: str
updated_at: str
def create_mcp_server(db_path: Path | Literal[":memory:"]) -> FastMCP:
"""Create an MCP server with the specified database path."""
mcp = FastMCP("haiku-rag")
@mcp.tool()
async def add_document_from_file(
file_path: str, metadata: dict[str, Any] | None = None
) -> int | None:
"""Add a document to the RAG system from a file path."""
try:
async with HaikuRAG(db_path) as rag:
document = await rag.create_document_from_source(
Path(file_path), metadata or {}
)
return document.id
except Exception:
return None
@mcp.tool()
async def add_document_from_url(
url: str, metadata: dict[str, Any] | None = None
) -> int | None:
"""Add a document to the RAG system from a URL."""
try:
async with HaikuRAG(db_path) as rag:
document = await rag.create_document_from_source(url, metadata or {})
return document.id
except Exception:
return None
@mcp.tool()
async def add_document_from_text(
content: str, uri: str | None = None, metadata: dict[str, Any] | None = None
) -> int | None:
"""Add a document to the RAG system from text content."""
try:
async with HaikuRAG(db_path) as rag:
document = await rag.create_document(content, uri, metadata or {})
return document.id
except Exception:
return None
@mcp.tool()
async def search_documents(query: str, limit: int = 5) -> list[SearchResult]:
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
try:
async with HaikuRAG(db_path) as rag:
results = await rag.search(query, limit)
search_results = []
for chunk, score in results:
search_results.append(
SearchResult(
document_id=chunk.document_id,
content=chunk.content,
score=score,
)
)
return search_results
except Exception:
return []
@mcp.tool()
async def get_document(document_id: int) -> DocumentResult | None:
"""Get a document by its ID."""
try:
async with HaikuRAG(db_path) as rag:
document = await rag.get_document_by_id(document_id)
if document is None:
return None
return DocumentResult(
id=document.id,
content=document.content,
uri=document.uri,
metadata=document.metadata,
created_at=str(document.created_at),
updated_at=str(document.updated_at),
)
except Exception:
return None
@mcp.tool()
async def list_documents(
limit: int | None = None, offset: int | None = None
) -> list[DocumentResult]:
"""List all documents with optional pagination."""
try:
async with HaikuRAG(db_path) as rag:
documents = await rag.list_documents(limit, offset)
return [
DocumentResult(
id=doc.id,
content=doc.content,
uri=doc.uri,
metadata=doc.metadata,
created_at=str(doc.created_at),
updated_at=str(doc.updated_at),
)
for doc in documents
]
except Exception:
return []
@mcp.tool()
async def delete_document(document_id: int) -> bool:
"""Delete a document by its ID."""
try:
async with HaikuRAG(db_path) as rag:
return await rag.delete_document(document_id)
except Exception:
return False
return mcp

135
uv.lock
View file

@ -206,6 +206,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" },
]
[[package]]
name = "authlib"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
@ -586,6 +598,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "fastmcp"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "python-dotenv" },
{ name = "rich" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/76/d9b352dd632dbac9eea3255df7bba6d83b2def769b388ec332368d7b4638/fastmcp-2.8.1.tar.gz", hash = "sha256:c89d8ce8bf53a166eda444cfdcb2c638170e62445487229fbaf340aed31beeaf", size = 2559427, upload-time = "2025-06-15T01:24:37.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/f9/ecb902857d634e81287f205954ef1c69637f27b487b109bf3b4b62d3dbe7/fastmcp-2.8.1-py3-none-any.whl", hash = "sha256:3b56a7bbab6bbac64d2a251a98b3dec5bb822ab1e4e9f20bb259add028b10d44", size = 138191, upload-time = "2025-06-15T01:24:35.964Z" },
]
[[package]]
name = "filelock"
version = "3.18.0"
@ -726,6 +757,7 @@ name = "haiku-rag"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
{ name = "httpx" },
{ name = "markitdown", extra = ["audio-transcription", "docx", "pdf", "pptx", "xlsx"] },
{ name = "ollama" },
@ -756,6 +788,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastmcp", specifier = ">=2.8.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "markitdown", extras = ["audio-transcription", "docx", "pdf", "pptx", "xlsx"], specifier = ">=0.1.2" },
{ name = "ollama", specifier = ">=0.5.1" },
@ -824,6 +857,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" },
]
[[package]]
name = "huggingface-hub"
version = "0.33.0"
@ -1057,6 +1099,26 @@ xlsx = [
{ name = "pandas" },
]
[[package]]
name = "mcp"
version = "1.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/f2/dc2450e566eeccf92d89a00c3e813234ad58e2ba1e31d11467a09ac4f3b9/mcp-1.9.4.tar.gz", hash = "sha256:cfb0bcd1a9535b42edaef89947b9e18a8feb49362e1cc059d6e7fc636f2cb09f", size = 333294, upload-time = "2025-06-12T08:20:30.158Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/fc/80e655c955137393c443842ffcc4feccab5b12fa7cb8de9ced90f90e6998/mcp-1.9.4-py3-none-any.whl", hash = "sha256:7fcf36b62936adb8e63f89346bccca1268eeca9bf6dfb562ee10b1dfbda9dac0", size = 130232, upload-time = "2025-06-12T08:20:28.551Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -1374,6 +1436,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/16/873b955beda7bada5b0d798d3a601b2ff210e44ad5169f6d405b93892103/onnxruntime-1.22.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64845709f9e8a2809e8e009bc4c8f73b788cee9c6619b7d9930344eae4c9cd36", size = 16427482, upload-time = "2025-05-09T20:26:20.376Z" },
]
[[package]]
name = "openapi-pydantic"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
@ -1835,6 +1909,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
]
[[package]]
name = "pydub"
version = "0.25.1"
@ -1940,6 +2028,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "python-pptx"
version = "1.0.2"
@ -2193,6 +2290,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/98/e8bc58b178266eae2fcf4c9c7a8303a8d41164d781b32d71097924a6bebe/sqlite_vec-0.1.6-py3-none-win_amd64.whl", hash = "sha256:c65bcfd90fa2f41f9000052bcb8bb75d38240b2dae49225389eca6c3136d3f0c", size = 281540, upload-time = "2024-11-20T16:40:37.296Z" },
]
[[package]]
name = "sse-starlette"
version = "2.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" },
]
[[package]]
name = "standard-aifc"
version = "3.13.0"
@ -2215,6 +2324,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" },
]
[[package]]
name = "starlette"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/0332bd8a25779a0e2082b0e179805ad39afad642938b371ae0882e7f880d/starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", size = 2582856, upload-time = "2025-05-29T15:45:27.628Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/81/c60b35fe9674f63b38a8feafc414fca0da378a9dbd5fa1e0b8d23fcc7a9b/starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37", size = 72796, upload-time = "2025-05-29T15:45:26.305Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
@ -2402,6 +2523,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
]
[[package]]
name = "uvicorn"
version = "0.34.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
]
[[package]]
name = "virtualenv"
version = "20.31.2"