diff --git a/.gitignore b/.gitignore index 505a3b1..e15106e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,216 @@ -# Python-generated files +# Byte-compiled / optimized / DLL files __pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info +*.py[codz] +*$py.class -# Virtual environments +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc .venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/pyproject.toml b/pyproject.toml index ef9a150..4ccbcee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "qdrant-mcp" +name = "vector-search-mcp" version = "0.1.0" description = "Add your description here" readme = "README.md" @@ -11,7 +11,7 @@ dependencies = [ ] [project.scripts] -qdrant-mcp = "qdrant_mcp:run" +vector-search-mcp = "vector_search_mcp:run" [build-system] requires = ["uv_build"] @@ -23,4 +23,16 @@ secret = "qdrant-mcp" [dependency-groups] dev = [ "fastembed>=0.7.3", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-sugar>=1.1.1", ] + +[tool.basedpyright] +reportAny = false +reportExplicitAny = false +enableTypeIgnoreComments = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/scripts/client.py b/scripts/client.py deleted file mode 100644 index 120ce0c..0000000 --- a/scripts/client.py +++ /dev/null @@ -1,16 +0,0 @@ -import asyncio -from fastmcp import Client -from fastembed import TextEmbedding - - -embedding_model = TextEmbedding() -client = Client("http://localhost:8000/sse") - -async def call_tool(input: str, collection: str): - embedding: list[float] = list(embedding_model.embed(input))[0].tolist() - - async with client: - result = await client.call_tool("semantic_search", {"embedding": embedding, "collection": collection}) - print(result) - -asyncio.run(call_tool("Dime sobre las cucarachas", "dummy_collection")) diff --git a/scripts/create_dummy_collection.py b/scripts/create_dummy_collection.py deleted file mode 100644 index 9089990..0000000 --- a/scripts/create_dummy_collection.py +++ /dev/null @@ -1,40 +0,0 @@ -from qdrant_client import QdrantClient -from fastembed import TextEmbedding -from qdrant_client.models import Distance, VectorParams, PointStruct -from qdrant_mcp.config import Settings - -settings = Settings() -embedding_model = TextEmbedding() -client = QdrantClient(url=settings.url, api_key=settings.api_key) - -documents: list[str] = [ - "Rick es el mas guapo", - "Los pulpos tienen tres corazones y sangre azul", - "Las cucarachas pueden vivir hasta una semana sin cabeza", - "Los koalas tienen huellas dactilares casi idénticas a las humanas", - "La miel nunca se echa a perder, incluso después de miles de años" -] -embeddings = list(embedding_model.embed(documents)) -size = len(embeddings[0]) - -_ = client.recreate_collection( - collection_name="dummy_collection", - vectors_config=VectorParams( - distance=Distance.COSINE, - size=size - ) -) - -for idx, (emb, document) in enumerate(zip(embeddings, documents)): - _ = client.upsert( - collection_name="dummy_collection", - points=[ - PointStruct( - id=idx, - vector=emb, - payload={ - "text": document - } - ) - ] - ) diff --git a/src/qdrant_mcp/__init__.py b/src/qdrant_mcp/__init__.py deleted file mode 100644 index 4761925..0000000 --- a/src/qdrant_mcp/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .main import mcp - -def run(): - mcp.run(transport="sse") diff --git a/src/vector_search_mcp/__init__.py b/src/vector_search_mcp/__init__.py new file mode 100644 index 0000000..48267d7 --- /dev/null +++ b/src/vector_search_mcp/__init__.py @@ -0,0 +1,7 @@ +from fastmcp.server.server import Transport + +from .main import mcp + + +def run(transport: Transport = "sse"): + mcp.run(transport=transport) diff --git a/src/qdrant_mcp/config.py b/src/vector_search_mcp/config.py similarity index 66% rename from src/qdrant_mcp/config.py rename to src/vector_search_mcp/config.py index ad48ba0..df7bae5 100644 --- a/src/qdrant_mcp/config.py +++ b/src/vector_search_mcp/config.py @@ -1,7 +1,6 @@ -from pydantic import Field from vault_settings import VaultSettings class Settings(VaultSettings): - url: str = Field(...) + url: str api_key: str | None = None diff --git a/src/qdrant_mcp/engine.py b/src/vector_search_mcp/engine.py similarity index 74% rename from src/qdrant_mcp/engine.py rename to src/vector_search_mcp/engine.py index 219e3d8..dabcc18 100644 --- a/src/qdrant_mcp/engine.py +++ b/src/vector_search_mcp/engine.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any +from typing import Any, final from qdrant_client import AsyncQdrantClient, models @@ -7,9 +7,10 @@ from .config import Settings from .models import SearchRow +@final class QdrantEngine: def __init__(self) -> None: - self.settings = Settings() + self.settings = Settings() # type: ignore[reportCallIssue] self.client = AsyncQdrantClient( url=self.settings.url, api_key=self.settings.api_key ) @@ -32,4 +33,8 @@ class QdrantEngine: score_threshold=threshold, ) - return [SearchRow(chunk_id=str(point.id), score=point.score, payload=point.payload) for point in points if point.payload is not None] + return [ + SearchRow(chunk_id=str(point.id), score=point.score, payload=point.payload) + for point in points + if point.payload is not None + ] diff --git a/src/qdrant_mcp/main.py b/src/vector_search_mcp/main.py similarity index 78% rename from src/qdrant_mcp/main.py rename to src/vector_search_mcp/main.py index ea337a5..68692a9 100644 --- a/src/qdrant_mcp/main.py +++ b/src/vector_search_mcp/main.py @@ -2,7 +2,7 @@ from fastmcp import FastMCP from .engine import QdrantEngine -mcp = FastMCP("Qdrant MCP") +mcp = FastMCP("Vector Search MCP") engine = QdrantEngine() diff --git a/src/qdrant_mcp/models.py b/src/vector_search_mcp/models.py similarity index 98% rename from src/qdrant_mcp/models.py rename to src/vector_search_mcp/models.py index 8684961..cf6b84c 100644 --- a/src/qdrant_mcp/models.py +++ b/src/vector_search_mcp/models.py @@ -1,6 +1,8 @@ from typing import Any + from pydantic import BaseModel + class SearchRow(BaseModel): chunk_id: str score: float diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7aa966c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +import subprocess +import time + +import pytest +from fastembed import TextEmbedding +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, PointStruct, VectorParams + + +@pytest.fixture(scope="session") +def embedding_model(): + return TextEmbedding() + + +@pytest.fixture(scope="session") +def qdrant_client(embedding_model: TextEmbedding): + client = QdrantClient(":memory:") + + documents: list[str] = [ + "Rick es el mas guapo", + "Los pulpos tienen tres corazones y sangre azul", + "Las cucarachas pueden vivir hasta una semana sin cabeza", + "Los koalas tienen huellas dactilares casi idénticas a las humanas", + "La miel nunca se echa a perder, incluso después de miles de años", + ] + embeddings = list(embedding_model.embed(documents)) + size = len(embeddings[0]) + + _ = client.recreate_collection( + collection_name="dummy_collection", + vectors_config=VectorParams(distance=Distance.COSINE, size=size), + ) + + for idx, (emb, document) in enumerate(zip(embeddings, documents)): + _ = client.upsert( + collection_name="dummy_collection", + points=[ + PointStruct(id=idx, vector=emb.tolist(), payload={"text": document}) + ], + ) + + yield client + + +@pytest.fixture(scope="session", autouse=True) +def run_mcp(): + # Start the MCP server in the background + process = subprocess.Popen( + ["uv", "run", "vector-search-mcp"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Give the server a moment to start up + time.sleep(2) + + try: + yield "http://localhost:8000/sse" + finally: + # Clean up the process when tests are done + process.terminate() + try: + _ = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + _ = process.wait() diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..2810498 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,31 @@ +import json + +from fastembed import TextEmbedding +from fastmcp import Client +from mcp.types import TextContent + + +async def test_call_tool(embedding_model: TextEmbedding, run_mcp: str): + input = "Quien es el mas guapo?" + collection = "dummy_collection" + + embedding: list[float] = list(embedding_model.embed(input))[0].tolist() + + client = Client(run_mcp) + + async with client: + name = "semantic_search" + body = {"embedding": embedding, "collection": collection} + result = await client.call_tool(name, body) + + content_block = result.content[0] + + assert isinstance(content_block, TextContent) + + deserialized_result = json.loads(content_block.text) + + top_result = deserialized_result[0] + + assert top_result["chunk_id"] == "0" + assert top_result["score"] > 0.7 + assert top_result["payload"] == {"text": "Rick es el mas guapo"} diff --git a/uv.lock b/uv.lock index bcc1539..04847a6 100644 --- a/uv.lock +++ b/uv.lock @@ -546,6 +546,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "isodate" version = "0.7.2" @@ -1006,6 +1015,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "portalocker" version = "2.10.1" @@ -1148,6 +1166,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1233,31 +1292,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/2d/4424d730b1eaf3318f9cb86681e1c284caafbebfe51b3635411930e3dad0/qdrant_client-1.13.0-py3-none-any.whl", hash = "sha256:63a063d5232618b609f2c438caf6f3afd3bd110dd80d01be20c596e516efab6b", size = 306439, upload-time = "2025-01-17T10:11:40.31Z" }, ] -[[package]] -name = "qdrant-mcp" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastmcp" }, - { name = "qdrant-client" }, - { name = "vault-settings" }, -] - -[package.dev-dependencies] -dev = [ - { name = "fastembed" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastmcp", specifier = ">=2.12.3" }, - { name = "qdrant-client", specifier = "==1.13" }, - { name = "vault-settings", specifier = ">=0.1.0" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "fastembed", specifier = ">=0.7.3" }] - [[package]] name = "referencing" version = "0.36.2" @@ -1453,6 +1487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + [[package]] name = "tokenizers" version = "0.22.1" @@ -1546,6 +1589,39 @@ wheels = [ { url = "https://gitea.ia-innovacion.work/api/packages/innovacion/pypi/files/vault-settings/0.1.0/vault_settings-0.1.0-py3-none-any.whl", hash = "sha256:6f413ea5a9d1ef34bcdae82a03b06d7238370ed967787091955b390fcbd6b925" }, ] +[[package]] +name = "vector-search-mcp" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "qdrant-client" }, + { name = "vault-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastembed" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-sugar" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.12.3" }, + { name = "qdrant-client", specifier = "==1.13" }, + { name = "vault-settings", specifier = ">=0.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "fastembed", specifier = ">=0.7.3" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-sugar", specifier = ">=1.1.1" }, +] + [[package]] name = "werkzeug" version = "3.1.1"