diff --git a/README.md b/README.md index d7a33989..9c73285b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b22d1428..9fc68928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/haiku/rag/app.py b/src/haiku/rag/app.py index 6629981f..1972afae 100644 --- a/src/haiku/rag/app.py +++ b/src/haiku/rag/app.py @@ -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") diff --git a/src/haiku/rag/cli.py b/src/haiku/rag/cli.py index fe48199e..21b5d4d1 100644 --- a/src/haiku/rag/cli.py +++ b/src/haiku/rag/cli.py @@ -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() diff --git a/src/haiku/rag/mcp.py b/src/haiku/rag/mcp.py new file mode 100644 index 00000000..7f06361d --- /dev/null +++ b/src/haiku/rag/mcp.py @@ -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 diff --git a/uv.lock b/uv.lock index 57e7b557..c5f66477 100644 --- a/uv.lock +++ b/uv.lock @@ -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"