Run the DatabaseSink tests against Postgres + pgvector as well as SQLite
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s

The sink ships with a JSONB variant and is documented for asyncpg, but
the suite only ever exercised SQLite. The embeddings work will need
pgvector, so this adds a local Postgres stack and a way to run the
database tests on it, here and in CI.

- docker-compose.yml: ffb-postgres (pgvector/pgvector:pg16) on a private
  network, published on 127.0.0.1 only, with an init script that adds
  the vector extension to the test database and template1
- Makefile: pg-up/pg-down/pg-reset/pg-logs/pg-psql, test, test-pg,
  test-all, lint; .env is generated from .env.example with a random
  password, and FFB_TEST_DATABASE_URL from the environment wins over it
- tests: a db_url fixture parametrized over sqlite and postgres. The
  postgres leg skips unless FFB_TEST_DATABASE_URL is set, and creates a
  uniquely named database per test (with vector) that is dropped after.
  The DatabaseSink factories in the events, retention, correlation,
  soft-error and instrumentation tests now use it, so their 38 database
  tests run on both. The missing-events-table test accepts either
  backend's error. New tests check isolation and that vector works.
- asyncpg and pgvector added to the dev and test extras
- CI: a test-postgres job on FastMCP 2.12.2 and 4.x with a pgvector
  service container, failing if the Postgres cases skip
- CONTRIBUTING: how to run the suite against Postgres
This commit is contained in:
2026-09-29 19:33:41 -06:00
parent 9648440f20
commit daec2d9e72
16 changed files with 503 additions and 25 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copy to .env (make does this for you on first run) and adjust as needed.
COMPOSE_PROJECT_NAME=fastmcp-feedback-pkg
FFB_PG_IMAGE=pgvector/pgvector:pg16
FFB_PG_USER=ffb
FFB_PG_PASSWORD=change-me
FFB_PG_DB=ffb_test
# Published on 127.0.0.1 only. 55432 is commonly taken by other local stacks.
FFB_PG_PORT=55433
# Read by the test suite. Unset, the Postgres test cases skip.
# Each test gets its own database created on this server and dropped after.
FFB_TEST_DATABASE_URL=postgresql+asyncpg://ffb:change-me@127.0.0.1:55433/ffb_test
+52 -1
View File
@@ -36,11 +36,62 @@ jobs:
uv pip show fastmcp | grep -i '^version'
- name: Lint
run: uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/unit/test_instrumentation.py
run: uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py tests/unit/test_test_databases.py tests/unit/test_instrumentation.py
- name: Test
run: uv run --no-sync pytest -q --no-cov
# The DatabaseSink tests again, on Postgres + pgvector instead of SQLite.
# Oldest and newest supported FastMCP; the SQLite legs above cover the rest.
# act_runner runs the job in a container, so the service is reached by name.
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
fastmcp: ['==2.12.2', '>=4,<5']
services:
ffb-postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: ffb
POSTGRES_PASSWORD: ffb-ci
POSTGRES_DB: ffb_test
options: >-
--health-cmd "pg_isready -U ffb -d ffb_test"
--health-interval 2s
--health-timeout 3s
--health-retries 30
env:
FFB_TEST_DATABASE_URL: postgresql+asyncpg://ffb:ffb-ci@ffb-postgres:5432/ffb_test
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: '3.13'
- name: Install against fastmcp${{ matrix.fastmcp }}
run: |
uv venv
uv pip install -e ".[dev]" "fastmcp${{ matrix.fastmcp }}"
uv pip show fastmcp | grep -i '^version'
- name: Test with the Postgres cases enabled
run: make test-pg
- name: Postgres cases ran rather than skipped
run: |
out=$(uv run --no-sync pytest -q --no-cov -m postgres -rs)
echo "$out" | tail -3
if echo "$out" | grep -q 'SKIPPED'; then
echo "Postgres cases skipped; is the service reachable?"; exit 1
fi
# Newest FastMCP with no ceiling. Allowed to fail: it exists to say
# "a new major version is out and here is what breaks" before users do.
canary:
+30
View File
@@ -97,6 +97,36 @@ uv run pytest -m unit
uv run pytest -m integration
```
### Running the tests against Postgres
The `DatabaseSink` tests run on SQLite by default. Each one also has a
`[postgres]` variant that runs against a real Postgres 16 with pgvector
when `FFB_TEST_DATABASE_URL` is set, and skips otherwise. The repo ships a
compose stack for it (Docker required):
```bash
make pg-up # start ffb-postgres and wait until healthy (creates .env on first run)
make test-pg # whole suite, Postgres cases enabled
make test # whole suite, SQLite only
make test-all # both
make pg-down # stop it; the data volume is kept (pg-reset deletes it)
```
The server listens on `127.0.0.1` only, on `FFB_PG_PORT` from `.env`
(55433 by default; change it if that port is taken). `make pg-psql` opens a
shell on it. Every Postgres test gets its own freshly created database with
the `vector` extension, dropped when the test ends, so tests never see each
other's rows. To write a test that runs on both backends, take the `db_url`
fixture from `tests/conftest.py`: `db_url("name")` returns an async URL for
an empty database, the same one for the same name within a test.
To point the suite at another server, set the variable yourself; the user
needs permission to create and drop databases:
```bash
FFB_TEST_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/postgres uv run pytest -m postgres
```
### Writing Tests
- Place unit tests in `tests/unit/`
+62
View File
@@ -0,0 +1,62 @@
# Dev/test helpers. The Postgres stack exists only so the test suite can run
# against a real server; see CONTRIBUTING.md.
COMPOSE ?= docker compose
PYTEST ?= uv run --no-sync pytest
PYTEST_ARGS ?= -q --no-cov
# FFB_TEST_DATABASE_URL and friends come from .env when it exists. A value
# already in the environment (CI points it at a service container) wins.
ENV_TEST_DATABASE_URL := $(FFB_TEST_DATABASE_URL)
-include .env
ifneq ($(ENV_TEST_DATABASE_URL),)
FFB_TEST_DATABASE_URL := $(ENV_TEST_DATABASE_URL)
endif
.PHONY: help pg-up pg-down pg-logs pg-psql pg-reset test test-pg test-all lint
help:
@echo "pg-up start Postgres + pgvector and wait until healthy"
@echo "pg-down stop it (the data volume is kept)"
@echo "pg-reset stop it and delete the data volume"
@echo "pg-logs follow its logs"
@echo "pg-psql psql into the test database"
@echo "test test suite on SQLite only (Postgres cases skip)"
@echo "test-pg test suite with the Postgres cases enabled (needs pg-up)"
@echo "test-all both"
@echo "lint ruff"
# First run: copy the example and give it a random password.
.env:
@pw=$$(openssl rand -hex 16); sed "s/change-me/$$pw/g" .env.example > .env
@echo "wrote .env from .env.example"
pg-up: .env
$(COMPOSE) up -d --wait ffb-postgres
$(COMPOSE) logs --tail=20 ffb-postgres
pg-down:
$(COMPOSE) down
pg-reset:
$(COMPOSE) down -v
pg-logs:
$(COMPOSE) logs -f ffb-postgres
pg-psql:
$(COMPOSE) exec ffb-postgres psql -U "$(FFB_PG_USER)" -d "$(FFB_PG_DB)"
test:
env -u FFB_TEST_DATABASE_URL $(PYTEST) $(PYTEST_ARGS)
test-pg: .env
@test -n "$(FFB_TEST_DATABASE_URL)" || { echo "FFB_TEST_DATABASE_URL is not set (environment or .env)"; exit 1; }
@echo "$(PYTEST) $(PYTEST_ARGS) (Postgres cases enabled)"
@FFB_TEST_DATABASE_URL="$(FFB_TEST_DATABASE_URL)" $(PYTEST) $(PYTEST_ARGS)
test-all: test test-pg
lint:
uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py \
tests/unit/test_test_databases.py tests/unit/test_instrumentation.py
+31
View File
@@ -0,0 +1,31 @@
# Postgres + pgvector for running the test suite against a real server.
# Test-only: nothing here serves HTTP, so there is no caddy network.
# The port is published on 127.0.0.1 only, for pytest running on the host.
services:
ffb-postgres:
image: ${FFB_PG_IMAGE:-pgvector/pgvector:pg16}
restart: unless-stopped
environment:
POSTGRES_USER: ${FFB_PG_USER}
POSTGRES_PASSWORD: ${FFB_PG_PASSWORD}
POSTGRES_DB: ${FFB_PG_DB}
ports:
- "127.0.0.1:${FFB_PG_PORT:-55433}:5432"
volumes:
- ffb-postgres-data:/var/lib/postgresql/data
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
networks:
- internal
volumes:
ffb-postgres-data:
networks:
internal:
+6
View File
@@ -0,0 +1,6 @@
-- Runs once, on first start with an empty volume.
-- The test fixtures create a fresh database per test from template1, so the
-- extension goes there too; every database created afterwards inherits it.
CREATE EXTENSION IF NOT EXISTS vector;
\connect template1
CREATE EXTENSION IF NOT EXISTS vector;
+5
View File
@@ -56,6 +56,8 @@ mysql = ["pymysql>=1.1.0"]
dev = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
@@ -67,6 +69,8 @@ dev = [
test = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
@@ -119,6 +123,7 @@ markers = [
"integration: Integration tests for component interaction",
"slow: Slow running tests",
"database: Tests requiring database setup",
"postgres: Runs against the Postgres named by FFB_TEST_DATABASE_URL; skipped when unset",
]
filterwarnings = [
"ignore::DeprecationWarning",
+114
View File
@@ -0,0 +1,114 @@
"""Fresh databases for DatabaseSink tests: SQLite always, Postgres when asked.
Set FFB_TEST_DATABASE_URL to a ``postgresql+asyncpg://`` URL (``make test-pg``
does this from .env) and every test using the ``db_url`` fixture also runs
against that server. Each test gets its own uniquely named databases, created
on first use and dropped at teardown, so no two tests share tables.
"""
import asyncio
import os
import re
import threading
import uuid
from collections.abc import Coroutine
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import URL, make_url
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
ENV_VAR = "FFB_TEST_DATABASE_URL"
SKIP_REASON = f"{ENV_VAR} is not set; run `make pg-up` then `make test-pg`"
def postgres_url() -> URL | None:
raw = os.environ.get(ENV_VAR, "").strip()
if not raw:
return None
url = make_url(raw)
if not url.drivername.startswith("postgresql"):
raise ValueError(f"{ENV_VAR} must be a postgresql URL, got {url.drivername}")
return url
def run_blocking(coro: Coroutine[Any, Any, Any]) -> Any:
"""Run ``coro`` to completion on its own loop in a worker thread.
The sink factories in the tests are plain functions called from inside a
running event loop, so the admin work cannot await on that loop.
"""
box: dict[str, Any] = {}
def target() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as exc: # re-raised in the caller's thread
box["error"] = exc
thread = threading.Thread(target=target)
thread.start()
thread.join()
if "error" in box:
raise box["error"]
return box.get("value")
class PostgresDatabases:
"""Create databases on one server for a single test, then drop them all."""
def __init__(self, url: URL):
self.url = url
self.run_id = uuid.uuid4().hex[:10]
self.by_label: dict[str, str] = {}
def _admin(self, database: str | None = None):
url = self.url if database is None else self.url.set(database=database)
return create_async_engine(
url, isolation_level="AUTOCOMMIT", poolclass=NullPool
)
def url_for(self, label: str) -> str:
"""URL of this test's database for ``label``, created on first request."""
if label not in self.by_label:
safe = re.sub(r"[^a-z0-9_]", "_", label.lower())
name = f"ffb_t_{self.run_id}_{safe}"[:63]
run_blocking(self._create(name))
self.by_label[label] = name
return self.url.set(database=self.by_label[label]).render_as_string(
hide_password=False
)
async def _create(self, name: str) -> None:
engine = self._admin()
try:
async with engine.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{name}"'))
finally:
await engine.dispose()
# The compose init script puts vector in template1 already; CI's
# service container has no init script, so make sure here.
engine = self._admin(name)
try:
async with engine.connect() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
finally:
await engine.dispose()
def drop_all(self) -> None:
if self.by_label:
run_blocking(self._drop(list(self.by_label.values())))
self.by_label.clear()
async def _drop(self, names: list[str]) -> None:
engine = self._admin()
try:
async with engine.connect() as conn:
for name in names:
# FORCE: a sink a test forgot to close must not keep it alive.
await conn.execute(
text(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
)
finally:
await engine.dispose()
+33
View File
@@ -16,6 +16,8 @@ from fastmcp_feedback.database import FeedbackDatabase
from fastmcp_feedback.insights import FeedbackInsights
from fastmcp_feedback.models import Base, Feedback, FeedbackType, FeedbackStatus
from ._databases import SKIP_REASON, PostgresDatabases, postgres_url
@pytest_asyncio.fixture
async def event_loop():
@@ -177,6 +179,37 @@ def mock_database(mock_session):
return db
# Database backends for DatabaseSink tests
@pytest.fixture(
params=["sqlite", pytest.param("postgres", marks=pytest.mark.postgres)]
)
def db_backend(request) -> str:
"""Which database a ``db_url`` test runs on. Postgres needs FFB_TEST_DATABASE_URL."""
if request.param == "postgres" and postgres_url() is None:
pytest.skip(SKIP_REASON)
return request.param
@pytest.fixture
def db_url(db_backend, tmp_path):
"""Factory: ``db_url(name)`` returns an async URL for an empty database.
The same name within one test returns the same database, so a test can
reopen it; different names and different tests never share one. SQLite
databases are files under tmp_path; Postgres ones are created on the
server named by FFB_TEST_DATABASE_URL and dropped after the test.
"""
if db_backend == "sqlite":
yield lambda name="calls": f"sqlite+aiosqlite:///{tmp_path}/{name}.db"
return
databases = PostgresDatabases(postgres_url())
try:
yield lambda name="calls": databases.url_for(name)
finally:
databases.drop_all()
# Environment helpers
@pytest.fixture(autouse=True)
+2 -2
View File
@@ -37,7 +37,7 @@ SAME_CLIENT_RULE = RULE_USER_WINDOW if FASTMCP_4 else RULE_SESSION
@pytest.fixture
async def db(tmp_path):
async def db(db_url):
"""Make DatabaseSinks whose engines are always disposed, even on failure.
An undisposed aiosqlite engine keeps a worker thread alive and the test
@@ -46,7 +46,7 @@ async def db(tmp_path):
made = []
def factory(name="calls", cls=DatabaseSink):
sink = cls(f"sqlite+aiosqlite:///{tmp_path}/{name}.db", create_tables=True)
sink = cls(db_url(name), create_tables=True)
made.append(sink)
return sink
+6 -5
View File
@@ -14,7 +14,7 @@ import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import MetaData, func, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import DBAPIError
from fastmcp_feedback.instrumentation import (
REDACTED,
@@ -30,13 +30,13 @@ from fastmcp_feedback.instrumentation import (
@pytest.fixture
async def db(tmp_path):
async def db(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls", **kwargs):
kwargs.setdefault("create_tables", True)
sink = DatabaseSink(f"sqlite+aiosqlite:///{tmp_path}/{name}.db", **kwargs)
sink = DatabaseSink(db_url(name), **kwargs)
made.append(sink)
return sink
@@ -437,7 +437,7 @@ class TestSinks:
assert len(await table_rows(store, store.events)) == 1
assert await table_rows(store, store.table) == []
async def test_calls_stored_when_events_table_missing(self, tmp_path, db):
async def test_calls_stored_when_events_table_missing(self, db):
store = db("old", create_tables=False)
old = MetaData()
build_metadata("ffb_", old)
@@ -451,7 +451,8 @@ class TestSinks:
ok=True,
mode="meta",
)
with pytest.raises(OperationalError, match="no such table"):
# SQLite raises OperationalError, Postgres ProgrammingError.
with pytest.raises(DBAPIError, match="no such table|does not exist"):
await store.write([rec, event()])
assert len(await table_rows(store, store.table)) == 1
+8 -9
View File
@@ -373,9 +373,8 @@ class TestSinks:
class TestDatabaseSink:
async def test_end_to_end_with_created_tables(self, tmp_path):
url = f"sqlite+aiosqlite:///{tmp_path}/calls.db"
db = DatabaseSink(url, create_tables=True)
async def test_end_to_end_with_created_tables(self, db_url):
db = DatabaseSink(db_url(), create_tables=True)
app, _, mw = make_app(sinks=[db], mode="full", server_version="9.9")
await call(app, "create_token", {"label": "ci"})
with pytest.raises(ToolError):
@@ -397,8 +396,8 @@ class TestDatabaseSink:
assert rows[0]["server_version"] == "9.9"
assert "hunter2" not in json.dumps([dict(r) for r in rows], default=str)
async def test_prefix_and_host_owned_engine(self, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/host.db")
async def test_prefix_and_host_owned_engine(self, db_url):
engine = create_async_engine(db_url("host"))
db = DatabaseSink(engine, prefix="qa_", create_tables=True)
app, _, mw = make_app(sinks=[db])
await call(app, "echo", {"text": "hi"})
@@ -414,8 +413,8 @@ class TestDatabaseSink:
"qa_tool_calls",
]
async def test_no_tables_created_by_default(self, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/host.db")
async def test_no_tables_created_by_default(self, db_url):
engine = create_async_engine(db_url("host"))
app, _, mw = make_app(sinks=[DatabaseSink(engine)])
result = await call(app, "echo", {"text": "hi"})
await mw.flush()
@@ -429,9 +428,9 @@ class TestDatabaseSink:
assert tables == []
assert mw.dispatcher.sink_errors == 1
async def test_absent_payloads_are_sql_null(self, tmp_path):
async def test_absent_payloads_are_sql_null(self, db_url):
"""Meta mode stores no payloads; they must be SQL NULL, not JSON null."""
db = DatabaseSink(f"sqlite+aiosqlite:///{tmp_path}/n.db", create_tables=True)
db = DatabaseSink(db_url("n"), create_tables=True)
app, _, mw = make_app(sinks=[db])
await call(app, "echo", {"text": "hi"})
await mw.flush()
+2 -4
View File
@@ -21,14 +21,12 @@ from fastmcp_feedback.instrumentation import (
@pytest.fixture
async def db(tmp_path):
async def db(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls", **kwargs):
sink = DatabaseSink(
f"sqlite+aiosqlite:///{tmp_path}/{name}.db", create_tables=True, **kwargs
)
sink = DatabaseSink(db_url(name), create_tables=True, **kwargs)
made.append(sink)
return sink
+2 -4
View File
@@ -39,14 +39,12 @@ except TypeError: # added in FastMCP 3.4
@pytest.fixture
async def db(tmp_path):
async def db(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls"):
sink = DatabaseSink(
f"sqlite+aiosqlite:///{tmp_path}/{name}.db", create_tables=True
)
sink = DatabaseSink(db_url(name), create_tables=True)
made.append(sink)
return sink
+70
View File
@@ -0,0 +1,70 @@
"""Checks on the db_url fixture itself: isolation between tests, and pgvector."""
from datetime import UTC, datetime
import pytest
from sqlalchemy import func, select, text
from fastmcp_feedback.instrumentation import DatabaseSink, ToolCallRecord
SHARED_ID = "00000000-0000-0000-0000-00000000f00d"
@pytest.fixture
async def sink(db_url):
made = DatabaseSink(db_url(), create_tables=True)
yield made
await made.engine.dispose()
async def write_fixed_row(sink):
"""Insert a row with a fixed primary key and return the table's row count.
Two tests that did this against one shared database would collide on the
key, so each passing proves it had a database of its own.
"""
rec = ToolCallRecord(
id=SHARED_ID,
tool="t",
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=True,
mode="meta",
)
await sink.write([rec])
async with sink.engine.connect() as conn:
return (
await conn.execute(select(func.count()).select_from(sink.table))
).scalar()
class TestIsolation:
async def test_first_writer(self, sink):
assert await write_fixed_row(sink) == 1
async def test_second_writer(self, sink):
assert await write_fixed_row(sink) == 1
def test_names_map_to_databases(self, db_url):
assert db_url("a") == db_url("a")
assert db_url("a") != db_url("b")
async def test_vector_extension_available(db_url, db_backend):
if db_backend != "postgres":
pytest.skip("pgvector is Postgres only")
sink = DatabaseSink(db_url())
try:
async with sink.engine.connect() as conn:
version = (
await conn.execute(
text("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
)
).scalar()
distance = (
await conn.execute(text("SELECT '[1,2,3]'::vector <-> '[1,2,4]'"))
).scalar()
finally:
await sink.engine.dispose()
assert version is not None
assert distance == 1.0
Generated
+67
View File
@@ -51,6 +51,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
@@ -564,7 +612,9 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "mypy" },
{ name = "pgvector" },
{ name = "psycopg2-binary" },
{ name = "pymysql" },
{ name = "pytest" },
@@ -577,7 +627,9 @@ all = [
]
dev = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "mypy" },
{ name = "pgvector" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -598,6 +650,8 @@ postgresql = [
]
test = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "pgvector" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -611,9 +665,13 @@ requires-dist = [
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.22.1" },
{ name = "aiosqlite", marker = "extra == 'instrumentation'", specifier = ">=0.22.1" },
{ name = "aiosqlite", marker = "extra == 'test'", specifier = ">=0.22.1" },
{ name = "asyncpg", marker = "extra == 'dev'", specifier = ">=0.31.0" },
{ name = "asyncpg", marker = "extra == 'test'", specifier = ">=0.31.0" },
{ name = "fastmcp", specifier = ">=2.12.2,<5" },
{ name = "fastmcp-feedback", extras = ["postgresql", "mysql", "dev"], marker = "extra == 'all'" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.7.0" },
{ name = "pgvector", marker = "extra == 'dev'", specifier = ">=0.5.0" },
{ name = "pgvector", marker = "extra == 'test'", specifier = ">=0.5.0" },
{ name = "psycopg2-binary", marker = "extra == 'postgresql'", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pymysql", marker = "extra == 'mysql'", specifier = ">=1.1.0" },
@@ -1264,6 +1322,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
]
[[package]]
name = "pgvector"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"